This commit is contained in:
Marc Rejohn Castillano 2026-03-03 07:38:40 +08:00
parent 7115e2df05
commit d9270b3edf
18 changed files with 3638 additions and 8 deletions

View File

@ -0,0 +1,412 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../utils/app_time.dart';
import 'supabase_provider.dart';
//
// Report date-range model
//
/// Describes the selected date range along with a human-readable label
/// (e.g. "Last 30 Days", "This Month").
@immutable
class ReportDateRange {
const ReportDateRange({
required this.start,
required this.end,
required this.label,
});
final DateTime start;
final DateTime end;
final String label;
DateTimeRange get dateTimeRange => DateTimeRange(start: start, end: end);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ReportDateRange &&
runtimeType == other.runtimeType &&
start == other.start &&
end == other.end &&
label == other.label;
@override
int get hashCode => Object.hash(start, end, label);
}
/// Builds a default date range of "Last 30 Days" in the Asia/Manila timezone.
ReportDateRange _defaultDateRange() {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
final start = today.subtract(const Duration(days: 30));
final end = today.add(const Duration(days: 1));
return ReportDateRange(start: start, end: end, label: 'Last 30 Days');
}
final reportDateRangeProvider = StateProvider<ReportDateRange>((ref) {
return _defaultDateRange();
});
//
// Report widget toggle (which charts to show)
//
/// Each report widget that can be toggled on/off.
enum ReportWidgetType {
ticketsByStatus('Tickets by Status', ReportSection.counts),
tasksByStatus('Tasks by Status', ReportSection.counts),
conversionRate('Ticket-to-Task Conversion', ReportSection.counts),
requestType('Request Type Distribution', ReportSection.counts),
requestCategory('Request Category Distribution', ReportSection.counts),
tasksByHour('Tasks Created by Hour', ReportSection.trends),
ticketsByHour('Tickets Created by Hour', ReportSection.trends),
monthlyOverview('Monthly Overview', ReportSection.trends),
topOfficesTickets('Top Offices by Tickets', ReportSection.rankings),
topOfficesTasks('Top Offices by Tasks', ReportSection.rankings),
topTicketSubjects('Top Ticket Subjects', ReportSection.rankings),
topTaskSubjects('Top Task Subjects', ReportSection.rankings),
avgResolution('Avg Resolution Time by Office', ReportSection.performance),
staffWorkload('IT Staff Workload', ReportSection.performance);
const ReportWidgetType(this.label, this.section);
final String label;
final ReportSection section;
}
enum ReportSection {
counts('Counts'),
trends('Trends'),
rankings('Rankings'),
performance('Performance');
const ReportSection(this.label);
final String label;
}
/// Which widgets to include on the reports screen (and in PDF export).
/// Defaults to all enabled.
final reportWidgetToggleProvider = StateProvider<Set<ReportWidgetType>>(
(ref) => ReportWidgetType.values.toSet(),
);
//
// Helper: build RPC params from the current date range
//
Map<String, dynamic> _rangeParams(ReportDateRange range) => {
'p_start': range.start.toUtc().toIso8601String(),
'p_end': range.end.toUtc().toIso8601String(),
};
//
// FutureProviders one per report RPC
//
/// Generic helper to call an RPC and parse each row with [mapper].
Future<List<T>> _callRpc<T>(
Ref ref,
String rpcName,
Map<String, dynamic> params,
T Function(Map<String, dynamic>) mapper,
) async {
final client = ref.read(supabaseClientProvider);
final response = await client.rpc(rpcName, params: params);
final list = response as List<dynamic>;
return list.map((e) => mapper(Map<String, dynamic>.from(e as Map))).toList();
}
// --- 1. Tickets by status ---
final ticketsByStatusReportProvider =
FutureProvider.autoDispose<List<StatusCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_tickets_by_status',
_rangeParams(range),
(m) => StatusCount(
status: m['status'] as String? ?? 'unknown',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 2. Tasks by status ---
final tasksByStatusReportProvider =
FutureProvider.autoDispose<List<StatusCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_tasks_by_status',
_rangeParams(range),
(m) => StatusCount(
status: m['status'] as String? ?? 'unknown',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 3. Tasks by hour ---
final tasksByHourReportProvider = FutureProvider.autoDispose<List<HourCount>>((
ref,
) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_tasks_by_hour',
_rangeParams(range),
(m) => HourCount(
hour: (m['hour'] as num?)?.toInt() ?? 0,
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 4. Tickets by hour ---
final ticketsByHourReportProvider = FutureProvider.autoDispose<List<HourCount>>(
(ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_tickets_by_hour',
_rangeParams(range),
(m) => HourCount(
hour: (m['hour'] as num?)?.toInt() ?? 0,
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
},
);
// --- 5. Top offices by tickets ---
final topOfficesTicketsReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_top_offices_tickets',
{..._rangeParams(range), 'p_limit': 10},
(m) => NamedCount(
name: m['office_name'] as String? ?? '',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 6. Top offices by tasks ---
final topOfficesTasksReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_top_offices_tasks',
{..._rangeParams(range), 'p_limit': 10},
(m) => NamedCount(
name: m['office_name'] as String? ?? '',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 7. Top ticket subjects ---
final topTicketSubjectsReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_top_ticket_subjects',
{..._rangeParams(range), 'p_limit': 10, 'p_threshold': 0.4},
(m) => NamedCount(
name: m['subject_group'] as String? ?? '',
count: (m['ticket_count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 8. Top task subjects ---
final topTaskSubjectsReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_top_task_subjects',
{..._rangeParams(range), 'p_limit': 10, 'p_threshold': 0.4},
(m) => NamedCount(
name: m['subject_group'] as String? ?? '',
count: (m['task_count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 9. Monthly overview ---
final monthlyOverviewReportProvider =
FutureProvider.autoDispose<List<MonthlyOverview>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_monthly_overview',
_rangeParams(range),
(m) => MonthlyOverview(
month: m['month'] as String? ?? '',
ticketCount: (m['ticket_count'] as num?)?.toInt() ?? 0,
taskCount: (m['task_count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 10. Request type distribution ---
final requestTypeReportProvider = FutureProvider.autoDispose<List<NamedCount>>((
ref,
) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_request_type_distribution',
_rangeParams(range),
(m) => NamedCount(
name: m['request_type'] as String? ?? 'Unspecified',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 11. Request category distribution ---
final requestCategoryReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_request_category_distribution',
_rangeParams(range),
(m) => NamedCount(
name: m['request_category'] as String? ?? 'Unspecified',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 12. Avg resolution time by office ---
final avgResolutionReportProvider =
FutureProvider.autoDispose<List<OfficeResolution>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_avg_resolution_by_office',
_rangeParams(range),
(m) => OfficeResolution(
officeName: m['office_name'] as String? ?? '',
avgHours: (m['avg_hours'] as num?)?.toDouble() ?? 0,
),
);
});
// --- 13. Staff workload ---
final staffWorkloadReportProvider =
FutureProvider.autoDispose<List<StaffWorkload>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_staff_workload',
_rangeParams(range),
(m) => StaffWorkload(
staffName: m['staff_name'] as String? ?? '',
assignedCount: (m['assigned_count'] as num?)?.toInt() ?? 0,
completedCount: (m['completed_count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 14. Ticket-to-task conversion rate ---
final ticketToTaskRateReportProvider =
FutureProvider.autoDispose<ConversionRate>((ref) async {
final range = ref.watch(reportDateRangeProvider);
final client = ref.read(supabaseClientProvider);
final response = await client.rpc(
'report_ticket_to_task_rate',
params: _rangeParams(range),
);
final list = response as List<dynamic>;
if (list.isEmpty) {
return const ConversionRate(
totalTickets: 0,
promotedTickets: 0,
conversionRate: 0,
);
}
final m = Map<String, dynamic>.from(list.first as Map);
return ConversionRate(
totalTickets: (m['total_tickets'] as num?)?.toInt() ?? 0,
promotedTickets: (m['promoted_tickets'] as num?)?.toInt() ?? 0,
conversionRate: (m['conversion_rate'] as num?)?.toDouble() ?? 0,
);
});
//
// Data models for report results
//
@immutable
class StatusCount {
const StatusCount({required this.status, required this.count});
final String status;
final int count;
}
@immutable
class HourCount {
const HourCount({required this.hour, required this.count});
final int hour;
final int count;
}
@immutable
class NamedCount {
const NamedCount({required this.name, required this.count});
final String name;
final int count;
}
@immutable
class MonthlyOverview {
const MonthlyOverview({
required this.month,
required this.ticketCount,
required this.taskCount,
});
final String month;
final int ticketCount;
final int taskCount;
}
@immutable
class OfficeResolution {
const OfficeResolution({required this.officeName, required this.avgHours});
final String officeName;
final double avgHours;
}
@immutable
class StaffWorkload {
const StaffWorkload({
required this.staffName,
required this.assignedCount,
required this.completedCount,
});
final String staffName;
final int assignedCount;
final int completedCount;
}
@immutable
class ConversionRate {
const ConversionRate({
required this.totalTickets,
required this.promotedTickets,
required this.conversionRate,
});
final int totalTickets;
final int promotedTickets;
final double conversionRate;
}

View File

@ -16,6 +16,7 @@ import '../screens/notifications/notifications_screen.dart';
import '../screens/profile/profile_screen.dart';
import '../screens/shared/under_development_screen.dart';
import '../screens/shared/permissions_screen.dart';
import '../screens/reports/reports_screen.dart';
import '../screens/tasks/task_detail_screen.dart';
import '../screens/tasks/tasks_list_screen.dart';
import '../screens/tickets/ticket_detail_screen.dart';
@ -45,9 +46,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
final isSignedIn = session != null;
final profileAsync = ref.read(currentProfileProvider);
final isAdminRoute = state.matchedLocation.startsWith('/settings');
final isAdmin = profileAsync is AsyncData
? (profileAsync.value)?.role == 'admin'
: false;
final role = profileAsync is AsyncData
? (profileAsync.value)?.role
: null;
final isAdmin = role == 'admin';
final isReportsRoute = state.matchedLocation == '/reports';
final hasReportsAccess =
role == 'admin' || role == 'dispatcher' || role == 'it_staff';
if (!isSignedIn && !isAuthRoute) {
return '/login';
@ -58,6 +63,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
if (isAdminRoute && !isAdmin) {
return '/tickets';
}
if (isReportsRoute && !hasReportsAccess) {
return '/tickets';
}
return null;
},
routes: [
@ -122,11 +130,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/reports',
builder: (context, state) => const UnderDevelopmentScreen(
title: 'Reports',
subtitle: 'Reporting automation is under development.',
icon: Icons.analytics,
),
builder: (context, state) => const ReportsScreen(),
),
GoRoute(
path: '/settings/users',

View File

@ -0,0 +1,459 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/reports_provider.dart';
import '../../utils/app_time.dart';
/// A Metabase-inspired date filter with quick presets, relative offsets,
/// and a custom date-range picker.
class ReportDateFilter extends ConsumerWidget {
const ReportDateFilter({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final range = ref.watch(reportDateRangeProvider);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = theme.textTheme;
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(Icons.calendar_today, size: 18, color: colors.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(range.label, style: text.labelLarge),
Text(
AppTime.formatDateRange(range.dateTimeRange),
style: text.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
FilledButton.tonalIcon(
onPressed: () => _showDateFilterDialog(context, ref),
icon: const Icon(Icons.tune, size: 18),
label: const Text('Change'),
),
],
),
),
);
}
void _showDateFilterDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => _DateFilterDialog(
current: ref.read(reportDateRangeProvider),
onApply: (newRange) {
ref.read(reportDateRangeProvider.notifier).state = newRange;
},
),
);
}
}
//
// Date filter dialog with three tabs
//
class _DateFilterDialog extends StatefulWidget {
const _DateFilterDialog({required this.current, required this.onApply});
final ReportDateRange current;
final ValueChanged<ReportDateRange> onApply;
@override
State<_DateFilterDialog> createState() => _DateFilterDialogState();
}
class _DateFilterDialogState extends State<_DateFilterDialog>
with SingleTickerProviderStateMixin {
late TabController _tabController;
// Relative tab state
int _relativeAmount = 30;
String _relativeUnit = 'days';
// Custom tab state
DateTime? _customStart;
DateTime? _customEnd;
static const _units = [
'minutes',
'hours',
'days',
'weeks',
'months',
'quarters',
'years',
];
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_customStart = widget.current.start;
_customEnd = widget.current.end;
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = theme.textTheme;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Row(
children: [
Icon(Icons.date_range, color: colors.primary),
const SizedBox(width: 8),
Text('Filter Date Range', style: text.titleMedium),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
),
TabBar(
controller: _tabController,
labelStyle: text.labelMedium,
tabs: const [
Tab(text: 'Presets'),
Tab(text: 'Relative'),
Tab(text: 'Custom'),
],
),
Flexible(
child: TabBarView(
controller: _tabController,
children: [
_buildPresetsTab(context),
_buildRelativeTab(context),
_buildCustomTab(context),
],
),
),
],
),
),
);
}
// Presets Tab
Widget _buildPresetsTab(BuildContext context) {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
final presets = <_Preset>[
_Preset('Today', today, today.add(const Duration(days: 1))),
_Preset('Yesterday', today.subtract(const Duration(days: 1)), today),
_Preset(
'Last 7 Days',
today.subtract(const Duration(days: 7)),
today.add(const Duration(days: 1)),
),
_Preset(
'Last 30 Days',
today.subtract(const Duration(days: 30)),
today.add(const Duration(days: 1)),
),
_Preset(
'Last 90 Days',
today.subtract(const Duration(days: 90)),
today.add(const Duration(days: 1)),
),
_Preset(
'This Week',
today.subtract(Duration(days: today.weekday - 1)),
today.add(const Duration(days: 1)),
),
_Preset(
'This Month',
DateTime(now.year, now.month, 1),
DateTime(now.year, now.month + 1, 1),
),
_Preset(
'This Quarter',
DateTime(now.year, ((now.month - 1) ~/ 3) * 3 + 1, 1),
DateTime(now.year, ((now.month - 1) ~/ 3) * 3 + 4, 1),
),
_Preset(
'This Year',
DateTime(now.year, 1, 1),
DateTime(now.year + 1, 1, 1),
),
_Preset(
'Last Week',
today.subtract(Duration(days: today.weekday + 6)),
today.subtract(Duration(days: today.weekday - 1)),
),
_Preset(
'Last Month',
DateTime(now.year, now.month - 1, 1),
DateTime(now.year, now.month, 1),
),
_Preset(
'Last Quarter',
DateTime(now.year, ((now.month - 1) ~/ 3) * 3 - 2, 1),
DateTime(now.year, ((now.month - 1) ~/ 3) * 3 + 1, 1),
),
_Preset(
'Last Year',
DateTime(now.year - 1, 1, 1),
DateTime(now.year, 1, 1),
),
];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: presets.map((p) {
final isSelected = widget.current.label == p.label;
return ChoiceChip(
label: Text(p.label),
selected: isSelected,
onSelected: (_) {
widget.onApply(
ReportDateRange(start: p.start, end: p.end, label: p.label),
);
Navigator.pop(context);
},
);
}).toList(),
),
);
}
// Relative Tab
Widget _buildRelativeTab(BuildContext context) {
final theme = Theme.of(context);
final text = theme.textTheme;
// Compute preview
final previewRange = _computeRelativeRange(_relativeAmount, _relativeUnit);
final previewText = AppTime.formatDateRange(previewRange.dateTimeRange);
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Previous', style: text.labelLarge),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 80,
child: TextFormField(
initialValue: _relativeAmount.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onChanged: (v) {
setState(() {
_relativeAmount = int.tryParse(v) ?? _relativeAmount;
});
},
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _relativeUnit,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: _units
.map((u) => DropdownMenuItem(value: u, child: Text(u)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _relativeUnit = v);
},
),
),
const SizedBox(width: 12),
Text('ago', style: text.bodyLarge),
],
),
const SizedBox(height: 16),
Text(
previewText,
style: text.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () {
widget.onApply(previewRange);
Navigator.pop(context);
},
child: const Text('Apply'),
),
),
],
),
);
}
ReportDateRange _computeRelativeRange(int amount, String unit) {
final now = AppTime.now();
final end = now;
DateTime start;
switch (unit) {
case 'minutes':
start = now.subtract(Duration(minutes: amount));
case 'hours':
start = now.subtract(Duration(hours: amount));
case 'days':
start = now.subtract(Duration(days: amount));
case 'weeks':
start = now.subtract(Duration(days: amount * 7));
case 'months':
start = DateTime(now.year, now.month - amount, now.day);
case 'quarters':
start = DateTime(now.year, now.month - (amount * 3), now.day);
case 'years':
start = DateTime(now.year - amount, now.month, now.day);
default:
start = now.subtract(Duration(days: amount));
}
return ReportDateRange(start: start, end: end, label: 'Last $amount $unit');
}
// Custom Tab
Widget _buildCustomTab(BuildContext context) {
final theme = Theme.of(context);
final text = theme.textTheme;
final colors = theme.colorScheme;
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Start Date', style: text.labelLarge),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.calendar_today, size: 16),
label: Text(
_customStart != null
? AppTime.formatDate(_customStart!)
: 'Select start date',
),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: _customStart ?? AppTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) setState(() => _customStart = picked);
},
),
const SizedBox(height: 16),
Text('End Date', style: text.labelLarge),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.calendar_today, size: 16),
label: Text(
_customEnd != null
? AppTime.formatDate(_customEnd!)
: 'Select end date',
),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: _customEnd ?? AppTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) setState(() => _customEnd = picked);
},
),
if (_customStart != null && _customEnd != null) ...[
const SizedBox(height: 12),
Text(
AppTime.formatDateRange(
DateTimeRange(start: _customStart!, end: _customEnd!),
),
style: text.bodyMedium?.copyWith(color: colors.onSurfaceVariant),
),
],
const SizedBox(height: 20),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: (_customStart != null && _customEnd != null)
? () {
widget.onApply(
ReportDateRange(
start: _customStart!,
end: _customEnd!.add(const Duration(days: 1)),
label: 'Custom Range',
),
);
Navigator.pop(context);
}
: null,
child: const Text('Apply'),
),
),
],
),
);
}
}
class _Preset {
const _Preset(this.label, this.start, this.end);
final String label;
final DateTime start;
final DateTime end;
}

