tasq/lib/screens/reports/widgets/request_distribution_charts.dart
2026-03-03 07:38:40 +08:00

326 lines
10 KiB
Dart

import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart';
import 'report_card_wrapper.dart';
/// 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),
),
],
),
);
}
}