tasq/lib/screens/reports/reports_screen.dart

343 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/reports_provider.dart';
import '../../widgets/app_page_header.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);
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: [
AppPageHeader(
title: 'Reports',
subtitle: 'Analytics and performance insights',
actions: [
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);
}
}