This commit is contained in:
2026-03-03 07:38:40 +08:00
parent 7115e2df05
commit d9270b3edf
18 changed files with 3638 additions and 8 deletions
+459
View File
@@ -0,0 +1,459 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/reports_provider.dart';
import '../../utils/app_time.dart';
/// A Metabase-inspired date filter with quick presets, relative offsets,
/// and a custom date-range picker.
class ReportDateFilter extends ConsumerWidget {
const ReportDateFilter({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final range = ref.watch(reportDateRangeProvider);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = theme.textTheme;
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(Icons.calendar_today, size: 18, color: colors.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(range.label, style: text.labelLarge),
Text(
AppTime.formatDateRange(range.dateTimeRange),
style: text.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
FilledButton.tonalIcon(
onPressed: () => _showDateFilterDialog(context, ref),
icon: const Icon(Icons.tune, size: 18),
label: const Text('Change'),
),
],
),
),
);
}
void _showDateFilterDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => _DateFilterDialog(
current: ref.read(reportDateRangeProvider),
onApply: (newRange) {
ref.read(reportDateRangeProvider.notifier).state = newRange;
},
),
);
}
}
// ──────────────────────────────────────────────
// Date filter dialog with three tabs
// ──────────────────────────────────────────────
class _DateFilterDialog extends StatefulWidget {
const _DateFilterDialog({required this.current, required this.onApply});
final ReportDateRange current;
final ValueChanged<ReportDateRange> onApply;
@override
State<_DateFilterDialog> createState() => _DateFilterDialogState();
}
class _DateFilterDialogState extends State<_DateFilterDialog>
with SingleTickerProviderStateMixin {
late TabController _tabController;
// Relative tab state
int _relativeAmount = 30;
String _relativeUnit = 'days';
// Custom tab state
DateTime? _customStart;
DateTime? _customEnd;
static const _units = [
'minutes',
'hours',
'days',
'weeks',
'months',
'quarters',
'years',
];
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_customStart = widget.current.start;
_customEnd = widget.current.end;
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = theme.textTheme;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Row(
children: [
Icon(Icons.date_range, color: colors.primary),
const SizedBox(width: 8),
Text('Filter Date Range', style: text.titleMedium),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
),
TabBar(
controller: _tabController,
labelStyle: text.labelMedium,
tabs: const [
Tab(text: 'Presets'),
Tab(text: 'Relative'),
Tab(text: 'Custom'),
],
),
Flexible(
child: TabBarView(
controller: _tabController,
children: [
_buildPresetsTab(context),
_buildRelativeTab(context),
_buildCustomTab(context),
],
),
),
],
),
),
);
}
// ─── Presets Tab ───
Widget _buildPresetsTab(BuildContext context) {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
final presets = <_Preset>[
_Preset('Today', today, today.add(const Duration(days: 1))),
_Preset('Yesterday', today.subtract(const Duration(days: 1)), today),
_Preset(
'Last 7 Days',
today.subtract(const Duration(days: 7)),
today.add(const Duration(days: 1)),
),
_Preset(
'Last 30 Days',
today.subtract(const Duration(days: 30)),
today.add(const Duration(days: 1)),
),
_Preset(
'Last 90 Days',
today.subtract(const Duration(days: 90)),
today.add(const Duration(days: 1)),
),
_Preset(
'This Week',
today.subtract(Duration(days: today.weekday - 1)),
today.add(const Duration(days: 1)),
),
_Preset(
'This Month',
DateTime(now.year, now.month, 1),
DateTime(now.year, now.month + 1, 1),
),
_Preset(
'This Quarter',
DateTime(now.year, ((now.month - 1) ~/ 3) * 3 + 1, 1),
DateTime(now.year, ((now.month - 1) ~/ 3) * 3 + 4, 1),
),
_Preset(
'This Year',
DateTime(now.year, 1, 1),
DateTime(now.year + 1, 1, 1),
),
_Preset(
'Last Week',
today.subtract(Duration(days: today.weekday + 6)),
today.subtract(Duration(days: today.weekday - 1)),
),
_Preset(
'Last Month',
DateTime(now.year, now.month - 1, 1),
DateTime(now.year, now.month, 1),
),
_Preset(
'Last Quarter',
DateTime(now.year, ((now.month - 1) ~/ 3) * 3 - 2, 1),
DateTime(now.year, ((now.month - 1) ~/ 3) * 3 + 1, 1),
),
_Preset(
'Last Year',
DateTime(now.year - 1, 1, 1),
DateTime(now.year, 1, 1),
),
];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: presets.map((p) {
final isSelected = widget.current.label == p.label;
return ChoiceChip(
label: Text(p.label),
selected: isSelected,
onSelected: (_) {
widget.onApply(
ReportDateRange(start: p.start, end: p.end, label: p.label),
);
Navigator.pop(context);
},
);
}).toList(),
),
);
}
// ─── Relative Tab ───
Widget _buildRelativeTab(BuildContext context) {
final theme = Theme.of(context);
final text = theme.textTheme;
// Compute preview
final previewRange = _computeRelativeRange(_relativeAmount, _relativeUnit);
final previewText = AppTime.formatDateRange(previewRange.dateTimeRange);
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Previous', style: text.labelLarge),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 80,
child: TextFormField(
initialValue: _relativeAmount.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onChanged: (v) {
setState(() {
_relativeAmount = int.tryParse(v) ?? _relativeAmount;
});
},
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _relativeUnit,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: _units
.map((u) => DropdownMenuItem(value: u, child: Text(u)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _relativeUnit = v);
},
),
),
const SizedBox(width: 12),
Text('ago', style: text.bodyLarge),
],
),
const SizedBox(height: 16),
Text(
previewText,
style: text.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () {
widget.onApply(previewRange);
Navigator.pop(context);
},
child: const Text('Apply'),
),
),
],
),
);
}
ReportDateRange _computeRelativeRange(int amount, String unit) {
final now = AppTime.now();
final end = now;
DateTime start;
switch (unit) {
case 'minutes':
start = now.subtract(Duration(minutes: amount));
case 'hours':
start = now.subtract(Duration(hours: amount));
case 'days':
start = now.subtract(Duration(days: amount));
case 'weeks':
start = now.subtract(Duration(days: amount * 7));
case 'months':
start = DateTime(now.year, now.month - amount, now.day);
case 'quarters':
start = DateTime(now.year, now.month - (amount * 3), now.day);
case 'years':
start = DateTime(now.year - amount, now.month, now.day);
default:
start = now.subtract(Duration(days: amount));
}
return ReportDateRange(start: start, end: end, label: 'Last $amount $unit');
}
// ─── Custom Tab ───
Widget _buildCustomTab(BuildContext context) {
final theme = Theme.of(context);
final text = theme.textTheme;
final colors = theme.colorScheme;
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Start Date', style: text.labelLarge),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.calendar_today, size: 16),
label: Text(
_customStart != null
? AppTime.formatDate(_customStart!)
: 'Select start date',
),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: _customStart ?? AppTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) setState(() => _customStart = picked);
},
),
const SizedBox(height: 16),
Text('End Date', style: text.labelLarge),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.calendar_today, size: 16),
label: Text(
_customEnd != null
? AppTime.formatDate(_customEnd!)
: 'Select end date',
),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: _customEnd ?? AppTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) setState(() => _customEnd = picked);
},
),
if (_customStart != null && _customEnd != null) ...[
const SizedBox(height: 12),
Text(
AppTime.formatDateRange(
DateTimeRange(start: _customStart!, end: _customEnd!),
),
style: text.bodyMedium?.copyWith(color: colors.onSurfaceVariant),
),
],
const SizedBox(height: 20),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: (_customStart != null && _customEnd != null)
? () {
widget.onApply(
ReportDateRange(
start: _customStart!,
end: _customEnd!.add(const Duration(days: 1)),
label: 'Custom Range',
),
);
Navigator.pop(context);
}
: null,
child: const Text('Apply'),
),
),
],
),
);
}
}
class _Preset {
const _Preset(this.label, this.start, this.end);
final String label;
final DateTime start;
final DateTime end;
}
+252
View File
@@ -0,0 +1,252 @@
import 'dart:isolate';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../providers/reports_provider.dart';
import '../../utils/app_time.dart';
/// Captures all visible report charts via their [RepaintBoundary] keys
/// and generates a PDF document for export / printing.
class ReportPdfExport {
ReportPdfExport._();
/// Capture a [RepaintBoundary] as a PNG [Uint8List].
///
/// Yields to the event loop after each capture so the UI stays responsive.
static Future<Uint8List?> _captureWidget(GlobalKey key) async {
final boundary =
key.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) return null;
final image = await boundary.toImage(pixelRatio: 1.5);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
// Yield to the event loop so the UI thread doesn't freeze
await Future<void>.delayed(Duration.zero);
return byteData?.buffer.asUint8List();
}
/// Generates the full report PDF from the given [captureKeys] map.
///
/// [captureKeys] maps a human-readable chart title to the [GlobalKey]
/// attached to that chart's [RepaintBoundary].
///
/// [dateRange] is for display in the header.
/// [enabledWidgets] filters which charts to include.
static Future<Uint8List> generatePdf({
required Map<String, GlobalKey> captureKeys,
required ReportDateRange dateRange,
required Set<ReportWidgetType> enabledWidgets,
}) async {
// Load fonts
final regularFontData = await rootBundle.load(
'assets/fonts/Roboto-Regular.ttf',
);
final boldFontData = await rootBundle.load('assets/fonts/Roboto-Bold.ttf');
// Try to load logo (may not exist in all builds)
Uint8List? logoBytes;
try {
final logoData = await rootBundle.load('assets/crmc_logo.png');
logoBytes = logoData.buffer.asUint8List();
} catch (_) {
// Logo not available — skip
}
// ── Capture chart images on the main thread (needs render objects) ──
final chartImages = <String, Uint8List>{};
for (final entry in captureKeys.entries) {
final bytes = await _captureWidget(entry.value);
if (bytes != null) {
chartImages[entry.key] = bytes;
}
}
final dateRangeText = AppTime.formatDateRange(dateRange.dateTimeRange);
final generated = AppTime.formatDate(AppTime.now());
final dateLabel = dateRange.label;
// ── Build the PDF on a background isolate so it doesn't freeze the UI ──
return Isolate.run(() {
return _buildPdfBytes(
chartImages: chartImages,
dateRangeText: dateRangeText,
dateLabel: dateLabel,
generated: generated,
regularFontData: regularFontData.buffer.asUint8List(),
boldFontData: boldFontData.buffer.asUint8List(),
logoBytes: logoBytes,
);
});
}
/// Pure function that assembles the PDF document from raw data.
/// Designed to run inside [Isolate.run].
static Future<Uint8List> _buildPdfBytes({
required Map<String, Uint8List> chartImages,
required String dateRangeText,
required String dateLabel,
required String generated,
required Uint8List regularFontData,
required Uint8List boldFontData,
Uint8List? logoBytes,
}) {
final regularFont = pw.Font.ttf(regularFontData.buffer.asByteData());
final boldFont = pw.Font.ttf(boldFontData.buffer.asByteData());
pw.MemoryImage? logoImage;
if (logoBytes != null) {
logoImage = pw.MemoryImage(logoBytes);
}
final doc = pw.Document();
// One page per chart to avoid TooManyPagesException and keep layout simple
// First chart gets the full header; subsequent ones get a compact header.
var isFirst = true;
for (final entry in chartImages.entries) {
doc.addPage(
pw.Page(
pageFormat: pdf.PdfPageFormat.a4.landscape,
margin: const pw.EdgeInsets.all(32),
theme: pw.ThemeData.withFont(base: regularFont, bold: boldFont),
build: (pw.Context ctx) {
final header = isFirst
? pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
if (logoImage != null)
pw.Container(
width: 50,
height: 50,
child: pw.Image(logoImage),
),
if (logoImage != null) pw.SizedBox(width: 12),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'TasQ Report',
style: pw.TextStyle(
fontSize: 20,
fontWeight: pw.FontWeight.bold,
),
),
pw.Text(
'$dateLabel$dateRangeText',
style: pw.TextStyle(
fontSize: 11,
color: pdf.PdfColors.grey700,
),
),
],
),
pw.Spacer(),
pw.Text(
'Generated: $generated',
style: pw.TextStyle(
fontSize: 9,
color: pdf.PdfColors.grey500,
),
),
],
),
pw.Divider(thickness: 0.5),
pw.SizedBox(height: 8),
],
)
: pw.Container(
margin: const pw.EdgeInsets.only(bottom: 8),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'TasQ Report',
style: pw.TextStyle(
fontSize: 10,
color: pdf.PdfColors.grey600,
),
),
pw.Text(
dateRangeText,
style: pw.TextStyle(
fontSize: 10,
color: pdf.PdfColors.grey600,
),
),
],
),
);
isFirst = false;
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
header,
pw.Text(
entry.key,
style: pw.TextStyle(
fontSize: 13,
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 6),
pw.Expanded(
child: pw.Center(
child: pw.Image(
pw.MemoryImage(entry.value),
fit: pw.BoxFit.contain,
),
),
),
pw.Container(
alignment: pw.Alignment.centerRight,
child: pw.Text(
'Page ${ctx.pageNumber} • Generated by TasQ',
style: pw.TextStyle(fontSize: 9, color: pdf.PdfColors.grey),
),
),
],
);
},
),
);
}
if (chartImages.isEmpty) {
doc.addPage(
pw.Page(
pageFormat: pdf.PdfPageFormat.a4.landscape,
margin: const pw.EdgeInsets.all(32),
theme: pw.ThemeData.withFont(base: regularFont, bold: boldFont),
build: (pw.Context ctx) => pw.Center(
child: pw.Text(
'No charts selected for export.',
style: pw.TextStyle(fontSize: 14),
),
),
),
);
}
return doc.save();
}
/// Show a print / share dialog for the generated PDF.
static Future<void> sharePdf(Uint8List pdfBytes) async {
await Printing.layoutPdf(
onLayout: (_) => pdfBytes,
name:
'TasQ_Report_${AppTime.formatDate(AppTime.now()).replaceAll(' ', '_')}.pdf',
);
}
}
@@ -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
View File
@@ -0,0 +1,348 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/reports_provider.dart';
import 'report_date_filter.dart';
import 'report_widget_selector.dart';
import 'report_pdf_export.dart';
import 'widgets/status_charts.dart';
import 'widgets/hourly_charts.dart';
import 'widgets/top_offices_charts.dart';
import 'widgets/top_subjects_charts.dart';
import 'widgets/monthly_overview_chart.dart';
import 'widgets/request_distribution_charts.dart';
import 'widgets/avg_resolution_chart.dart';
import 'widgets/staff_workload_chart.dart';
import 'widgets/conversion_rate_card.dart';
/// Main reports screen — scrollable collection of chart widgets,
/// filterable by date range and toggleable per-widget, exportable to PDF.
class ReportsScreen extends ConsumerStatefulWidget {
const ReportsScreen({super.key});
@override
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
}
class _ReportsScreenState extends ConsumerState<ReportsScreen> {
// RepaintBoundary keys for PDF capture
final _keys = <ReportWidgetType, GlobalKey>{
for (final t in ReportWidgetType.values) t: GlobalKey(),
};
bool _exporting = false;
Future<void> _exportPdf() async {
setState(() => _exporting = true);
try {
final enabled = ref.read(reportWidgetToggleProvider);
final dateRange = ref.read(reportDateRangeProvider);
// Wait a frame so charts are rendered before capture
await Future<void>.delayed(const Duration(milliseconds: 200));
final captureKeys = <String, GlobalKey>{};
for (final type in ReportWidgetType.values) {
if (enabled.contains(type)) {
captureKeys[type.label] = _keys[type]!;
}
}
final pdfBytes = await ReportPdfExport.generatePdf(
captureKeys: captureKeys,
dateRange: dateRange,
enabledWidgets: enabled,
);
await ReportPdfExport.sharePdf(pdfBytes);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('PDF export failed: $e')));
}
} finally {
if (mounted) setState(() => _exporting = false);
}
}
@override
Widget build(BuildContext context) {
final enabled = ref.watch(reportWidgetToggleProvider);
final theme = Theme.of(context);
return Scaffold(
body: Column(
children: [
// Sticky top section
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
children: [
// Title row
Row(
children: [
Expanded(
child: Text(
'Reports',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
FilledButton.icon(
onPressed: _exporting ? null : _exportPdf,
icon: _exporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.picture_as_pdf, size: 18),
label: Text(_exporting ? 'Exporting…' : 'Export PDF'),
),
],
),
const SizedBox(height: 8),
const ReportDateFilter(),
const SizedBox(height: 4),
const ReportWidgetSelector(),
const SizedBox(height: 8),
],
),
),
),
),
// Scrollable chart area
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: _buildCharts(context, enabled),
),
),
),
),
],
),
);
}
Widget _buildCharts(BuildContext context, Set<ReportWidgetType> enabled) {
if (enabled.isEmpty) {
return SizedBox(
height: 300,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.analytics_outlined,
size: 48,
color: Theme.of(context).colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'No widgets selected',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 4),
Text(
'Use the "Widgets to Include" panel to enable reports.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 600;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Status donuts (paired on desktop) ──
if (_anyEnabled(enabled, [
ReportWidgetType.ticketsByStatus,
ReportWidgetType.tasksByStatus,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.ticketsByStatus))
TicketsByStatusChart(
repaintKey: _keys[ReportWidgetType.ticketsByStatus],
),
if (enabled.contains(ReportWidgetType.tasksByStatus))
TasksByStatusChart(
repaintKey: _keys[ReportWidgetType.tasksByStatus],
),
],
),
// ── Conversion rate KPI ──
if (enabled.contains(ReportWidgetType.conversionRate))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ConversionRateCard(
repaintKey: _keys[ReportWidgetType.conversionRate],
),
),
// ── Request type + category donuts ──
if (_anyEnabled(enabled, [
ReportWidgetType.requestType,
ReportWidgetType.requestCategory,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.requestType))
RequestTypeChart(
repaintKey: _keys[ReportWidgetType.requestType],
),
if (enabled.contains(ReportWidgetType.requestCategory))
RequestCategoryChart(
repaintKey: _keys[ReportWidgetType.requestCategory],
),
],
),
// ── Monthly overview (full width) ──
if (enabled.contains(ReportWidgetType.monthlyOverview))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MonthlyOverviewChart(
repaintKey: _keys[ReportWidgetType.monthlyOverview],
),
),
// ── Hourly charts (paired) ──
if (_anyEnabled(enabled, [
ReportWidgetType.tasksByHour,
ReportWidgetType.ticketsByHour,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.tasksByHour))
TasksByHourChart(
repaintKey: _keys[ReportWidgetType.tasksByHour],
),
if (enabled.contains(ReportWidgetType.ticketsByHour))
TicketsByHourChart(
repaintKey: _keys[ReportWidgetType.ticketsByHour],
),
],
),
// ── Top offices + subjects for Tickets (paired) ──
if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTickets,
ReportWidgetType.topTicketSubjects,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.topOfficesTickets))
TopOfficesTicketsChart(
repaintKey: _keys[ReportWidgetType.topOfficesTickets],
),
if (enabled.contains(ReportWidgetType.topTicketSubjects))
TopTicketSubjectsChart(
repaintKey: _keys[ReportWidgetType.topTicketSubjects],
),
],
),
// ── Top offices + subjects for Tasks (paired) ──
if (_anyEnabled(enabled, [
ReportWidgetType.topOfficesTasks,
ReportWidgetType.topTaskSubjects,
]))
_responsiveRow(
isWide: isWide,
children: [
if (enabled.contains(ReportWidgetType.topOfficesTasks))
TopOfficesTasksChart(
repaintKey: _keys[ReportWidgetType.topOfficesTasks],
),
if (enabled.contains(ReportWidgetType.topTaskSubjects))
TopTaskSubjectsChart(
repaintKey: _keys[ReportWidgetType.topTaskSubjects],
),
],
),
// ── Avg resolution (full width) ──
if (enabled.contains(ReportWidgetType.avgResolution))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AvgResolutionChart(
repaintKey: _keys[ReportWidgetType.avgResolution],
),
),
// ── Staff workload (full width) ──
if (enabled.contains(ReportWidgetType.staffWorkload))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: StaffWorkloadChart(
repaintKey: _keys[ReportWidgetType.staffWorkload],
),
),
],
);
},
);
}
/// Build either a [Row] (desktop, >=600) or a [Column] (mobile) for a pair
/// of chart widgets.
Widget _responsiveRow({
required bool isWide,
required List<Widget> children,
}) {
if (children.isEmpty) return const SizedBox.shrink();
if (isWide && children.length > 1) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children:
children
.expand(
(w) => [Expanded(child: w), const SizedBox(width: 12)],
)
.toList()
..removeLast(), // remove trailing SizedBox
),
);
}
// Single column layout
return Column(
children: children.map((w) {
return Padding(padding: const EdgeInsets.only(bottom: 12), child: w);
}).toList(),
);
}
bool _anyEnabled(
Set<ReportWidgetType> enabled,
List<ReportWidgetType> types,
) {
return types.any(enabled.contains);
}
}
@@ -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(),
);
},
),
);
}
}
@@ -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),
),
],
);
}
}
@@ -0,0 +1,271 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// Converts 24h hour int to AM/PM string.
String _hourAmPm(int h) {
if (h == 0) return '12AM';
if (h < 12) return '${h}AM';
if (h == 12) return '12PM';
return '${h - 12}PM';
}
/// Vertical bar chart showing tasks created per hour of day (023).
class TasksByHourChart extends ConsumerWidget {
const TasksByHourChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(tasksByHourReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Tasks Created by Hour',
isLoading: true,
height: 260,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Tasks Created by Hour',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, List<HourCount> data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Tasks Created by Hour',
repaintBoundaryKey: repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
// Fill all 24 hours
final hourMap = {for (final h in data) h.hour: h.count};
final maxCount = data
.fold<int>(0, (m, e) => e.count > m ? e.count : m)
.toDouble();
final colors = Theme.of(context).colorScheme;
return ReportCardWrapper(
title: 'Tasks Created by Hour',
repaintBoundaryKey: repaintKey,
height: 260,
child: BarChart(
BarChartData(
maxY: maxCount * 1.15,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIdx, rod, rodIdx) {
return BarTooltipItem(
'${_hourAmPm(group.x)}${rod.toY.toInt()}',
Theme.of(context).textTheme.bodySmall!.copyWith(
color: colors.onInverseSurface,
),
);
},
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) => Text(
value.toInt().toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final h = value.toInt();
// Show every 3rd hour label to avoid crowding
if (h % 3 != 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_hourAmPm(h),
style: Theme.of(context).textTheme.labelSmall,
),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
drawVerticalLine: false,
horizontalInterval: maxCount > 0
? (maxCount / 4).ceilToDouble()
: 1,
),
borderData: FlBorderData(show: false),
barGroups: List.generate(24, (i) {
final count = (hourMap[i] ?? 0).toDouble();
return BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: count,
width: 10,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
color: colors.primary,
),
],
);
}),
),
),
);
}
}
/// Vertical bar chart showing tickets created per hour of day (023).
class TicketsByHourChart extends ConsumerWidget {
const TicketsByHourChart({super.key, this.repaintKey});
final GlobalKey? repaintKey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncData = ref.watch(ticketsByHourReportProvider);
return asyncData.when(
loading: () => ReportCardWrapper(
title: 'Tickets Created by Hour',
isLoading: true,
height: 260,
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
error: (e, _) => ReportCardWrapper(
title: 'Tickets Created by Hour',
error: e.toString(),
repaintBoundaryKey: repaintKey,
child: const SizedBox.shrink(),
),
data: (data) => _build(context, data),
);
}
Widget _build(BuildContext context, List<HourCount> data) {
if (data.isEmpty) {
return ReportCardWrapper(
title: 'Tickets Created by Hour',
repaintBoundaryKey: repaintKey,
child: const SizedBox(
height: 200,
child: Center(child: Text('No data for selected period')),
),
);
}
final hourMap = {for (final h in data) h.hour: h.count};
final maxCount = data
.fold<int>(0, (m, e) => e.count > m ? e.count : m)
.toDouble();
final colors = Theme.of(context).colorScheme;
return ReportCardWrapper(
title: 'Tickets Created by Hour',
repaintBoundaryKey: repaintKey,
height: 260,
child: BarChart(
BarChartData(
maxY: maxCount * 1.15,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIdx, rod, rodIdx) {
return BarTooltipItem(
'${_hourAmPm(group.x)}${rod.toY.toInt()}',
Theme.of(context).textTheme.bodySmall!.copyWith(
color: colors.onInverseSurface,
),
);
},
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) => Text(
value.toInt().toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final h = value.toInt();
if (h % 3 != 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_hourAmPm(h),
style: Theme.of(context).textTheme.labelSmall,
),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
drawVerticalLine: false,
horizontalInterval: maxCount > 0
? (maxCount / 4).ceilToDouble()
: 1,
),
borderData: FlBorderData(show: false),
barGroups: List.generate(24, (i) {
final count = (hourMap[i] ?? 0).toDouble();
return BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: count,
width: 10,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
color: colors.tertiary,
),
],
);
}),
),
),
);
}
}
@@ -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),
],
);
}
}
@@ -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;
}
}
@@ -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),
),
],
),
);
}
}
@@ -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),
],
);
}
}
@@ -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)}';
@@ -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(),
);
},
),
);
}
}
@@ -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(' ');
}