Reports
This commit is contained in:
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user