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