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