history_screen.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import 'dart:io';
  2. import 'dart:ui';
  3. import 'package:flutter/material.dart';
  4. import 'package:path/path.dart' as p;
  5. import '../services/database_helper.dart';
  6. import '../models/palm_record.dart';
  7. import '../widgets/palm_bounding_box.dart';
  8. /// Simple detection data parsed from the stored Map in the database.
  9. class _DetectionData {
  10. final String className;
  11. final double confidence;
  12. final Rect normalizedBox;
  13. const _DetectionData({
  14. required this.className,
  15. required this.confidence,
  16. required this.normalizedBox,
  17. });
  18. factory _DetectionData.fromMap(Map<String, dynamic> m) {
  19. return _DetectionData(
  20. className: (m['className'] ?? m['class'] ?? 'Unknown').toString(),
  21. confidence: (m['confidence'] as num?)?.toDouble() ?? 0.0,
  22. normalizedBox: Rect.fromLTRB(
  23. (m['x1'] as num?)?.toDouble() ?? 0.0,
  24. (m['y1'] as num?)?.toDouble() ?? 0.0,
  25. (m['x2'] as num?)?.toDouble() ?? 1.0,
  26. (m['y2'] as num?)?.toDouble() ?? 1.0,
  27. ),
  28. );
  29. }
  30. }
  31. class HistoryScreen extends StatefulWidget {
  32. const HistoryScreen({super.key});
  33. @override
  34. State<HistoryScreen> createState() => _HistoryScreenState();
  35. }
  36. class _HistoryScreenState extends State<HistoryScreen> {
  37. final DatabaseHelper _dbHelper = DatabaseHelper();
  38. List<PalmRecord> _records = [];
  39. bool _isLoading = true;
  40. @override
  41. void initState() {
  42. super.initState();
  43. _loadHistory();
  44. }
  45. Future<void> _loadHistory() async {
  46. final records = await _dbHelper.getAllRecords();
  47. setState(() {
  48. _records = records;
  49. _isLoading = false;
  50. });
  51. }
  52. Future<void> _resetHistory() async {
  53. final confirm = await showDialog<bool>(
  54. context: context,
  55. builder: (context) => AlertDialog(
  56. title: const Text("Reset History"),
  57. content: const Text("Are you sure you want to clear all analysis records? This action is irreversible."),
  58. actions: [
  59. TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
  60. TextButton(
  61. onPressed: () => Navigator.pop(context, true),
  62. child: const Text("Reset", style: TextStyle(color: Colors.red)),
  63. ),
  64. ],
  65. ),
  66. );
  67. if (confirm == true) {
  68. await _dbHelper.clearAllRecords();
  69. _loadHistory();
  70. if (mounted) {
  71. ScaffoldMessenger.of(context).showSnackBar(
  72. const SnackBar(content: Text("History cleared successfully.")),
  73. );
  74. }
  75. }
  76. }
  77. Color _getStatusColor(String label) {
  78. if (label == 'Empty_Bunch' || label == 'Abnormal') return Colors.red;
  79. if (label == 'Ripe' || label == 'Overripe') return Colors.green;
  80. return Colors.orange;
  81. }
  82. @override
  83. Widget build(BuildContext context) {
  84. return Scaffold(
  85. appBar: AppBar(
  86. title: const Text('History Vault'),
  87. actions: [
  88. if (_records.isNotEmpty)
  89. IconButton(
  90. icon: const Icon(Icons.delete_sweep),
  91. tooltip: 'Reset History',
  92. onPressed: _resetHistory,
  93. ),
  94. ],
  95. ),
  96. body: _isLoading
  97. ? const Center(child: CircularProgressIndicator())
  98. : _records.isEmpty
  99. ? const Center(child: Text("No records found yet."))
  100. : ListView.builder(
  101. itemCount: _records.length,
  102. padding: const EdgeInsets.all(12),
  103. itemBuilder: (context, index) {
  104. final record = _records[index];
  105. final statusColor = _getStatusColor(record.ripenessClass);
  106. final fileName = p.basename(record.imagePath);
  107. return Card(
  108. margin: const EdgeInsets.only(bottom: 12),
  109. elevation: 3,
  110. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
  111. child: ListTile(
  112. leading: CircleAvatar(
  113. backgroundColor: statusColor.withOpacity(0.2),
  114. child: Icon(Icons.spa, color: statusColor),
  115. ),
  116. title: Text(
  117. record.ripenessClass,
  118. style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
  119. ),
  120. subtitle: Text(
  121. "File: $fileName\nConf: ${(record.confidence * 100).toStringAsFixed(1)}% | ${record.timestamp.toString().split('.')[0]}",
  122. style: const TextStyle(fontSize: 12),
  123. ),
  124. trailing: const Icon(Icons.chevron_right),
  125. isThreeLine: true,
  126. onTap: () => _showDetails(record),
  127. ),
  128. );
  129. },
  130. ),
  131. );
  132. }
  133. void _showDetails(PalmRecord record) {
  134. final fileName = p.basename(record.imagePath);
  135. final List<_DetectionData> detections = record.detections
  136. .map((d) => _DetectionData.fromMap(d))
  137. .toList();
  138. showModalBottomSheet(
  139. context: context,
  140. isScrollControlled: true,
  141. shape: const RoundedRectangleBorder(
  142. borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
  143. ),
  144. builder: (context) {
  145. return DraggableScrollableSheet(
  146. initialChildSize: 0.7,
  147. minChildSize: 0.5,
  148. maxChildSize: 0.95,
  149. expand: false,
  150. builder: (context, scrollController) {
  151. return SingleChildScrollView(
  152. controller: scrollController,
  153. padding: const EdgeInsets.all(24),
  154. child: Column(
  155. crossAxisAlignment: CrossAxisAlignment.start,
  156. children: [
  157. Row(
  158. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  159. children: [
  160. const Text("Record Details", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
  161. IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
  162. ],
  163. ),
  164. const Divider(),
  165. const SizedBox(height: 16),
  166. // Image with Bounding Boxes
  167. AspectRatio(
  168. aspectRatio: 1, // Assuming squared input 640x640
  169. child: Container(
  170. decoration: BoxDecoration(
  171. color: Colors.grey.shade200,
  172. borderRadius: BorderRadius.circular(12),
  173. ),
  174. child: record.imagePath.isNotEmpty && File(record.imagePath).existsSync()
  175. ? ClipRRect(
  176. borderRadius: BorderRadius.circular(12),
  177. child: Stack(
  178. children: [
  179. // The Image
  180. Positioned.fill(
  181. child: Image.file(File(record.imagePath), fit: BoxFit.contain),
  182. ),
  183. // The Overlays
  184. Positioned.fill(
  185. child: LayoutBuilder(
  186. builder: (context, constraints) {
  187. return Stack(
  188. children: detections.map<Widget>((d) {
  189. return PalmBoundingBox(
  190. normalizedRect: d.normalizedBox,
  191. label: d.className,
  192. confidence: d.confidence,
  193. constraints: constraints,
  194. );
  195. }).toList(),
  196. );
  197. },
  198. ),
  199. ),
  200. ],
  201. ),
  202. )
  203. : const Center(
  204. child: Icon(Icons.image, size: 64, color: Colors.grey),
  205. ),
  206. ),
  207. ),
  208. const SizedBox(height: 24),
  209. _buildDetailItem("File Name", fileName, Colors.blueGrey),
  210. _buildDetailItem("Primary Grade", record.ripenessClass, _getStatusColor(record.ripenessClass)),
  211. _buildDetailItem("Total Detections", detections.length.toString(), Colors.black),
  212. _buildDetailItem("Timestamp", record.timestamp.toString().split('.')[0], Colors.black87),
  213. const Divider(height: 32),
  214. const Text("Analytical Breakdown", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
  215. const SizedBox(height: 12),
  216. ...detections.map((d) => Padding(
  217. padding: const EdgeInsets.only(bottom: 8),
  218. child: Row(
  219. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  220. children: [
  221. Row(
  222. children: [
  223. Container(
  224. width: 12,
  225. height: 12,
  226. decoration: BoxDecoration(
  227. color: _getStatusColor(d.className),
  228. shape: BoxShape.circle,
  229. ),
  230. ),
  231. const SizedBox(width: 8),
  232. Text(d.className, style: const TextStyle(fontWeight: FontWeight.w500)),
  233. ],
  234. ),
  235. Text("${(d.confidence * 100).toStringAsFixed(1)}%", style: const TextStyle(color: Colors.grey)),
  236. ],
  237. ),
  238. )),
  239. const SizedBox(height: 24),
  240. const Text(
  241. "Industrial Summary: This image has been verified for harvest quality. All detected bunches are categorized according to ripeness standards.",
  242. style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey),
  243. ),
  244. const SizedBox(height: 24),
  245. ],
  246. ),
  247. );
  248. },
  249. );
  250. },
  251. );
  252. }
  253. Widget _buildDetailItem(String label, String value, Color valueColor) {
  254. return Padding(
  255. padding: const EdgeInsets.only(bottom: 12),
  256. child: Row(
  257. crossAxisAlignment: CrossAxisAlignment.start,
  258. children: [
  259. Text("$label: ", style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
  260. Expanded(
  261. child: Text(value, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: valueColor)),
  262. ),
  263. ],
  264. ),
  265. );
  266. }
  267. }