View File

@ -0,0 +1,252 @@
import 'dart:isolate';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../providers/reports_provider.dart';
import '../../utils/app_time.dart';
/// Captures all visible report charts via their [RepaintBoundary] keys
/// and generates a PDF document for export / printing.
class ReportPdfExport {
ReportPdfExport._();
/// Capture a [RepaintBoundary] as a PNG [Uint8List].
///
/// Yields to the event loop after each capture so the UI stays responsive.
static Future<Uint8List?> _captureWidget(GlobalKey key) async {
final boundary =
key.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) return null;
final image = await boundary.toImage(pixelRatio: 1.5);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
// Yield to the event loop so the UI thread doesn't freeze
await Future<void>.delayed(Duration.zero);
return byteData?.buffer.asUint8List();
}
/// Generates the full report PDF from the given [captureKeys] map.
///
/// [captureKeys] maps a human-readable chart title to the [GlobalKey]
/// attached to that chart's [RepaintBoundary].
///
/// [dateRange] is for display in the header.
/// [enabledWidgets] filters which charts to include.
static Future<Uint8List> generatePdf({
required Map<String, GlobalKey> captureKeys,
required ReportDateRange dateRange,
required Set<ReportWidgetType> enabledWidgets,
}) async {
// Load fonts
final regularFontData = await rootBundle.load(
'assets/fonts/Roboto-Regular.ttf',
);
final boldFontData = await rootBundle.load('assets/fonts/Roboto-Bold.ttf');
// Try to load logo (may not exist in all builds)
Uint8List? logoBytes;
try {
final logoData = await rootBundle.load('assets/crmc_logo.png');
logoBytes = logoData.buffer.asUint8List();
} catch (_) {
// Logo not available skip
}
// Capture chart images on the main thread (needs render objects)
final chartImages = <String, Uint8List>{};
for (final entry in captureKeys.entries) {
final bytes = await _captureWidget(entry.value);
if (bytes != null) {
chartImages[entry.key] = bytes;
}
}
final dateRangeText = AppTime.formatDateRange(dateRange.dateTimeRange);
final generated = AppTime.formatDate(AppTime.now());
final dateLabel = dateRange.label;
// Build the PDF on a background isolate so it doesn't freeze the UI ──
return Isolate.run(() {
return _buildPdfBytes(
chartImages: chartImages,
dateRangeText: dateRangeText,
dateLabel: dateLabel,
generated: generated,
regularFontData: regularFontData.buffer.asUint8List(),
boldFontData: boldFontData.buffer.asUint8List(),
logoBytes: logoBytes,
);
});
}
/// Pure function that assembles the PDF document from raw data.
/// Designed to run inside [Isolate.run].
static Future<Uint8List> _buildPdfBytes({
required Map<String, Uint8List> chartImages,
required String dateRangeText,
required String dateLabel,
required String generated,
required Uint8List regularFontData,
required Uint8List boldFontData,
Uint8List? logoBytes,
}) {
final regularFont = pw.Font.ttf(regularFontData.buffer.asByteData());
final boldFont = pw.Font.ttf(boldFontData.buffer.asByteData());
pw.MemoryImage? logoImage;
if (logoBytes != null) {
logoImage = pw.MemoryImage(logoBytes);
}
final doc = pw.Document();
// One page per chart to avoid TooManyPagesException and keep layout simple
// First chart gets the full header; subsequent ones get a compact header.
var isFirst = true;
for (final entry in chartImages.entries) {
doc.addPage(
pw.Page(
pageFormat: pdf.PdfPageFormat.a4.landscape,
margin: const pw.EdgeInsets.all(32),
theme: pw.ThemeData.withFont(base: regularFont, bold: boldFont),
build: (pw.Context ctx) {
final header = isFirst
? pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
if (logoImage != null)
pw.Container(
width: 50,
height: 50,
child: pw.Image(logoImage),
),
if (logoImage != null) pw.SizedBox(width: 12),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'TasQ Report',
style: pw.TextStyle(
fontSize: 20,
fontWeight: pw.FontWeight.bold,
),
),
pw.Text(
'$dateLabel$dateRangeText',
style: pw.TextStyle(
fontSize: 11,
color: pdf.PdfColors.grey700,
),
),
],
),
pw.Spacer(),
pw.Text(
'Generated: $generated',
style: pw.TextStyle(
fontSize: 9,
color: pdf.PdfColors.grey500,
),
),
],
),
pw.Divider(thickness: 0.5),
pw.SizedBox(height: 8),
],
)
: pw.Container(
margin: const pw.EdgeInsets.only(bottom: 8),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'TasQ Report',
style: pw.TextStyle(
fontSize: 10,
color: pdf.PdfColors.grey600,
),
),
pw.Text(
dateRangeText,
style: pw.TextStyle(
fontSize: 10,
color: pdf.PdfColors.grey600,
),
),
],
),
);
isFirst = false;
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
header,
pw.Text(
entry.key,
style: pw.TextStyle(
fontSize: 13,
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 6),
pw.Expanded(
child: pw.Center(
child: pw.Image(
pw.MemoryImage(entry.value),
fit: pw.BoxFit.contain,
),
),
),
pw.Container(
alignment: pw.Alignment.centerRight,
child: pw.Text(
'Page ${ctx.pageNumber} • Generated by TasQ',
style: pw.TextStyle(fontSize: 9, color: pdf.PdfColors.grey),
),
),
],
);
},
),
);
}
if (chartImages.isEmpty) {
doc.addPage(
pw.Page(
pageFormat: pdf.PdfPageFormat.a4.landscape,
margin: const pw.EdgeInsets.all(32),
theme: pw.ThemeData.withFont(base: regularFont, bold: boldFont),
build: (pw.Context ctx) => pw.Center(
child: pw.Text(
'No charts selected for export.',
style: pw.TextStyle(fontSize: 14),
),
),
),
);
}
return doc.save();
}
/// Show a print / share dialog for the generated PDF.
static Future<void> sharePdf(Uint8List pdfBytes) async {
await Printing.layoutPdf(
onLayout: (_) => pdfBytes,
name:
'TasQ_Report_${AppTime.formatDate(AppTime.now()).replaceAll(' ', '_')}.pdf',
);
}
}

