272 lines
8.5 KiB
Dart
272 lines
8.5 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';
|
||
|
||
/// 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 (0–23).
|
||
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 (0–23).
|
||
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,
|
||
),
|
||
],
|
||
);
|
||
}),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|