Reports
This commit is contained in:
parent
7115e2df05
commit
d9270b3edf
412
lib/providers/reports_provider.dart
Normal file
412
lib/providers/reports_provider.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
459
lib/screens/reports/report_date_filter.dart
Normal file
459
lib/screens/reports/report_date_filter.dart
Normal 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;
|
||||
}
|
||||
252
lib/screens/reports/report_pdf_export.dart
Normal file
252
lib/screens/reports/report_pdf_export.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/screens/reports/report_widget_selector.dart
Normal file
90
lib/screens/reports/report_widget_selector.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
348
lib/screens/reports/reports_screen.dart
Normal file
348
lib/screens/reports/reports_screen.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
134
lib/screens/reports/widgets/avg_resolution_chart.dart
Normal file
134
lib/screens/reports/widgets/avg_resolution_chart.dart
Normal 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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/screens/reports/widgets/conversion_rate_card.dart
Normal file
127
lib/screens/reports/widgets/conversion_rate_card.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
271
lib/screens/reports/widgets/hourly_charts.dart
Normal file
271
lib/screens/reports/widgets/hourly_charts.dart
Normal 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 (0–23).
|
||||
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 (0–23).
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
220
lib/screens/reports/widgets/monthly_overview_chart.dart
Normal file
220
lib/screens/reports/widgets/monthly_overview_chart.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/screens/reports/widgets/report_card_wrapper.dart
Normal file
103
lib/screens/reports/widgets/report_card_wrapper.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
325
lib/screens/reports/widgets/request_distribution_charts.dart
Normal file
325
lib/screens/reports/widgets/request_distribution_charts.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
192
lib/screens/reports/widgets/staff_workload_chart.dart
Normal file
192
lib/screens/reports/widgets/staff_workload_chart.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
334
lib/screens/reports/widgets/status_charts.dart
Normal file
334
lib/screens/reports/widgets/status_charts.dart
Normal 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)}';
|
||||
165
lib/screens/reports/widgets/top_offices_charts.dart
Normal file
165
lib/screens/reports/widgets/top_offices_charts.dart
Normal 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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/screens/reports/widgets/top_subjects_charts.dart
Normal file
177
lib/screens/reports/widgets/top_subjects_charts.dart
Normal 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(' ');
|
||||
}
|
||||
16
pubspec.lock
16
pubspec.lock
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user