tasq/lib/screens/reports/widgets/monthly_overview_chart.dart
2026-03-03 07:38:40 +08:00

221 lines
7.2 KiB
Dart

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),
],
);
}
}