View File

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/reports_provider.dart';
/// Collapsible panel for toggling which report widgets to include
/// on the screen and in the exported PDF.
class ReportWidgetSelector extends ConsumerWidget {
const ReportWidgetSelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final enabled = ref.watch(reportWidgetToggleProvider);
final theme = Theme.of(context);
final text = theme.textTheme;
return Card(
child: ExpansionTile(
leading: const Icon(Icons.widgets_outlined, size: 20),
title: Text('Widgets to Include', style: text.titleSmall),
subtitle: Text(
'${enabled.length} of ${ReportWidgetType.values.length} selected',
style: text.bodySmall,
),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
children: [
// Quick actions
Row(
children: [
TextButton.icon(
onPressed: () {
ref.read(reportWidgetToggleProvider.notifier).state =
ReportWidgetType.values.toSet();
},
icon: const Icon(Icons.select_all, size: 16),
label: const Text('Select All'),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () {
ref.read(reportWidgetToggleProvider.notifier).state = {};
},
icon: const Icon(Icons.deselect, size: 16),
label: const Text('Deselect All'),
),
],
),
const SizedBox(height: 4),
// Group by section
for (final section in ReportSection.values) ...[
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
section.label,
style: text.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
Wrap(
spacing: 8,
runSpacing: 4,
children: ReportWidgetType.values
.where((w) => w.section == section)
.map((w) {
final isSelected = enabled.contains(w);
return FilterChip(
label: Text(w.label),
selected: isSelected,
onSelected: (selected) {
final current = Set<ReportWidgetType>.from(enabled);
if (selected) {
current.add(w);
} else {
current.remove(w);
}
ref.read(reportWidgetToggleProvider.notifier).state =
current;
},
);
})
.toList(),
),
],
],
),
);
}
}

