history_screen.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  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 '../services/app_strings.dart';
  6. import '../theme/locale_controller.dart';
  7. import 'batch_report_screen.dart';
  8. import 'record_detail_screen.dart';
  9. /// A group of [PalmRecord]s that share a batchId (or the fallback
  10. /// "ungrouped" bucket for pre-migration records with no batchId).
  11. class _BatchGroup {
  12. final String? batchId;
  13. final List<PalmRecord> records;
  14. _BatchGroup({required this.batchId, required this.records});
  15. DateTime get latestTimestamp => records.first.timestamp;
  16. String get dominantClass {
  17. final tally = <String, int>{};
  18. for (final r in records) {
  19. tally[r.ripenessClass] = (tally[r.ripenessClass] ?? 0) + 1;
  20. }
  21. return tally.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
  22. }
  23. }
  24. /// A single record paired with the batch group it belongs to, so a flat
  25. /// (ungrouped) list item can still open [RecordDetailScreen] with the full
  26. /// batch as navigable frames.
  27. class _FlatRecord {
  28. final PalmRecord record;
  29. final _BatchGroup group;
  30. _FlatRecord({required this.record, required this.group});
  31. }
  32. class HistoryScreen extends StatefulWidget {
  33. const HistoryScreen({super.key});
  34. @override
  35. State<HistoryScreen> createState() => _HistoryScreenState();
  36. }
  37. class _HistoryScreenState extends State<HistoryScreen> {
  38. final DatabaseHelper _dbHelper = DatabaseHelper();
  39. List<_BatchGroup> _allGroups = [];
  40. List<_BatchGroup> _groups = [];
  41. bool _isLoading = true;
  42. bool _sortAscending = false; // false = latest first (default)
  43. DateTimeRange? _dateRange;
  44. bool _flatView = false;
  45. @override
  46. void initState() {
  47. super.initState();
  48. _loadHistory();
  49. }
  50. Future<void> _loadHistory() async {
  51. final records = await _dbHelper.getAllRecords(); // already ordered timestamp DESC
  52. final Map<String, List<PalmRecord>> byBatch = {};
  53. for (final record in records) {
  54. final key = record.batchId ?? '__ungrouped__';
  55. byBatch.putIfAbsent(key, () => []).add(record);
  56. }
  57. final groups = byBatch.entries
  58. .map((e) => _BatchGroup(batchId: e.key == '__ungrouped__' ? null : e.key, records: e.value))
  59. .toList();
  60. setState(() {
  61. _allGroups = groups;
  62. _isLoading = false;
  63. });
  64. _applyFilters();
  65. }
  66. void _applyFilters() {
  67. var filtered = _allGroups;
  68. if (_dateRange != null) {
  69. filtered = filtered.where((g) {
  70. final d = g.latestTimestamp;
  71. return !d.isBefore(_dateRange!.start) && d.isBefore(_dateRange!.end.add(const Duration(days: 1)));
  72. }).toList();
  73. }
  74. filtered = [...filtered]
  75. ..sort((a, b) => _sortAscending
  76. ? a.latestTimestamp.compareTo(b.latestTimestamp)
  77. : b.latestTimestamp.compareTo(a.latestTimestamp));
  78. setState(() => _groups = filtered);
  79. }
  80. void _clearFilters() {
  81. setState(() {
  82. _sortAscending = false;
  83. _dateRange = null;
  84. });
  85. _applyFilters();
  86. }
  87. Future<void> _resetHistory() async {
  88. final errorColor = Theme.of(context).colorScheme.error;
  89. final confirm = await showDialog<bool>(
  90. context: context,
  91. builder: (context) => AlertDialog(
  92. title: Text(AppStrings.t('history_reset_title')),
  93. content: Text(AppStrings.t('history_reset_message')),
  94. actions: [
  95. TextButton(onPressed: () => Navigator.pop(context, false), child: Text(AppStrings.t('cancel_button'))),
  96. TextButton(
  97. onPressed: () => Navigator.pop(context, true),
  98. child: Text(AppStrings.t('reset_button'), style: TextStyle(color: errorColor)),
  99. ),
  100. ],
  101. ),
  102. );
  103. if (confirm == true) {
  104. await _dbHelper.clearAllRecords();
  105. _loadHistory();
  106. if (mounted) {
  107. ScaffoldMessenger.of(context).showSnackBar(
  108. SnackBar(content: Text(AppStrings.t('history_cleared_snackbar'))),
  109. );
  110. }
  111. }
  112. }
  113. Future<bool> _confirmDelete(String message) async {
  114. final errorColor = Theme.of(context).colorScheme.error;
  115. final confirm = await showDialog<bool>(
  116. context: context,
  117. builder: (context) => AlertDialog(
  118. title: Text(AppStrings.t('delete_button')),
  119. content: Text(message),
  120. actions: [
  121. TextButton(onPressed: () => Navigator.pop(context, false), child: Text(AppStrings.t('cancel_button'))),
  122. TextButton(
  123. onPressed: () => Navigator.pop(context, true),
  124. child: Text(AppStrings.t('delete_button'), style: TextStyle(color: errorColor)),
  125. ),
  126. ],
  127. ),
  128. );
  129. return confirm ?? false;
  130. }
  131. Future<void> _deleteGroup(_BatchGroup group) async {
  132. if (group.batchId != null) {
  133. await _dbHelper.deleteBatch(group.batchId!);
  134. } else {
  135. await _dbHelper.deleteRecord(group.records.first);
  136. }
  137. _loadHistory();
  138. }
  139. List<_FlatRecord> get _flatRecords {
  140. final all = <_FlatRecord>[
  141. for (final g in _groups)
  142. for (final r in g.records) _FlatRecord(record: r, group: g),
  143. ];
  144. all.sort((a, b) => _sortAscending
  145. ? a.record.timestamp.compareTo(b.record.timestamp)
  146. : b.record.timestamp.compareTo(a.record.timestamp));
  147. return all;
  148. }
  149. Future<void> _deleteSingleRecord(PalmRecord record) async {
  150. await _dbHelper.deleteRecord(record);
  151. _loadHistory();
  152. }
  153. bool get _hasActiveFilter => _dateRange != null || _sortAscending;
  154. Future<void> _openFilterSheet() async {
  155. await showModalBottomSheet(
  156. context: context,
  157. shape: const RoundedRectangleBorder(
  158. borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
  159. ),
  160. builder: (sheetContext) {
  161. return StatefulBuilder(
  162. builder: (sheetContext, setSheetState) {
  163. final colorScheme = Theme.of(sheetContext).colorScheme;
  164. return Padding(
  165. padding: const EdgeInsets.all(24),
  166. child: Column(
  167. mainAxisSize: MainAxisSize.min,
  168. crossAxisAlignment: CrossAxisAlignment.start,
  169. children: [
  170. Row(
  171. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  172. children: [
  173. Text(AppStrings.t('filter_sort_title'), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
  174. TextButton(
  175. onPressed: () {
  176. _clearFilters();
  177. setSheetState(() {});
  178. },
  179. child: Text(AppStrings.t('reset_button')),
  180. ),
  181. ],
  182. ),
  183. const SizedBox(height: 16),
  184. Text(AppStrings.t('sort_order_label'), style: const TextStyle(fontWeight: FontWeight.w600)),
  185. const SizedBox(height: 8),
  186. Row(
  187. children: [
  188. Expanded(
  189. child: ChoiceChip(
  190. label: Text(AppStrings.t('sort_latest_first')),
  191. selected: !_sortAscending,
  192. selectedColor: colorScheme.primary,
  193. labelStyle: TextStyle(color: !_sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
  194. onSelected: (_) {
  195. setState(() => _sortAscending = false);
  196. setSheetState(() {});
  197. _applyFilters();
  198. },
  199. ),
  200. ),
  201. const SizedBox(width: 12),
  202. Expanded(
  203. child: ChoiceChip(
  204. label: Text(AppStrings.t('sort_oldest_first')),
  205. selected: _sortAscending,
  206. selectedColor: colorScheme.primary,
  207. labelStyle: TextStyle(color: _sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
  208. onSelected: (_) {
  209. setState(() => _sortAscending = true);
  210. setSheetState(() {});
  211. _applyFilters();
  212. },
  213. ),
  214. ),
  215. ],
  216. ),
  217. const SizedBox(height: 24),
  218. Text(AppStrings.t('date_range_label'), style: const TextStyle(fontWeight: FontWeight.w600)),
  219. const SizedBox(height: 8),
  220. Row(
  221. children: [
  222. Expanded(
  223. child: OutlinedButton.icon(
  224. icon: const Icon(Icons.date_range),
  225. label: Text(
  226. _dateRange == null
  227. ? AppStrings.t('all_dates_label')
  228. : "${_formatDate(_dateRange!.start)} – ${_formatDate(_dateRange!.end)}",
  229. ),
  230. onPressed: () async {
  231. final now = DateTime.now();
  232. final picked = await showDateRangePicker(
  233. context: sheetContext,
  234. firstDate: DateTime(now.year - 5),
  235. lastDate: now,
  236. initialDateRange: _dateRange,
  237. );
  238. if (picked != null) {
  239. setState(() => _dateRange = picked);
  240. setSheetState(() {});
  241. _applyFilters();
  242. }
  243. },
  244. ),
  245. ),
  246. if (_dateRange != null)
  247. IconButton(
  248. icon: const Icon(Icons.clear),
  249. tooltip: AppStrings.t('clear_date_range_tooltip'),
  250. onPressed: () {
  251. setState(() => _dateRange = null);
  252. setSheetState(() {});
  253. _applyFilters();
  254. },
  255. ),
  256. ],
  257. ),
  258. const SizedBox(height: 8),
  259. ],
  260. ),
  261. );
  262. },
  263. );
  264. },
  265. );
  266. }
  267. String _formatDate(DateTime d) => "${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}";
  268. @override
  269. Widget build(BuildContext context) {
  270. return ValueListenableBuilder<String>(
  271. valueListenable: LocaleController.localeCode,
  272. builder: (context, _, _) => _buildScaffold(context),
  273. );
  274. }
  275. Widget _buildScaffold(BuildContext context) {
  276. return Scaffold(
  277. appBar: AppBar(
  278. title: Text(AppStrings.t('history_vault_title')),
  279. actions: [
  280. IconButton(
  281. icon: Icon(_flatView ? Icons.collections_bookmark : Icons.view_list),
  282. tooltip: _flatView ? AppStrings.t('view_toggle_batches_tooltip') : AppStrings.t('view_toggle_flat_tooltip'),
  283. onPressed: () => setState(() => _flatView = !_flatView),
  284. ),
  285. IconButton(
  286. icon: Icon(_hasActiveFilter ? Icons.filter_alt : Icons.filter_list),
  287. tooltip: AppStrings.t('filter_sort_title'),
  288. onPressed: _openFilterSheet,
  289. ),
  290. if (_allGroups.isNotEmpty)
  291. IconButton(
  292. icon: const Icon(Icons.delete_sweep),
  293. tooltip: AppStrings.t('reset_history_tooltip'),
  294. onPressed: _resetHistory,
  295. ),
  296. ],
  297. ),
  298. body: _isLoading
  299. ? const Center(child: CircularProgressIndicator())
  300. : _groups.isEmpty
  301. ? _buildEmptyState()
  302. : _flatView
  303. ? _buildFlatList()
  304. : ListView.builder(
  305. itemCount: _groups.length,
  306. padding: const EdgeInsets.all(12),
  307. itemBuilder: (context, index) => _buildGroupCard(_groups[index]),
  308. ),
  309. );
  310. }
  311. Widget _buildFlatList() {
  312. final flat = _flatRecords;
  313. return ListView.builder(
  314. itemCount: flat.length,
  315. padding: const EdgeInsets.all(12),
  316. itemBuilder: (context, index) => _buildFlatTile(flat[index]),
  317. );
  318. }
  319. Widget _buildFlatTile(_FlatRecord entry) {
  320. final record = entry.record;
  321. final statusColor = getStatusColor(record.ripenessClass);
  322. return Dismissible(
  323. key: ValueKey('flat_${record.id}'),
  324. direction: DismissDirection.endToStart,
  325. background: _buildDeleteBackground(),
  326. confirmDismiss: (_) => _confirmDelete(AppStrings.t('history_delete_record_confirm')),
  327. onDismissed: (_) => _deleteSingleRecord(record),
  328. child: Card(
  329. margin: const EdgeInsets.only(bottom: 12),
  330. elevation: 3,
  331. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
  332. child: ListTile(
  333. leading: CircleAvatar(
  334. backgroundColor: statusColor.withValues(alpha: 0.2),
  335. child: Icon(Icons.spa, color: statusColor),
  336. ),
  337. title: Text(
  338. AppStrings.className(record.ripenessClass),
  339. style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
  340. ),
  341. subtitle: Text(
  342. AppStrings.tp('record_conf_timestamp', {
  343. 'percent': (record.confidence * 100).toStringAsFixed(1),
  344. 'timestamp': record.timestamp.toString().split('.')[0],
  345. }),
  346. style: const TextStyle(fontSize: 12),
  347. ),
  348. trailing: const Icon(Icons.chevron_right),
  349. isThreeLine: true,
  350. onTap: () => Navigator.push(
  351. context,
  352. MaterialPageRoute(
  353. builder: (context) => RecordDetailScreen(
  354. frames: entry.group.records,
  355. initialIndex: entry.group.records.indexOf(record),
  356. ),
  357. ),
  358. ),
  359. ),
  360. ),
  361. );
  362. }
  363. Widget _buildEmptyState() {
  364. if (_allGroups.isEmpty) {
  365. return Center(child: Text(AppStrings.t('history_empty_no_records')));
  366. }
  367. return Center(
  368. child: Column(
  369. mainAxisSize: MainAxisSize.min,
  370. children: [
  371. Text(AppStrings.t('history_empty_no_matches')),
  372. const SizedBox(height: 12),
  373. TextButton(onPressed: _clearFilters, child: Text(AppStrings.t('clear_filters_button'))),
  374. ],
  375. ),
  376. );
  377. }
  378. Widget _buildDeleteBackground() {
  379. final colorScheme = Theme.of(context).colorScheme;
  380. return Container(
  381. margin: const EdgeInsets.only(bottom: 12),
  382. decoration: BoxDecoration(
  383. color: colorScheme.error,
  384. borderRadius: BorderRadius.circular(15),
  385. ),
  386. alignment: Alignment.centerRight,
  387. padding: const EdgeInsets.symmetric(horizontal: 24),
  388. child: Icon(Icons.delete, color: colorScheme.onError, size: 28),
  389. );
  390. }
  391. Widget _buildGroupCard(_BatchGroup group) {
  392. final key = ValueKey(group.batchId ?? 'record_${group.records.first.id}');
  393. // A single ungrouped legacy record: render exactly as before, no batch chrome.
  394. if (group.batchId == null && group.records.length == 1) {
  395. return Dismissible(
  396. key: key,
  397. direction: DismissDirection.endToStart,
  398. background: _buildDeleteBackground(),
  399. confirmDismiss: (_) => _confirmDelete(AppStrings.t('history_delete_record_confirm')),
  400. onDismissed: (_) => _deleteGroup(group),
  401. child: _buildRecordTile(group.records.first),
  402. );
  403. }
  404. final statusColor = getStatusColor(group.dominantClass);
  405. final start = group.records.last.timestamp;
  406. final end = group.records.first.timestamp;
  407. final timeRange = start.difference(end).inSeconds.abs() < 1
  408. ? _formatTime(end)
  409. : "${_formatTime(start)} – ${_formatTime(end)}";
  410. return Dismissible(
  411. key: key,
  412. direction: DismissDirection.endToStart,
  413. background: _buildDeleteBackground(),
  414. confirmDismiss: (_) => _confirmDelete(AppStrings.tp('history_delete_batch_confirm', {'count': '${group.records.length}'})),
  415. onDismissed: (_) => _deleteGroup(group),
  416. child: Card(
  417. margin: const EdgeInsets.only(bottom: 12),
  418. elevation: 3,
  419. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
  420. child: ListTile(
  421. leading: CircleAvatar(
  422. backgroundColor: statusColor.withValues(alpha: 0.2),
  423. child: Icon(Icons.collections_bookmark, color: statusColor),
  424. ),
  425. title: Text(
  426. group.batchId == null
  427. ? AppStrings.t('ungrouped_scans_label')
  428. : AppStrings.tp('batch_scans_label', {'count': '${group.records.length}'}),
  429. style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
  430. ),
  431. subtitle: Text(
  432. AppStrings.tp('majority_label', {'className': AppStrings.className(group.dominantClass), 'timeRange': timeRange}),
  433. style: const TextStyle(fontSize: 12),
  434. ),
  435. trailing: const Icon(Icons.chevron_right),
  436. isThreeLine: true,
  437. onTap: group.batchId == null
  438. ? null
  439. : () => Navigator.push(
  440. context,
  441. MaterialPageRoute(builder: (context) => BatchReportScreen(batchId: group.batchId!)),
  442. ),
  443. ),
  444. ),
  445. );
  446. }
  447. Widget _buildRecordTile(PalmRecord record) {
  448. final statusColor = getStatusColor(record.ripenessClass);
  449. return Card(
  450. margin: const EdgeInsets.only(bottom: 12),
  451. elevation: 3,
  452. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
  453. child: ListTile(
  454. leading: CircleAvatar(
  455. backgroundColor: statusColor.withValues(alpha: 0.2),
  456. child: Icon(Icons.spa, color: statusColor),
  457. ),
  458. title: Text(
  459. AppStrings.className(record.ripenessClass),
  460. style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
  461. ),
  462. subtitle: Text(
  463. AppStrings.tp('record_conf_timestamp', {
  464. 'percent': (record.confidence * 100).toStringAsFixed(1),
  465. 'timestamp': record.timestamp.toString().split('.')[0],
  466. }),
  467. style: const TextStyle(fontSize: 12),
  468. ),
  469. trailing: const Icon(Icons.chevron_right),
  470. isThreeLine: true,
  471. onTap: () => Navigator.push(
  472. context,
  473. MaterialPageRoute(builder: (context) => RecordDetailScreen(frames: [record], initialIndex: 0)),
  474. ),
  475. ),
  476. );
  477. }
  478. String _formatTime(DateTime t) => t.toString().split('.')[0];
  479. }