| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- import 'package:flutter/material.dart';
- import '../services/database_helper.dart';
- import '../models/palm_record.dart';
- import '../constants/mpob_colors.dart';
- import 'batch_report_screen.dart';
- import 'record_detail_screen.dart';
- /// A group of [PalmRecord]s that share a batchId (or the fallback
- /// "ungrouped" bucket for pre-migration records with no batchId).
- class _BatchGroup {
- final String? batchId;
- final List<PalmRecord> records;
- _BatchGroup({required this.batchId, required this.records});
- DateTime get latestTimestamp => records.first.timestamp;
- String get dominantClass {
- final tally = <String, int>{};
- for (final r in records) {
- tally[r.ripenessClass] = (tally[r.ripenessClass] ?? 0) + 1;
- }
- return tally.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
- }
- }
- class HistoryScreen extends StatefulWidget {
- const HistoryScreen({super.key});
- @override
- State<HistoryScreen> createState() => _HistoryScreenState();
- }
- class _HistoryScreenState extends State<HistoryScreen> {
- final DatabaseHelper _dbHelper = DatabaseHelper();
- List<_BatchGroup> _allGroups = [];
- List<_BatchGroup> _groups = [];
- bool _isLoading = true;
- bool _sortAscending = false; // false = latest first (default)
- DateTimeRange? _dateRange;
- @override
- void initState() {
- super.initState();
- _loadHistory();
- }
- Future<void> _loadHistory() async {
- final records = await _dbHelper.getAllRecords(); // already ordered timestamp DESC
- final Map<String, List<PalmRecord>> byBatch = {};
- for (final record in records) {
- final key = record.batchId ?? '__ungrouped__';
- byBatch.putIfAbsent(key, () => []).add(record);
- }
- final groups = byBatch.entries
- .map((e) => _BatchGroup(batchId: e.key == '__ungrouped__' ? null : e.key, records: e.value))
- .toList();
- setState(() {
- _allGroups = groups;
- _isLoading = false;
- });
- _applyFilters();
- }
- void _applyFilters() {
- var filtered = _allGroups;
- if (_dateRange != null) {
- filtered = filtered.where((g) {
- final d = g.latestTimestamp;
- return !d.isBefore(_dateRange!.start) && d.isBefore(_dateRange!.end.add(const Duration(days: 1)));
- }).toList();
- }
- filtered = [...filtered]
- ..sort((a, b) => _sortAscending
- ? a.latestTimestamp.compareTo(b.latestTimestamp)
- : b.latestTimestamp.compareTo(a.latestTimestamp));
- setState(() => _groups = filtered);
- }
- void _clearFilters() {
- setState(() {
- _sortAscending = false;
- _dateRange = null;
- });
- _applyFilters();
- }
- Future<void> _resetHistory() async {
- final errorColor = Theme.of(context).colorScheme.error;
- final confirm = await showDialog<bool>(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text("Reset History"),
- content: const Text("Are you sure you want to clear all analysis records? This action is irreversible."),
- actions: [
- TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: Text("Reset", style: TextStyle(color: errorColor)),
- ),
- ],
- ),
- );
- if (confirm == true) {
- await _dbHelper.clearAllRecords();
- _loadHistory();
- if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text("History cleared successfully.")),
- );
- }
- }
- }
- Future<bool> _confirmDelete(String message) async {
- final errorColor = Theme.of(context).colorScheme.error;
- final confirm = await showDialog<bool>(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text("Delete"),
- content: Text(message),
- actions: [
- TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: Text("Delete", style: TextStyle(color: errorColor)),
- ),
- ],
- ),
- );
- return confirm ?? false;
- }
- Future<void> _deleteGroup(_BatchGroup group) async {
- if (group.batchId != null) {
- await _dbHelper.deleteBatch(group.batchId!);
- } else {
- await _dbHelper.deleteRecord(group.records.first);
- }
- _loadHistory();
- }
- bool get _hasActiveFilter => _dateRange != null || _sortAscending;
- Future<void> _openFilterSheet() async {
- await showModalBottomSheet(
- context: context,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
- ),
- builder: (sheetContext) {
- return StatefulBuilder(
- builder: (sheetContext, setSheetState) {
- final colorScheme = Theme.of(sheetContext).colorScheme;
- return Padding(
- padding: const EdgeInsets.all(24),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- const Text("Filter & Sort", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
- TextButton(
- onPressed: () {
- _clearFilters();
- setSheetState(() {});
- },
- child: const Text("Reset"),
- ),
- ],
- ),
- const SizedBox(height: 16),
- const Text("Sort Order", style: TextStyle(fontWeight: FontWeight.w600)),
- const SizedBox(height: 8),
- Row(
- children: [
- Expanded(
- child: ChoiceChip(
- label: const Text("Latest First"),
- selected: !_sortAscending,
- selectedColor: colorScheme.primary,
- labelStyle: TextStyle(color: !_sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
- onSelected: (_) {
- setState(() => _sortAscending = false);
- setSheetState(() {});
- _applyFilters();
- },
- ),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: ChoiceChip(
- label: const Text("Oldest First"),
- selected: _sortAscending,
- selectedColor: colorScheme.primary,
- labelStyle: TextStyle(color: _sortAscending ? colorScheme.onPrimary : colorScheme.onSurface),
- onSelected: (_) {
- setState(() => _sortAscending = true);
- setSheetState(() {});
- _applyFilters();
- },
- ),
- ),
- ],
- ),
- const SizedBox(height: 24),
- const Text("Date Range", style: TextStyle(fontWeight: FontWeight.w600)),
- const SizedBox(height: 8),
- Row(
- children: [
- Expanded(
- child: OutlinedButton.icon(
- icon: const Icon(Icons.date_range),
- label: Text(
- _dateRange == null
- ? "All Dates"
- : "${_formatDate(_dateRange!.start)} – ${_formatDate(_dateRange!.end)}",
- ),
- onPressed: () async {
- final now = DateTime.now();
- final picked = await showDateRangePicker(
- context: sheetContext,
- firstDate: DateTime(now.year - 5),
- lastDate: now,
- initialDateRange: _dateRange,
- );
- if (picked != null) {
- setState(() => _dateRange = picked);
- setSheetState(() {});
- _applyFilters();
- }
- },
- ),
- ),
- if (_dateRange != null)
- IconButton(
- icon: const Icon(Icons.clear),
- tooltip: 'Clear date range',
- onPressed: () {
- setState(() => _dateRange = null);
- setSheetState(() {});
- _applyFilters();
- },
- ),
- ],
- ),
- const SizedBox(height: 8),
- ],
- ),
- );
- },
- );
- },
- );
- }
- String _formatDate(DateTime d) => "${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}";
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- appBar: AppBar(
- title: const Text('History Vault'),
- actions: [
- IconButton(
- icon: Icon(_hasActiveFilter ? Icons.filter_alt : Icons.filter_list),
- tooltip: 'Filter & Sort',
- onPressed: _openFilterSheet,
- ),
- if (_allGroups.isNotEmpty)
- IconButton(
- icon: const Icon(Icons.delete_sweep),
- tooltip: 'Reset History',
- onPressed: _resetHistory,
- ),
- ],
- ),
- body: _isLoading
- ? const Center(child: CircularProgressIndicator())
- : _groups.isEmpty
- ? _buildEmptyState()
- : ListView.builder(
- itemCount: _groups.length,
- padding: const EdgeInsets.all(12),
- itemBuilder: (context, index) => _buildGroupCard(_groups[index]),
- ),
- );
- }
- Widget _buildEmptyState() {
- if (_allGroups.isEmpty) {
- return const Center(child: Text("No records found yet."));
- }
- return Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Text("No records match the selected filters."),
- const SizedBox(height: 12),
- TextButton(onPressed: _clearFilters, child: const Text("Clear Filters")),
- ],
- ),
- );
- }
- Widget _buildDeleteBackground() {
- final colorScheme = Theme.of(context).colorScheme;
- return Container(
- margin: const EdgeInsets.only(bottom: 12),
- decoration: BoxDecoration(
- color: colorScheme.error,
- borderRadius: BorderRadius.circular(15),
- ),
- alignment: Alignment.centerRight,
- padding: const EdgeInsets.symmetric(horizontal: 24),
- child: Icon(Icons.delete, color: colorScheme.onError, size: 28),
- );
- }
- Widget _buildGroupCard(_BatchGroup group) {
- final key = ValueKey(group.batchId ?? 'record_${group.records.first.id}');
- // A single ungrouped legacy record: render exactly as before, no batch chrome.
- if (group.batchId == null && group.records.length == 1) {
- return Dismissible(
- key: key,
- direction: DismissDirection.endToStart,
- background: _buildDeleteBackground(),
- confirmDismiss: (_) => _confirmDelete("Delete this record? This action is irreversible."),
- onDismissed: (_) => _deleteGroup(group),
- child: _buildRecordTile(group.records.first),
- );
- }
- final statusColor = getStatusColor(group.dominantClass);
- final start = group.records.last.timestamp;
- final end = group.records.first.timestamp;
- final timeRange = start.difference(end).inSeconds.abs() < 1
- ? _formatTime(end)
- : "${_formatTime(start)} – ${_formatTime(end)}";
- return Dismissible(
- key: key,
- direction: DismissDirection.endToStart,
- background: _buildDeleteBackground(),
- confirmDismiss: (_) => _confirmDelete("Delete this batch of ${group.records.length} scans? This action is irreversible."),
- onDismissed: (_) => _deleteGroup(group),
- child: Card(
- margin: const EdgeInsets.only(bottom: 12),
- elevation: 3,
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
- child: ListTile(
- leading: CircleAvatar(
- backgroundColor: statusColor.withOpacity(0.2),
- child: Icon(Icons.collections_bookmark, color: statusColor),
- ),
- title: Text(
- group.batchId == null ? "Ungrouped Scans" : "Batch • ${group.records.length} scans",
- style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
- ),
- subtitle: Text(
- "Majority: ${group.dominantClass}\n$timeRange",
- style: const TextStyle(fontSize: 12),
- ),
- trailing: const Icon(Icons.chevron_right),
- isThreeLine: true,
- onTap: group.batchId == null
- ? null
- : () => Navigator.push(
- context,
- MaterialPageRoute(builder: (context) => BatchReportScreen(batchId: group.batchId!)),
- ),
- ),
- ),
- );
- }
- Widget _buildRecordTile(PalmRecord record) {
- final statusColor = getStatusColor(record.ripenessClass);
- return Card(
- margin: const EdgeInsets.only(bottom: 12),
- elevation: 3,
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
- child: ListTile(
- leading: CircleAvatar(
- backgroundColor: statusColor.withOpacity(0.2),
- child: Icon(Icons.spa, color: statusColor),
- ),
- title: Text(
- record.ripenessClass,
- style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
- ),
- subtitle: Text(
- "Conf: ${(record.confidence * 100).toStringAsFixed(1)}% | ${record.timestamp.toString().split('.')[0]}",
- style: const TextStyle(fontSize: 12),
- ),
- trailing: const Icon(Icons.chevron_right),
- isThreeLine: true,
- onTap: () => Navigator.push(
- context,
- MaterialPageRoute(builder: (context) => RecordDetailScreen(frames: [record], initialIndex: 0)),
- ),
- ),
- );
- }
- String _formatTime(DateTime t) => t.toString().split('.')[0];
- }
|