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/profile/profile_screen.dart';
|
||||||
import '../screens/shared/under_development_screen.dart';
|
import '../screens/shared/under_development_screen.dart';
|
||||||
import '../screens/shared/permissions_screen.dart';
|
import '../screens/shared/permissions_screen.dart';
|
||||||
|
import '../screens/reports/reports_screen.dart';
|
||||||
import '../screens/tasks/task_detail_screen.dart';
|
import '../screens/tasks/task_detail_screen.dart';
|
||||||
import '../screens/tasks/tasks_list_screen.dart';
|
import '../screens/tasks/tasks_list_screen.dart';
|
||||||
import '../screens/tickets/ticket_detail_screen.dart';
|
import '../screens/tickets/ticket_detail_screen.dart';
|
||||||
|
|
@ -45,9 +46,13 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
final isSignedIn = session != null;
|
final isSignedIn = session != null;
|
||||||
final profileAsync = ref.read(currentProfileProvider);
|
final profileAsync = ref.read(currentProfileProvider);
|
||||||
final isAdminRoute = state.matchedLocation.startsWith('/settings');
|
final isAdminRoute = state.matchedLocation.startsWith('/settings');
|
||||||
final isAdmin = profileAsync is AsyncData
|
final role = profileAsync is AsyncData
|
||||||
? (profileAsync.value)?.role == 'admin'
|
? (profileAsync.value)?.role
|
||||||
: false;
|
: null;
|
||||||
|
final isAdmin = role == 'admin';
|
||||||
|
final isReportsRoute = state.matchedLocation == '/reports';
|
||||||
|
final hasReportsAccess =
|
||||||
|
role == 'admin' || role == 'dispatcher' || role == 'it_staff';
|
||||||
|
|
||||||
if (!isSignedIn && !isAuthRoute) {
|
if (!isSignedIn && !isAuthRoute) {
|
||||||
return '/login';
|
return '/login';
|
||||||
|
|
@ -58,6 +63,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
if (isAdminRoute && !isAdmin) {
|
if (isAdminRoute && !isAdmin) {
|
||||||
return '/tickets';
|
return '/tickets';
|
||||||
}
|
}
|
||||||
|
if (isReportsRoute && !hasReportsAccess) {
|
||||||
|
return '/tickets';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
|
@ -122,11 +130,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/reports',
|
path: '/reports',
|
||||||
builder: (context, state) => const UnderDevelopmentScreen(
|
builder: (context, state) => const ReportsScreen(),
|
||||||
title: 'Reports',
|
|
||||||
subtitle: 'Reporting automation is under development.',
|
|
||||||
icon: Icons.analytics,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings/users',
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.1"
|
version: "0.3.1"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.8"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -409,6 +417,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ dependencies:
|
||||||
shared_preferences: ^2.2.0
|
shared_preferences: ^2.2.0
|
||||||
uuid: ^4.1.0
|
uuid: ^4.1.0
|
||||||
skeletonizer: ^2.1.3
|
skeletonizer: ^2.1.3
|
||||||
|
fl_chart: ^0.70.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user