View File

@ -0,0 +1,348 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/reports_provider.dart';
import 'report_date_filter.dart';
import 'report_widget_selector.dart';
import 'report_pdf_export.dart';
import 'widgets/status_charts.dart';
import 'widgets/hourly_charts.dart';
import 'widgets/top_offices_charts.dart';
import 'widgets/top_subjects_charts.dart';
import 'widgets/monthly_overview_chart.dart';
import 'widgets/request_distribution_charts.dart';
import 'widgets/avg_resolution_chart.dart';
import 'widgets/staff_workload_chart.dart';
import 'widgets/conversion_rate_card.dart';
/// Main reports screen scrollable collection of chart widgets,
/// filterable by date range and toggleable per-widget, exportable to PDF.
class ReportsScreen extends ConsumerStatefulWidget {
const ReportsScreen({super.key});
@override
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
}
class _ReportsScreenState extends ConsumerState<ReportsScreen> {
// RepaintBoundary keys for PDF capture
final _keys = <ReportWidgetType, GlobalKey>{
for (final t in ReportWidgetType.values) t: GlobalKey(),
};
bool _exporting = false;
Future<void> _exportPdf() async {
setState(() => _exporting = true);
try {
final enabled = ref.read(reportWidgetToggleProvider);
final dateRange = ref.read(reportDateRangeProvider);
// Wait a frame so charts are rendered before capture
await Future<void>.delayed(const Duration(milliseconds: 200));
final captureKeys = <String, GlobalKey>{};
for (final type in ReportWidgetType.values) {
if (enabled.contains(type)) {
captureKeys[type.label] = _keys[type]!;
}
}
final pdfBytes = await ReportPdfExport.generatePdf(
captureKeys: captureKeys,
dateRange: dateRange,
enabledWidgets: enabled,
);
await ReportPdfExport.sharePdf(pdfBytes);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('PDF export failed: $e')));
}
} finally {
if (mounted) setState(() => _exporting = false);
}
}
@override
Widget build(BuildContext context) {
final enabled = ref.watch(reportWidgetToggleProvider);
final theme = Theme.of(context);
return Scaffold(
body: Column(
children: [
// Sticky top section
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
children: [
// Title row
Row(
children: [
Expanded(
child: Text(
'Reports',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
FilledButton.icon(
onPressed: _exporting ? null : _exportPdf,
icon: _exporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.picture_as_pdf, size: 18),
label: Text(_exporting ? 'Exporting…' : 'Export PDF'),
),
],
),
const SizedBox(height: 8),
const ReportDateFilter(),
const SizedBox(height: 4),
const ReportWidgetSelector(),
const SizedBox(height: 8),
],
),
),
),
),
// Scrollable chart area
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: _buildCharts(context, enabled),
),
),
),
),
],
),
);
}
Widget _buildCharts(BuildContext context, Set<ReportWidgetType> enabled) {
if (enabled.isEmpty) {
return SizedBox(
height: 300,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.analytics_outlined,
size: 48,
color: Theme.of(context).colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'No widgets selected',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 4),
Text(
'Use the "Widgets to Include" panel to enable reports.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 600;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Status donuts (paired on desktop)
if (_anyEnabled(enabled, [
ReportWidgetType.ticketsByStatus,
ReportWidgetType.tasksByStatus,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.ticketsByStatus))
TicketsByStatusChart(
repaintKey: _keys[ReportWidgetType.ticketsByStatus],
),
if (enabled.contains(ReportWidgetType.tasksByStatus))
TasksByStatusChart(
repaintKey: _keys[ReportWidgetType.tasksByStatus],
),
],
),
// Conversion rate KPI
if (enabled.contains(ReportWidgetType.conversionRate))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ConversionRateCard(
repaintKey: _keys[ReportWidgetType.conversionRate],
),
),
// Request type + category donuts
if (_anyEnabled(enabled, [
ReportWidgetType.requestType,
ReportWidgetType.requestCategory,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.requestType))
RequestTypeChart(
repaintKey: _keys[ReportWidgetType.requestType],
),
if (enabled.contains(ReportWidgetType.requestCategory))
RequestCategoryChart(
repaintKey: _keys[ReportWidgetType.requestCategory],
),
],
),
// Monthly overview (full width)
if (enabled.contains(ReportWidgetType.monthlyOverview))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MonthlyOverviewChart(
repaintKey: _keys[ReportWidgetType.monthlyOverview],
),
),
// Hourly charts (paired)
if (_anyEnabled(enabled, [
ReportWidgetType.tasksByHour,
ReportWidgetType.ticketsByHour,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.tasksByHour))
TasksByHourChart(
repaintKey: _keys[ReportWidgetType.tasksByHour],
),
if (enabled.contains(ReportWidgetType.ticketsByHour))
TicketsByHourChart(
repaintKey: _keys[ReportWidgetType.ticketsByHour],
),
],
),
// Top offices + subjects for Tickets (paired)
if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTickets,
ReportWidgetType.topTicketSubjects,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.topOfficesTickets))
TopOfficesTicketsChart(
repaintKey: _keys[ReportWidgetType.topOfficesTickets],
),
if (enabled.contains(ReportWidgetType.topTicketSubjects))
TopTicketSubjectsChart(
repaintKey: _keys[ReportWidgetType.topTicketSubjects],
),
],
),
// Top offices + subjects for Tasks (paired)
if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTasks,
ReportWidgetType.topTaskSubjects,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.topOfficesTasks))
TopOfficesTasksChart(
repaintKey: _keys[ReportWidgetType.topOfficesTasks],
),
if (enabled.contains(ReportWidgetType.topTaskSubjects))
TopTaskSubjectsChart(
repaintKey: _keys[ReportWidgetType.topTaskSubjects],
),
],
),
// Avg resolution (full width)
if (enabled.contains(ReportWidgetType.avgResolution))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AvgResolutionChart(
repaintKey: _keys[ReportWidgetType.avgResolution],
),
),
// Staff workload (full width)
if (enabled.contains(ReportWidgetType.staffWorkload))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: StaffWorkloadChart(
repaintKey: _keys[ReportWidgetType.staffWorkload],
),
),
],
);
},
);
}
/// Build either a [Row] (desktop, >=600) or a [Column] (mobile) for a pair
/// of chart widgets.
Widget _responsiveRow({
required bool isWide,
required List<Widget> children,
}) {
if (children.isEmpty) return const SizedBox.shrink();
if (isWide && children.length > 1) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children:
children
.expand(
(w) => [Expanded(child: w), const SizedBox(width: 12)],
)
.toList()
..removeLast(), // remove trailing SizedBox
),
);
}
// Single column layout
return Column(
children: children.map((w) {
return Padding(padding: const EdgeInsets.only(bottom: 12), child: w);
}).toList(),
);
}
bool _anyEnabled(
Set<ReportWidgetType> enabled,
List<ReportWidgetType> types,
) {
return types.any(enabled.contains);
}
}

