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

335 lines
11 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 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)}';