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