View File

@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// Formats hours into a human-readable string.
String _formatHours(double h) {
if (h < 1) {
return '${(h * 60).round()}m';
}
if (h >= 24) {
final days = (h / 24).floor();
final rem = (h % 24).round();
return rem > 0 ? '${days}d ${rem}h' : '${days}d';
}
return '${h.toStringAsFixed(1)}h';
}
/// Horizontal bar chart average task resolution time (hours) per office.
/// Limited to the top 10 slowest offices. Uses custom Flutter widgets so
/// labels sit genuinely inside each bar.
class AvgResolutionChart extends ConsumerWidget {
const AvgResolutionChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(avgResolutionReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Avg Resolution Time by Office',
isLoading: true,
height: 320,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Avg Resolution Time by Office',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, List<OfficeResolution> rawData) {
final data = rawData.length > 10 ? rawData.sublist(0, 10) : rawData;
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Avg Resolution Time by Office',
repaintBoundaryKey: repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final maxHours = data.fold<double>(
0,
(m, e) => e.avgHours > m ? e.avgHours : m,
);
final height = (data.length * 34.0).clamp(160.0, 420.0);
final colors = Theme.of(context).colorScheme;
final barColor = colors.error.withValues(alpha: 0.75);
final onBarColor = barColor.computeLuminance() > 0.5
? Colors.black87
: Colors.white;
return ReportCardWrapper(
title: 'Avg Resolution Time by Office',
repaintBoundaryKey: repaintKey,
height: height,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
color: onBarColor,
fontWeight: FontWeight.w600,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: data.map((item) {
final fraction = maxHours > 0 ? item.avgHours / maxHours : 0.0;
final barWidth = (fraction * availableWidth).clamp(
120.0,
availableWidth,
);
final timeLabel = _formatHours(item.avgHours);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Tooltip(
message: '${item.officeName}: $timeLabel',
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: barWidth,
height: 28,
decoration: BoxDecoration(
color: barColor,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(6),
),
),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: Text(
item.officeName,
style: labelStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Text(timeLabel, style: labelStyle),
],
),
),
),
),
);
}).toList(),
);
},
),
);
}
}

View File

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// KPI card showing ticket-to-task conversion rate, with a visual gauge arc.
class ConversionRateCard extends ConsumerWidget {
const ConversionRateCard({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(ticketToTaskRateReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Ticket-to-Task Conversion',
isLoading: true,
height: 160,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Ticket-to-Task Conversion',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, ConversionRate data) {
final colors = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
return ReportCardWrapper(
title: 'Ticket-to-Task Conversion',
repaintBoundaryKey: repaintKey,
child: Row(
children: [
// Gauge
SizedBox(
width: 100,
height: 100,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 90,
height: 90,
child: CircularProgressIndicator(
value: data.conversionRate / 100,
strokeWidth: 8,
backgroundColor: colors.surfaceContainerHighest,
color: colors.primary,
strokeCap: StrokeCap.round,
),
),
Text(
'${data.conversionRate.toStringAsFixed(1)}%',
style: text.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: colors.primary,
),
),
],
),
),
const SizedBox(width: 24),
// Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_MetricRow(
label: 'Total Tickets',
value: data.totalTickets.toString(),
text: text,
),
const SizedBox(height: 8),
_MetricRow(
label: 'Promoted to Task',
value: data.promotedTickets.toString(),
text: text,
),
const SizedBox(height: 8),
_MetricRow(
label: 'Not Promoted',
value: (data.totalTickets - data.promotedTickets).toString(),
text: text,
),
],
),
),
],
),
);
}
}
class _MetricRow extends StatelessWidget {
const _MetricRow({
required this.label,
required this.value,
required this.text,
});
final String label;
final String value;
final TextTheme text;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(child: Text(label, style: text.bodySmall)),
Text(
value,
style: text.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
],
);
}
}

View File

