history_screen.dart 15 KB

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