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