@ -0,0 +1,271 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// Converts 24h hour int to AM/PM string.
String _hourAmPm(int h) {
if (h == 0) return '12AM';
if (h < 12) return '${h}AM';
if (h == 12) return '12PM';
return '${h - 12}PM';
}
/// Vertical bar chart showing tasks created per hour of day (023).
class TasksByHourChart extends ConsumerWidget {
const TasksByHourChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(tasksByHourReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Tasks Created by Hour',
isLoading: true,
height: 260,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Tasks Created by Hour',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, List<HourCount> data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Tasks Created by Hour',
repaintBoundaryKey: repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
// Fill all 24 hours
final hourMap = {for (final h in data) h.hour: h.count};
final maxCount = data
.fold<int>(0, (m, e) => e.count > m ? e.count : m)
.toDouble();
final colors = Theme.of(context).colorScheme;
return ReportCardWrapper(
title: 'Tasks Created by Hour',
repaintBoundaryKey: repaintKey,
height: 260,
child: BarChart(
BarChartData(
maxY: maxCount * 1.15,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIdx, rod, rodIdx) {
return BarTooltipItem(
'${_hourAmPm(group.x)}${rod.toY.toInt()}',
Theme.of(context).textTheme.bodySmall!.copyWith(
color: colors.onInverseSurface,
),
);
},
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) => Text(
value.toInt().toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final h = value.toInt();
// Show every 3rd hour label to avoid crowding
if (h % 3 != 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_hourAmPm(h),
style: Theme.of(context).textTheme.labelSmall,
),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
drawVerticalLine: false,
horizontalInterval: maxCount > 0
? (maxCount / 4).ceilToDouble()
: 1,
),
borderData: FlBorderData(show: false),
barGroups: List.generate(24, (i) {
final count = (hourMap[i] ?? 0).toDouble();
return BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: count,
width: 10,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
color: colors.primary,
),
],
);
}),
),
),
);
}
}
/// Vertical bar chart showing tickets created per hour of day (023).
class TicketsByHourChart extends ConsumerWidget {
const TicketsByHourChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(ticketsByHourReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Tickets Created by Hour',
isLoading: true,
height: 260,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Tickets Created by Hour',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, List<HourCount> data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Tickets Created by Hour',
repaintBoundaryKey: repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final hourMap = {for (final h in data) h.hour: h.count};
final maxCount = data
.fold<int>(0, (m, e) => e.count > m ? e.count : m)
.toDouble();
final colors = Theme.of(context).colorScheme;
return ReportCardWrapper(
title: 'Tickets Created by Hour',
repaintBoundaryKey: repaintKey,
height: 260,
child: BarChart(
BarChartData(
maxY: maxCount * 1.15,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIdx, rod, rodIdx) {
return BarTooltipItem(
'${_hourAmPm(group.x)}${rod.toY.toInt()}',
Theme.of(context).textTheme.bodySmall!.copyWith(
color: colors.onInverseSurface,
),
);
},
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) => Text(
value.toInt().toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final h = value.toInt();
if (h % 3 != 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_hourAmPm(h),
style: Theme.of(context).textTheme.labelSmall,
),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
drawVerticalLine: false,
horizontalInterval: maxCount > 0
? (maxCount / 4).ceilToDouble()
: 1,
),
borderData: FlBorderData(show: false),
barGroups: List.generate(24, (i) {
final count = (hourMap[i] ?? 0).toDouble();
return BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: count,
width: 10,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
color: colors.tertiary,
),
],
);
}),
),
),
);
}
}

View File

@ -0,0 +1,220 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// Dual-series line chart showing tickets and tasks per month.
class MonthlyOverviewChart extends ConsumerWidget {
const MonthlyOverviewChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(monthlyOverviewReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Monthly Overview',
isLoading: true,
height: 280,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Monthly Overview',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, List<MonthlyOverview> data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Monthly Overview',
repaintBoundaryKey: repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final colors = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
final allCounts = [
...data.map((e) => e.ticketCount),
...data.map((e) => e.taskCount),
];
final maxY = allCounts.fold<int>(0, (m, e) => e > m ? e : m).toDouble();
return ReportCardWrapper(
title: 'Monthly Overview',
repaintBoundaryKey: repaintKey,
height: 300,
child: Column(
children: [
// Legend row
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LegendDot(color: colors.primary, label: 'Tickets'),
const SizedBox(width: 16),
_LegendDot(color: colors.secondary, label: 'Tasks'),
],
),
const SizedBox(height: 12),
Expanded(
child: LineChart(
LineChartData(
maxY: maxY * 1.15,
minY: 0,
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) {
return spots.map((spot) {
final label = spot.barIndex == 0 ? 'Tickets' : 'Tasks';
return LineTooltipItem(
'$label: ${spot.y.toInt()}',
text.bodySmall!.copyWith(
color: colors.onInverseSurface,
),
);
}).toList();
},
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) => Text(
value.toInt().toString(),
style: text.labelSmall,
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 1,
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx < 0 || idx >= data.length) {
return const SizedBox.shrink();
}
final month = data[idx].month;
// Show abbreviated month: "2026-03" "Mar"
final shortMonth = _shortMonth(month);
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(shortMonth, style: text.labelSmall),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
drawVerticalLine: false,
horizontalInterval: maxY > 0 ? (maxY / 4).ceilToDouble() : 1,
),
borderData: FlBorderData(show: false),
lineBarsData: [
// Tickets line
LineChartBarData(
spots: List.generate(
data.length,
(i) =>
FlSpot(i.toDouble(), data[i].ticketCount.toDouble()),
),
isCurved: true,
preventCurveOverShooting: true,
color: colors.primary,
barWidth: 3,
dotData: FlDotData(show: data.length <= 12),
belowBarData: BarAreaData(
show: true,
color: colors.primary.withValues(alpha: 0.08),
),
),
// Tasks line
LineChartBarData(
spots: List.generate(
data.length,
(i) => FlSpot(i.toDouble(), data[i].taskCount.toDouble()),
),
isCurved: true,
preventCurveOverShooting: true,
color: colors.secondary,
barWidth: 3,
dotData: FlDotData(show: data.length <= 12),
belowBarData: BarAreaData(
show: true,
color: colors.secondary.withValues(alpha: 0.08),
),
),
],
),
),
),
],
),
);
}
String _shortMonth(String yyyyMm) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final parts = yyyyMm.split('-');
if (parts.length < 2) return yyyyMm;
final monthIndex = int.tryParse(parts[1]);
if (monthIndex == null || monthIndex < 1 || monthIndex > 12) return yyyyMm;
return months[monthIndex - 1];
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}

View File

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:skeletonizer/skeletonizer.dart';
/// Wraps each report chart widget in a themed Card with a title, loading
/// skeleton and error state handling. Exposes a [GlobalKey] on the inner
/// [RepaintBoundary] so the PDF exporter can capture the rendered chart
/// as a raster image.
class ReportCardWrapper extends StatelessWidget {
const ReportCardWrapper({
super.key,
required this.title,
required this.child,
this.isLoading = false,
this.error,
this.height,
this.repaintBoundaryKey,
});
/// Title displayed in the card header.
final String title;
/// The chart widget to render inside the card.
final Widget child;
/// Whether to display a loading skeleton.
final bool isLoading;
/// An error message to display instead of the chart.
final String? error;
/// Optional fixed height for the chart area.
final double? height;
/// Key attached to the inner [RepaintBoundary] used for PDF image capture.
final GlobalKey? repaintBoundaryKey;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = theme.textTheme;
Widget body;
if (error != null) {
body = Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: colors.error, size: 32),
const SizedBox(height: 8),
Text(
error!,
style: text.bodyMedium?.copyWith(color: colors.error),
textAlign: TextAlign.center,
),
],
),
),
);
} else if (isLoading) {
body = Skeletonizer(
enabled: true,
child: Container(
height: height ?? 200,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
),
);
} else {
body = child;
}
final cardContent = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text(title, style: text.titleSmall),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: height != null ? SizedBox(height: height, child: body) : body,
),
],
);
final card = Card(
// Rely on CardTheme for elevation (M2 exception in hybrid system).
child: cardContent,
);
if (repaintBoundaryKey != null) {
return RepaintBoundary(key: repaintBoundaryKey, child: card);
}
return card;
}
}

View File

