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