322 lines
10 KiB
Dart
322 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|