@ -0,0 +1,325 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// Donut chart distribution of tasks by request type with hover animation.
class RequestTypeChart extends ConsumerStatefulWidget {
const RequestTypeChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
static const _typeColors = <String, Color>{
'Install': Color(0xFF4CAF50),
'Repair': Color(0xFFFF9800),
'Upgrade': Color(0xFF2196F3),
'Replace': Color(0xFF9C27B0),
'Other': Color(0xFF607D8B),
'Unspecified': Color(0xFFBDBDBD),
};
@override
ConsumerState<RequestTypeChart> createState() => _RequestTypeChartState();
}
class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
int _touchedIndex = -1;
@override
Widget build(BuildContext context) {
final asyncData = ref.watch(requestTypeReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Request Type Distribution',
isLoading: true,
height: 220,
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Request Type Distribution',
error: e.toString(),
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, List<NamedCount> data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Request Type Distribution',
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final total = data.fold<int>(0, (s, e) => s + e.count);
final colorScheme = Theme.of(context).colorScheme;
final fallbackColors = [
colorScheme.primary,
colorScheme.secondary,
colorScheme.tertiary,
colorScheme.error,
colorScheme.outline,
];
Color colorFor(int idx, String name) =>
RequestTypeChart._typeColors[name] ??
fallbackColors[idx % fallbackColors.length];
return ReportCardWrapper(
title: 'Request Type Distribution',
repaintBoundaryKey: widget.repaintKey,
height: 220,
child: Row(
children: [
Expanded(
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback:
(FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
_touchedIndex = -1;
return;
}
_touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
});
},
),
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return PieChartSectionData(
value: e.count.toDouble(),
title: '',
radius: isTouched ? 60 : 50,
color: colorFor(i, e.name),
borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2)
: BorderSide.none,
);
}).toList(),
),
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _HoverLegendItem(
color: colorFor(i, e.name),
label: e.name,
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
),
],
),
);
}
}
/// Donut chart distribution of tasks by request category with hover animation.
class RequestCategoryChart extends ConsumerStatefulWidget {
const RequestCategoryChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
static const _catColors = <String, Color>{
'Software': Color(0xFF42A5F5),
'Hardware': Color(0xFFEF5350),
'Network': Color(0xFF66BB6A),
'Unspecified': Color(0xFFBDBDBD),
};
@override
ConsumerState<RequestCategoryChart> createState() =>
_RequestCategoryChartState();
}
class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
int _touchedIndex = -1;
@override
Widget build(BuildContext context) {
final asyncData = ref.watch(requestCategoryReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Request Category Distribution',
isLoading: true,
height: 220,
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Request Category Distribution',
error: e.toString(),
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, List<NamedCount> data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Request Category Distribution',
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final total = data.fold<int>(0, (s, e) => s + e.count);
final colorScheme = Theme.of(context).colorScheme;
final fallbackColors = [
colorScheme.primary,
colorScheme.secondary,
colorScheme.tertiary,
];
Color colorFor(int idx, String name) =>
RequestCategoryChart._catColors[name] ??
fallbackColors[idx % fallbackColors.length];
return ReportCardWrapper(
title: 'Request Category Distribution',
repaintBoundaryKey: widget.repaintKey,
height: 220,
child: Row(
children: [
Expanded(
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback:
(FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
_touchedIndex = -1;
return;
}
_touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
});
},
),
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return PieChartSectionData(
value: e.count.toDouble(),
title: '',
radius: isTouched ? 60 : 50,
color: colorFor(i, e.name),
borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2)
: BorderSide.none,
);
}).toList(),
),
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _HoverLegendItem(
color: colorFor(i, e.name),
label: e.name,
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
),
],
),
);
}
}
// Shared helpers
class _HoverLegendItem extends StatelessWidget {
const _HoverLegendItem({
required this.color,
required this.label,
required this.value,
this.isTouched = false,
});
final Color color;
final String label;
final String value;
final bool isTouched;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isTouched ? 14 : 10,
height: isTouched ? 14 : 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: (text.bodySmall ?? const TextStyle()).copyWith(
fontWeight: isTouched ? FontWeight.bold : FontWeight.normal,
),
child: Text(label),
),
const SizedBox(width: 4),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: (text.labelSmall ?? const TextStyle()).copyWith(
fontWeight: isTouched ? FontWeight.bold : FontWeight.normal,
),
child: Text(value),
),
],
),
);
}
}

View File

@ -0,0 +1,192 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// Grouped vertical bar chart assigned vs completed tasks per IT staff member.
class StaffWorkloadChart extends ConsumerWidget {
const StaffWorkloadChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(staffWorkloadReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'IT Staff Workload',
isLoading: true,
height: 300,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'IT Staff Workload',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, List<StaffWorkload> data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'IT Staff Workload',
repaintBoundaryKey: repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final colors = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
final maxY = data.fold<int>(
0,
(m, e) => [
m,
e.assignedCount,
e.completedCount,
].reduce((a, b) => a > b ? a : b),
);
return ReportCardWrapper(
title: 'IT Staff Workload',
repaintBoundaryKey: repaintKey,
height: 320,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LegendDot(color: colors.primary, label: 'Assigned'),
const SizedBox(width: 16),
_LegendDot(color: colors.tertiary, label: 'Completed'),
],
),
const SizedBox(height: 12),
Expanded(
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxY * 1.2,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIdx, rod, rodIdx) {
final item = data[group.x];
final label = rodIdx == 0 ? 'Assigned' : 'Completed';
return BarTooltipItem(
'${item.staffName}\n$label: ${rod.toY.toInt()}',
text.bodySmall!.copyWith(
color: colors.onInverseSurface,
),
);
},
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) => Text(
value.toInt().toString(),
style: text.labelSmall,
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx < 0 || idx >= data.length) {
return const SizedBox.shrink();
}
final name = data[idx].staffName;
// Show first name only to save space
final short = name.split(' ').first;
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
short.length > 10
? '${short.substring(0, 8)}'
: short,
style: text.labelSmall,
),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
drawVerticalLine: false,
horizontalInterval: maxY > 0 ? (maxY / 4).ceilToDouble() : 1,
),
borderData: FlBorderData(show: false),
barGroups: List.generate(data.length, (i) {
return BarChartGroupData(
x: i,
barsSpace: 4,
barRods: [
BarChartRodData(
toY: data[i].assignedCount.toDouble(),
width: 14,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
color: colors.primary,
),
BarChartRodData(
toY: data[i].completedCount.toDouble(),
width: 14,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
color: colors.tertiary,
),
],
);
}),
),
),
),
],
),
);
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}

View File

@ -0,0 +1,334 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// Donut chart ticket counts per status with hover animation.
class TicketsByStatusChart extends ConsumerStatefulWidget {
const TicketsByStatusChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
ConsumerState<TicketsByStatusChart> createState() =>
_TicketsByStatusChartState();
}
class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
int _touchedIndex = -1;
@override
Widget build(BuildContext context) {
final asyncData = ref.watch(ticketsByStatusReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Tickets by Status',
isLoading: true,
height: 220,
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Tickets by Status',
error: e.toString(),
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox.shrink(),
),
data: (data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Tickets by Status',
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final total = data.fold<int>(0, (s, e) => s + e.count);
return ReportCardWrapper(
title: 'Tickets by Status',
repaintBoundaryKey: widget.repaintKey,
height: 220,
child: Row(
children: [
Expanded(
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback:
(FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
_touchedIndex = -1;
return;
}
_touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
});
},
),
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return PieChartSectionData(
value: e.count.toDouble(),
title: '',
radius: isTouched ? 60 : 50,
color: _ticketStatusColor(context, e.status),
borderSide: isTouched
? const BorderSide(
color: Colors.white,
width: 2,
)
: BorderSide.none,
);
}).toList(),
),
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _LegendItem(
color: _ticketStatusColor(context, e.status),
label: _capitalize(e.status),
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
),
],
),
);
},
);
}
Color _ticketStatusColor(BuildContext context, String status) {
final colors = Theme.of(context).colorScheme;
switch (status) {
case 'pending':
return colors.tertiary;
case 'promoted':
return colors.secondary;
case 'closed':
return colors.primary;
default:
return colors.outlineVariant;
}
}
}
/// Donut chart task counts per status with hover animation.
class TasksByStatusChart extends ConsumerStatefulWidget {
const TasksByStatusChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
ConsumerState<TasksByStatusChart> createState() =>
_TasksByStatusChartState();
}
class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
int _touchedIndex = -1;
@override
Widget build(BuildContext context) {
final asyncData = ref.watch(tasksByStatusReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Tasks by Status',
isLoading: true,
height: 220,
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Tasks by Status',
error: e.toString(),
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox.shrink(),
),
data: (data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Tasks by Status',
repaintBoundaryKey: widget.repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final total = data.fold<int>(0, (s, e) => s + e.count);
return ReportCardWrapper(
title: 'Tasks by Status',
repaintBoundaryKey: widget.repaintKey,
height: 220,
child: Row(
children: [
Expanded(
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback:
(FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
_touchedIndex = -1;
return;
}
_touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
});
},
),
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return PieChartSectionData(
value: e.count.toDouble(),
title: '',
radius: isTouched ? 60 : 50,
color: _taskStatusColor(context, e.status),
borderSide: isTouched
? const BorderSide(
color: Colors.white,
width: 2,
)
: BorderSide.none,
);
}).toList(),
),
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: data.asMap().entries.map((entry) {
final i = entry.key;
final e = entry.value;
final isTouched = i == _touchedIndex;
return _LegendItem(
color: _taskStatusColor(context, e.status),
label: _formatTaskStatus(e.status),
value:
'${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)',
isTouched: isTouched,
);
}).toList(),
),
],
),
);
},
);
}
Color _taskStatusColor(BuildContext context, String status) {
final colors = Theme.of(context).colorScheme;
switch (status) {
case 'queued':
return colors.surfaceContainerHighest;
case 'in_progress':
return colors.secondary;
case 'completed':
return colors.primary;
case 'cancelled':
return colors.error;
default:
return colors.outlineVariant;
}
}
String _formatTaskStatus(String status) {
switch (status) {
case 'in_progress':
return 'In Progress';
case 'queued':
return 'Queued';
case 'completed':
return 'Completed';
case 'cancelled':
return 'Cancelled';
default:
return _capitalize(status);
}
}
}
// Shared helpers
class _LegendItem extends StatelessWidget {
const _LegendItem({
required this.color,
required this.label,
required this.value,
this.isTouched = false,
});
final Color color;
final String label;
final String value;
final bool isTouched;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isTouched ? 14 : 10,
height: isTouched ? 14 : 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: (text.bodySmall ?? const TextStyle()).copyWith(
fontWeight: isTouched ? FontWeight.bold : FontWeight.normal,
),
child: Text(label),
),
const SizedBox(width: 4),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: (text.labelSmall ?? const TextStyle()).copyWith(
fontWeight: isTouched ? FontWeight.bold : FontWeight.normal,
),
child: Text(value),
),
],
),
);
}
}
String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';

