history_screen.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import 'package:flutter/material.dart';
  2. import '../services/database_helper.dart';
  3. import '../models/palm_record.dart';
  4. import '../constants/mpob_colors.dart';
  5. import 'batch_report_screen.dart';
  6. import 'record_detail_screen.dart';
  7. /// A group of [PalmRecord]s that share a batchId (or the fallback
  8. /// "ungrouped" bucket for pre-migration records with no batchId).
  9. class _BatchGroup {
  10. final String? batchId;
  11. final List<PalmRecord> records;
  12. _BatchGroup({required this.batchId, required this.records});
  13. DateTime get latestTimestamp => records.first.timestamp;
  14. String get dominantClass {
  15. final tally = <String, int>{};
  16. for (final r in records) {
  17. tally[r.ripenessClass] = (tally[r.ripenessClass] ?? 0) + 1;
  18. }
  19. return tally.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
  20. }
  21. }
  22. class HistoryScreen extends StatefulWidget {
  23. const HistoryScreen({super.key});
  24. @override
  25. State<HistoryScreen> createState() => _HistoryScreenState();
  26. }
  27. class _HistoryScreenState extends State<HistoryScreen> {
  28. final DatabaseHelper _dbHelper = DatabaseHelper();
  29. List<_BatchGroup> _allGroups = [];
  30. List<_BatchGroup> _groups = [];
  31. bool _isLoading = true;
  32. bool _sortAscending = false; // false = latest first (default)
  33. DateTimeRange? _dateRange;
  34. @override
  35. void initState() {
  36. super.initState();
  37. _loadHistory();
  38. }
  39. Future<void> _loadHistory() async {
  40. final records = await _dbHelper.getAllRecords(); // already ordered timestamp DESC
  41. final Map<String, List<PalmRecord>> byBatch = {};
  42. for (final record in records) {
  43. final key = record.batchId ?? '__ungrouped__';
  44. byBatch.putIfAbsent(key, () => []).add(record);
  45. }
  46. final groups = byBatch.entries
  47. .map((e) => _BatchGroup(batchId: e.key == '__ungrouped__' ? null : e.key, records: e.value))
  48. .toList();
  49. setState(() {
  50. _allGroups = groups;
  51. _isLoading = false;
  52. });
  53. _applyFilters();
  54. }
  55. void _applyFilters() {
  56. var filtered = _allGroups;
  57. if (_dateRange != null) {
  58. filtered = filtered.where((g) {
  59. final d = g.latestTimestamp;
  60. return !d.isBefore(_dateRange!.start) && d.isBefore(_dateRange!.end.add(const Duration(days: 1)));
  61. }).toList();
  62. }
  63. filtered = [...filtered]
  64. ..sort((a, b) => _sortAscending
  65. ? a.latestTimestamp.compareTo(b.latestTimestamp)
  66. : b.latestTimestamp.compareTo(a.latestTimestamp));
  67. setState(() => _groups = filtered);
  68. }
  69. void _clearFilters() {
  70. setState(() {
  71. _sortAscending = false;
  72. _dateRange = null;
  73. });
  74. _applyFilters();
  75. }
  76. Future<void> _resetHistory() async {
  77. final errorColor = Theme.of(context).colorScheme.error;
  78. final confirm = await showDialog<bool>(
  79. context: context,
  80. builder: (context) => AlertDialog(
  81. title: const Text("Reset History"),
  82. content: const Text("Are you sure you want to clear all analysis records? This action is irreversible."),
  83. actions: [
  84. TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
  85. TextButton(
  86. onPressed: () => Navigator.pop(context, true),
  87. child: Text("Reset", style: TextStyle(color: errorColor)),
  88. ),
  89. ],
  90. ),
  91. );
  92. if (confirm == true) {
  93. await _dbHelper.clearAllRecords();
  94. _loadHistory();
  95. if (mounted) {
  96. ScaffoldMessenger.of(context).showSnackBar(
  97. const SnackBar(content: Text("History cleared successfully.")),
  98. );
  99. }
  100. }
  101. }
  102. Future<bool> _confirmDelete(String message) async {
  103. final errorColor = Theme.of(context).colorScheme.error;
  104. final confirm = await showDialog<bool>(
  105. context: context,
  106. builder: (context) => AlertDialog(
  107. title: const Text("Delete"),
  108. content: Text(message),
  109. actions: [
  110. TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
  111. TextButton(
  112. onPressed: () => Navigator.pop(context, true),
  113. child: Text("Delete", style: TextStyle(color: errorColor)),
  114. ),
  115. ],
  116. ),
  117. );
  118. return confirm ?? false;
  119. }
  120. Future<void> _deleteGroup(_BatchGroup group) async {
  121. if (group.batchId != null) {
  122. await _dbHelper.deleteBatch(group.batchId!);
  123. } else {
  124. await _dbHelper.deleteRecord(group.records.first);
  125. }
  126. _loadHistory();
  127. }
  128. bool get _hasActiveFilter => _dateRange != null || _sortAscending;
  129. Future<void> _openFilterSheet() async {
  130. await showModalBottomSheet(
  131. context: context,
  132. shape: const RoundedRectangleBorder(
  133. borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
  134. ),
  135. builder: (sheetContext) {
  136. return StatefulBuilder(
  137. builder: (sheetContext, setSheetState) {
  138. final colorScheme = Theme.of(sheetContext).colorScheme;
  139. return Padding(
  140. padding: const EdgeInsets.all(24),
  141. child: Column(
  142. mainAxisSize: MainAxisSize.min,
  143. crossAxisAlignment: CrossAxisAlignment.start,
  144. children: [
  145. Row(
  146. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  147. children: [
  148. const Text("Filter & Sort", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
  149. TextButton(
  150. onPressed: () {
  151. _clearFilters();
  152. setSheetState(() {});
  153. },
  154. child: const Text("Reset"),
  155. ),
  156. ],
  157. ),
  158. const SizedBox(height: 16),
  159. const Text("Sort Order", style: TextStyle(fontWeight: FontWeight.w600)),
  160. const SizedBox(height: 8),
  161. Row(
  162. children: [
  163. Expanded(
  164. child: ChoiceChip(
  165. label: const Text("Latest First"),
  166. selected: !_sortAscending,
  167. selectedColor: colorScheme.primary,
  168. labelStyle: TextStyle(color: !_sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
  169. onSelected: (_) {
  170. setState(() => _sortAscending = false);
  171. setSheetState(() {});
  172. _applyFilters();
  173. },
  174. ),
  175. ),
  176. const SizedBox(width: 12),
  177. Expanded(
  178. child: ChoiceChip(
  179. label: const Text("Oldest First"),
  180. selected: _sortAscending,
  181. selectedColor: colorScheme.primary,
  182. labelStyle: TextStyle(color: _sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
  183. onSelected: (_) {
  184. setState(() => _sortAscending = true);
  185. setSheetState(() {});
  186. _applyFilters();
  187. },
  188. ),
  189. ),
  190. ],
  191. ),
  192. const SizedBox(height: 24),
  193. const Text("Date Range", style: TextStyle(fontWeight: FontWeight.w600)),
  194. const SizedBox(height: 8),
  195. Row(
  196. children: [
  197. Expanded(
  198. child: OutlinedButton.icon(
  199. icon: const Icon(Icons.date_range),
  200. label: Text(
  201. _dateRange == null
  202. ? "All Dates"
  203. : "${_formatDate(_dateRange!.start)} – ${_formatDate(_dateRange!.end)}",
  204. ),
  205. onPressed: () async {
  206. final now = DateTime.now();
  207. final picked = await showDateRangePicker(
  208. context: sheetContext,
  209. firstDate: DateTime(now.year - 5),
  210. lastDate: now,
  211. initialDateRange: _dateRange,
  212. );
  213. if (picked != null) {
  214. setState(() => _dateRange = picked);
  215. setSheetState(() {});
  216. _applyFilters();
  217. }
  218. },
  219. ),
  220. ),
  221. if (_dateRange != null)
  222. IconButton(
  223. icon: const Icon(Icons.clear),
  224. tooltip: 'Clear date range',
  225. onPressed: () {
  226. setState(() => _dateRange = null);
  227. setSheetState(() {});
  228. _applyFilters();
  229. },
  230. ),
  231. ],
  232. ),
  233. const SizedBox(height: 8),
  234. ],
  235. ),
  236. );
  237. },
  238. );
  239. },
  240. );
  241. }
  242. String _formatDate(DateTime d) => "${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}";
  243. @override
  244. Widget build(BuildContext context) {
  245. return Scaffold(
  246. appBar: AppBar(
  247. title: const Text('History Vault'),
  248. actions: [
  249. IconButton(
  250. icon: Icon(_hasActiveFilter ? Icons.filter_alt : Icons.filter_list),
  251. tooltip: 'Filter & Sort',
  252. onPressed: _openFilterSheet,
  253. ),
  254. if (_allGroups.isNotEmpty)
  255. IconButton(
  256. icon: const Icon(Icons.delete_sweep),
  257. tooltip: 'Reset History',
  258. onPressed: _resetHistory,
  259. ),
  260. ],
  261. ),
  262. body: _isLoading
  263. ? const Center(child: CircularProgressIndicator())
  264. : _groups.isEmpty
  265. ? _buildEmptyState()
  266. : ListView.builder(
  267. itemCount: _groups.length,
  268. padding: const EdgeInsets.all(12),
  269. itemBuilder: (context, index) => _buildGroupCard(_groups[index]),
  270. ),
  271. );
  272. }
  273. Widget _buildEmptyState() {
  274. if (_allGroups.isEmpty) {
  275. return const Center(child: Text("No records found yet."));
  276. }
  277. return Center(
  278. child: Column(
  279. mainAxisSize: MainAxisSize.min,
  280. children: [
  281. const Text("No records match the selected filters."),
  282. const SizedBox(height: 12),
  283. TextButton(onPressed: _clearFilters, child: const Text("Clear Filters")),
  284. ],
  285. ),
  286. );
  287. }
  288. Widget _buildDeleteBackground() {
  289. final colorScheme = Theme.of(context).colorScheme;
  290. return Container(
  291. margin: const EdgeInsets.only(bottom: 12),
  292. decoration: BoxDecoration(
  293. color: colorScheme.error,
  294. borderRadius: BorderRadius.circular(15),
  295. ),
  296. alignment: Alignment.centerRight,
  297. padding: const EdgeInsets.symmetric(horizontal: 24),
  298. child: Icon(Icons.delete, color: colorScheme.onError, size: 28),
  299. );
  300. }
  301. Widget _buildGroupCard(_BatchGroup group) {
  302. final key = ValueKey(group.batchId ?? 'record_${group.records.first.id}');
  303. // A single ungrouped legacy record: render exactly as before, no batch chrome.
  304. if (group.batchId == null && group.records.length == 1) {
  305. return Dismissible(
  306. key: key,
  307. direction: DismissDirection.endToStart,
  308. background: _buildDeleteBackground(),
  309. confirmDismiss: (_) => _confirmDelete("Delete this record? This action is irreversible."),
  310. onDismissed: (_) => _deleteGroup(group),
  311. child: _buildRecordTile(group.records.first),
  312. );
  313. }
  314. final statusColor = getStatusColor(group.dominantClass);
  315. final start = group.records.last.timestamp;
  316. final end = group.records.first.timestamp;
  317. final timeRange = start.difference(end).inSeconds.abs() < 1
  318. ? _formatTime(end)
  319. : "${_formatTime(start)} – ${_formatTime(end)}";
  320. return Dismissible(
  321. key: key,
  322. direction: DismissDirection.endToStart,
  323. background: _buildDeleteBackground(),
  324. confirmDismiss: (_) => _confirmDelete("Delete this batch of ${group.records.length} scans? This action is irreversible."),
  325. onDismissed: (_) => _deleteGroup(group),
  326. child: Card(
  327. margin: const EdgeInsets.only(bottom: 12),
  328. elevation: 3,
  329. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
  330. child: ListTile(
  331. leading: CircleAvatar(
  332. backgroundColor: statusColor.withOpacity(0.2),
  333. child: Icon(Icons.collections_bookmark, color: statusColor),
  334. ),
  335. title: Text(
  336. group.batchId == null ? "Ungrouped Scans" : "Batch • ${group.records.length} scans",
  337. style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
  338. ),
  339. subtitle: Text(
  340. "Majority: ${group.dominantClass}\n$timeRange",
  341. style: const TextStyle(fontSize: 12),
  342. ),
  343. trailing: const Icon(Icons.chevron_right),
  344. isThreeLine: true,
  345. onTap: group.batchId == null
  346. ? null
  347. : () => Navigator.push(
  348. context,
  349. MaterialPageRoute(builder: (context) => BatchReportScreen(batchId: group.batchId!)),
  350. ),
  351. ),
  352. ),
  353. );
  354. }
  355. Widget _buildRecordTile(PalmRecord record) {
  356. final statusColor = getStatusColor(record.ripenessClass);
  357. return Card(
  358. margin: const EdgeInsets.only(bottom: 12),
  359. elevation: 3,
  360. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
  361. child: ListTile(
  362. leading: CircleAvatar(
  363. backgroundColor: statusColor.withOpacity(0.2),
  364. child: Icon(Icons.spa, color: statusColor),
  365. ),
  366. title: Text(
  367. record.ripenessClass,
  368. style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
  369. ),
  370. subtitle: Text(
  371. "Conf: ${(record.confidence * 100).toStringAsFixed(1)}% | ${record.timestamp.toString().split('.')[0]}",
  372. style: const TextStyle(fontSize: 12),
  373. ),
  374. trailing: const Icon(Icons.chevron_right),
  375. isThreeLine: true,
  376. onTap: () => Navigator.push(
  377. context,
  378. MaterialPageRoute(builder: (context) => RecordDetailScreen(frames: [record], initialIndex: 0)),
  379. ),
  380. ),
  381. );
  382. }
  383. String _formatTime(DateTime t) => t.toString().split('.')[0];
  384. }