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