View File

@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// Horizontal bar chart top 10 offices by ticket count.
/// Uses custom Flutter widgets so labels sit genuinely inside each bar.
class TopOfficesTicketsChart extends ConsumerWidget {
const TopOfficesTicketsChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(topOfficesTicketsReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Top Offices by Tickets',
isLoading: true,
height: 320,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Top Offices by Tickets',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _HorizontalBarBody(
data: data,
title: 'Top Offices by Tickets',
barColor: Theme.of(context).colorScheme.primary,
repaintKey: repaintKey,
),
);
}
}
/// Horizontal bar chart top 10 offices by task count.
class TopOfficesTasksChart extends ConsumerWidget {
const TopOfficesTasksChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(topOfficesTasksReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Top Offices by Tasks',
isLoading: true,
height: 320,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Top Offices by Tasks',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _HorizontalBarBody(
data: data,
title: 'Top Offices by Tasks',
barColor: Theme.of(context).colorScheme.secondary,
repaintKey: repaintKey,
),
);
}
}
// Shared horizontal-bar body (pure Flutter widgets)
class _HorizontalBarBody extends StatelessWidget {
const _HorizontalBarBody({
required this.data,
required this.title,
required this.barColor,
this.repaintKey,
});
final List<NamedCount> data;
final String title;
final Color barColor;
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context) {
if (data.isEmpty) {
return ReportCardWrapper(
title: title,
repaintBoundaryKey: repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final maxCount = data.fold<int>(0, (m, e) => e.count > m ? e.count : m);
final height = (data.length * 34.0).clamp(160.0, 420.0);
final onBarColor = barColor.computeLuminance() > 0.5
? Colors.black87
: Colors.white;
return ReportCardWrapper(
title: title,
repaintBoundaryKey: repaintKey,
height: height,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
color: onBarColor,
fontWeight: FontWeight.w600,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: data.map((item) {
final fraction = maxCount > 0 ? item.count / maxCount : 0.0;
final barWidth = (fraction * availableWidth).clamp(
120.0,
availableWidth,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Tooltip(
message: '${item.name}: ${item.count}',
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: barWidth,
height: 28,
decoration: BoxDecoration(
color: barColor,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(6),
),
),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: Text(
item.name,
style: labelStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Text('${item.count}', style: labelStyle),
],
),
),
),
),
);
}).toList(),
);
},
),
);
}
}

View File

@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// Horizontal bar chart top 10 ticket subjects (pg_trgm clustered).
/// Uses custom Flutter widgets so labels sit genuinely inside each bar.
class TopTicketSubjectsChart extends ConsumerWidget {
const TopTicketSubjectsChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(topTicketSubjectsReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Top Ticket Subjects',
isLoading: true,
height: 320,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Top Ticket Subjects',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _SubjectBarBody(
data: data,
title: 'Top Ticket Subjects',
barColor: Theme.of(context).colorScheme.tertiary,
repaintKey: repaintKey,
),
);
}
}
/// Horizontal bar chart top 10 task subjects (pg_trgm clustered).
class TopTaskSubjectsChart extends ConsumerWidget {
const TopTaskSubjectsChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(topTaskSubjectsReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Top Task Subjects',
isLoading: true,
height: 320,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Top Task Subjects',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _SubjectBarBody(
data: data,
title: 'Top Task Subjects',
barColor: Theme.of(context).colorScheme.secondary,
repaintKey: repaintKey,
),
);
}
}
// Shared horizontal-bar body (pure Flutter widgets)
class _SubjectBarBody extends StatelessWidget {
const _SubjectBarBody({
required this.data,
required this.title,
required this.barColor,
this.repaintKey,
});
final List<NamedCount> data;
final String title;
final Color barColor;
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context) {
if (data.isEmpty) {
return ReportCardWrapper(
title: title,
repaintBoundaryKey: repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final maxCount = data.fold<int>(0, (m, e) => e.count > m ? e.count : m);
final height = (data.length * 34.0).clamp(160.0, 420.0);
final onBarColor = barColor.computeLuminance() > 0.5
? Colors.black87
: Colors.white;
return ReportCardWrapper(
title: title,
repaintBoundaryKey: repaintKey,
height: height,
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
color: onBarColor,
fontWeight: FontWeight.w600,
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: data.map((item) {
final fraction = maxCount > 0 ? item.count / maxCount : 0.0;
final barWidth = (fraction * availableWidth).clamp(
120.0,
availableWidth,
);
final label = _titleCase(item.name);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Tooltip(
message: '$label: ${item.count}',
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: barWidth,
height: 28,
decoration: BoxDecoration(
color: barColor,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(6),
),
),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: Text(
label,
style: labelStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Text('${item.count}', style: labelStyle),
],
),
),
),
),
);
}).toList(),
);
},
),
);
}
}
String _titleCase(String s) {
if (s.isEmpty) return s;
return s
.split(' ')
.map((w) {
if (w.isEmpty) return w;
return '${w[0].toUpperCase()}${w.substring(1)}';
})
.join(' ');
}

View File

@ -305,6 +305,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
@ -409,6 +417,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: "5276944c6ffc975ae796569a826c38a62d2abcf264e26b88fa6f482e107f4237"
url: "https://pub.dev"
source: hosted
version: "0.70.2"
flutter:
dependency: "direct main"
description: flutter

View File

@ -35,6 +35,7 @@ dependencies:
shared_preferences: ^2.2.0
uuid: ^4.1.0
skeletonizer: ^2.1.3
fl_chart: ^0.70.2
dev_dependencies:
flutter_test: