record_detail_screen.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import 'dart:io';
  2. import 'package:flutter/material.dart';
  3. import 'package:path/path.dart' as p;
  4. import '../models/palm_record.dart';
  5. import '../models/palm_correction.dart';
  6. import '../services/database_helper.dart';
  7. import '../constants/mpob_colors.dart';
  8. import '../theme/app_theme.dart';
  9. import '../widgets/palm_bounding_box.dart';
  10. import '../widgets/class_picker_sheet.dart';
  11. import '../services/app_strings.dart';
  12. import '../theme/locale_controller.dart';
  13. import 'detection_editor_screen.dart';
  14. /// Detection data parsed from the JSON-serialized `detections` list stored
  15. /// on a [PalmRecord] (or a [PalmCorrection]'s corrected detections).
  16. class DetectionData {
  17. final String className;
  18. final double confidence;
  19. final Rect normalizedBox;
  20. final bool isManual;
  21. const DetectionData({
  22. required this.className,
  23. required this.confidence,
  24. required this.normalizedBox,
  25. this.isManual = false,
  26. });
  27. factory DetectionData.fromMap(Map<String, dynamic> m) {
  28. return DetectionData(
  29. className: (m['className'] ?? m['class'] ?? 'Unknown').toString(),
  30. confidence: (m['confidence'] as num?)?.toDouble() ?? 0.0,
  31. normalizedBox: Rect.fromLTRB(
  32. (m['x1'] as num?)?.toDouble() ?? 0.0,
  33. (m['y1'] as num?)?.toDouble() ?? 0.0,
  34. (m['x2'] as num?)?.toDouble() ?? 1.0,
  35. (m['y2'] as num?)?.toDouble() ?? 1.0,
  36. ),
  37. isManual: m['isManual'] == true,
  38. );
  39. }
  40. Map<String, dynamic> toMap() {
  41. return {
  42. 'className': className,
  43. 'classIndex': mpobClasses.indexOf(className),
  44. 'confidence': confidence,
  45. 'x1': normalizedBox.left,
  46. 'y1': normalizedBox.top,
  47. 'x2': normalizedBox.right,
  48. 'y2': normalizedBox.bottom,
  49. 'isManual': isManual,
  50. };
  51. }
  52. DetectionData copyWith({String? className, bool? isManual}) {
  53. return DetectionData(
  54. className: className ?? this.className,
  55. confidence: confidence,
  56. normalizedBox: normalizedBox,
  57. isManual: isManual ?? this.isManual,
  58. );
  59. }
  60. }
  61. /// Full-screen record detail view with prev/next navigation between the
  62. /// frames of the same batch (or a single-frame list for standalone records).
  63. class RecordDetailScreen extends StatefulWidget {
  64. final List<PalmRecord> frames;
  65. final int initialIndex;
  66. const RecordDetailScreen({super.key, required this.frames, required this.initialIndex});
  67. @override
  68. State<RecordDetailScreen> createState() => _RecordDetailScreenState();
  69. }
  70. class _RecordDetailScreenState extends State<RecordDetailScreen> {
  71. late final PageController _pageController;
  72. late int _index;
  73. @override
  74. void initState() {
  75. super.initState();
  76. _index = widget.initialIndex;
  77. _pageController = PageController(initialPage: widget.initialIndex);
  78. }
  79. @override
  80. void dispose() {
  81. _pageController.dispose();
  82. super.dispose();
  83. }
  84. void _goTo(int index) {
  85. _pageController.animateToPage(
  86. index,
  87. duration: const Duration(milliseconds: 250),
  88. curve: Curves.easeOut,
  89. );
  90. }
  91. @override
  92. Widget build(BuildContext context) {
  93. return ValueListenableBuilder<String>(
  94. valueListenable: LocaleController.localeCode,
  95. builder: (context, _, _) => _buildScaffold(context),
  96. );
  97. }
  98. Widget _buildScaffold(BuildContext context) {
  99. final isFirst = _index == 0;
  100. final isLast = _index == widget.frames.length - 1;
  101. return Scaffold(
  102. appBar: AppBar(
  103. title: Text(widget.frames.length > 1
  104. ? AppStrings.tp('frame_of_title', {'current': '${_index + 1}', 'total': '${widget.frames.length}'})
  105. : AppStrings.t('record_details_title')),
  106. actions: widget.frames.length > 1
  107. ? [
  108. IconButton(
  109. icon: const Icon(Icons.chevron_left),
  110. onPressed: isFirst ? null : () => _goTo(_index - 1),
  111. ),
  112. IconButton(
  113. icon: const Icon(Icons.chevron_right),
  114. onPressed: isLast ? null : () => _goTo(_index + 1),
  115. ),
  116. ]
  117. : null,
  118. ),
  119. body: PageView.builder(
  120. controller: _pageController,
  121. itemCount: widget.frames.length,
  122. onPageChanged: (i) => setState(() => _index = i),
  123. itemBuilder: (context, i) => _RecordDetailBody(record: widget.frames[i]),
  124. ),
  125. );
  126. }
  127. }
  128. enum _ViewMode { ai, corrected }
  129. class _RecordDetailBody extends StatefulWidget {
  130. final PalmRecord record;
  131. const _RecordDetailBody({required this.record});
  132. @override
  133. State<_RecordDetailBody> createState() => _RecordDetailBodyState();
  134. }
  135. class _RecordDetailBodyState extends State<_RecordDetailBody> {
  136. final DatabaseHelper _dbHelper = DatabaseHelper();
  137. _ViewMode _viewMode = _ViewMode.ai;
  138. late List<DetectionData> _aiDetections;
  139. late List<DetectionData> _correctionDraft;
  140. DateTime? _lastCorrectedAt;
  141. bool _isLoading = true;
  142. bool _isSaving = false;
  143. bool get _hasCorrection => _lastCorrectedAt != null;
  144. @override
  145. void initState() {
  146. super.initState();
  147. _aiDetections = widget.record.detections.map((d) => DetectionData.fromMap(d)).toList();
  148. _loadExistingCorrection();
  149. }
  150. Future<void> _loadExistingCorrection() async {
  151. final recordId = widget.record.id;
  152. if (recordId == null) {
  153. setState(() {
  154. _correctionDraft = List.of(_aiDetections);
  155. _isLoading = false;
  156. });
  157. return;
  158. }
  159. final existing = await _dbHelper.getLatestCorrection(recordId);
  160. if (!mounted) return;
  161. setState(() {
  162. if (existing != null) {
  163. _correctionDraft = existing.correctedDetections.map((d) => DetectionData.fromMap(d)).toList();
  164. _lastCorrectedAt = existing.timestamp;
  165. _viewMode = _ViewMode.corrected;
  166. } else {
  167. _correctionDraft = List.of(_aiDetections);
  168. }
  169. _isLoading = false;
  170. });
  171. }
  172. Future<void> _editDetection(int index) async {
  173. final result = await showClassPickerSheet(
  174. context,
  175. currentClass: _correctionDraft[index].className,
  176. showRemoveOption: true,
  177. );
  178. if (result == null) return;
  179. if (result.remove) {
  180. setState(() => _correctionDraft.removeAt(index));
  181. await _persistCorrection();
  182. } else if (result.className != null) {
  183. setState(() {
  184. _correctionDraft[index] = _correctionDraft[index].copyWith(className: result.className);
  185. });
  186. await _persistCorrection();
  187. }
  188. }
  189. Future<void> _handleBoxTap(int index) async {
  190. if (_viewMode != _ViewMode.corrected) {
  191. setState(() => _viewMode = _ViewMode.corrected);
  192. }
  193. await _editDetection(index);
  194. }
  195. /// Opens the full-screen editor for placing boxes the AI missed entirely.
  196. /// Always available — even a photo with zero AI detections needs a way
  197. /// into correction mode.
  198. Future<void> _openDetectionEditor() async {
  199. if (_viewMode != _ViewMode.corrected) {
  200. setState(() => _viewMode = _ViewMode.corrected);
  201. }
  202. final added = await Navigator.push<List<DetectionData>>(
  203. context,
  204. MaterialPageRoute(
  205. builder: (context) => DetectionEditorScreen(
  206. imagePath: widget.record.imagePath,
  207. existingDetections: _correctionDraft,
  208. ),
  209. ),
  210. );
  211. if (added != null && added.isNotEmpty) {
  212. setState(() => _correctionDraft.addAll(added));
  213. await _persistCorrection();
  214. }
  215. }
  216. /// Writes the current [_correctionDraft] as a new correction row. Called
  217. /// automatically after every box edit and after a reset — there is no
  218. /// separate manual "save" step to forget.
  219. Future<void> _persistCorrection() async {
  220. final recordId = widget.record.id;
  221. if (recordId == null || _correctionDraft.isEmpty) return;
  222. setState(() => _isSaving = true);
  223. final correction = PalmCorrection(
  224. originalRecordId: recordId,
  225. correctedClass: _correctionDraft.first.className,
  226. correctedDetections: _correctionDraft.map((d) => d.toMap()).toList(),
  227. timestamp: DateTime.now(),
  228. );
  229. await _dbHelper.insertCorrection(correction);
  230. if (!mounted) return;
  231. setState(() {
  232. _lastCorrectedAt = correction.timestamp;
  233. _isSaving = false;
  234. });
  235. }
  236. /// Wipes any saved correction for this photo entirely — back to a clean
  237. /// "no correction yet" state, so the correction tab hides again.
  238. Future<void> _resetCorrection() async {
  239. final recordId = widget.record.id;
  240. if (recordId != null) {
  241. await _dbHelper.deleteCorrectionsForRecord(recordId);
  242. }
  243. if (!mounted) return;
  244. setState(() {
  245. _correctionDraft = List.of(_aiDetections);
  246. _lastCorrectedAt = null;
  247. _viewMode = _ViewMode.ai;
  248. });
  249. ScaffoldMessenger.of(context).showSnackBar(
  250. SnackBar(content: Text(AppStrings.t('correction_reset_snackbar'))),
  251. );
  252. }
  253. @override
  254. Widget build(BuildContext context) {
  255. return ValueListenableBuilder<String>(
  256. valueListenable: LocaleController.localeCode,
  257. builder: (context, _, _) => _buildBody(context),
  258. );
  259. }
  260. Widget _buildBody(BuildContext context) {
  261. final colorScheme = Theme.of(context).colorScheme;
  262. final accents = AppTheme.chartAccents;
  263. if (_isLoading) {
  264. return const Center(child: CircularProgressIndicator());
  265. }
  266. final record = widget.record;
  267. final fileName = p.basename(record.imagePath);
  268. final activeDetections = _viewMode == _ViewMode.ai ? _aiDetections : _correctionDraft;
  269. final primaryGradeKey = activeDetections.isEmpty ? record.ripenessClass : activeDetections.first.className;
  270. final tally = <String, int>{};
  271. for (final d in activeDetections) {
  272. tally[d.className] = (tally[d.className] ?? 0) + 1;
  273. }
  274. return SingleChildScrollView(
  275. padding: const EdgeInsets.all(24),
  276. child: Column(
  277. crossAxisAlignment: CrossAxisAlignment.start,
  278. children: [
  279. if (_hasCorrection) ...[
  280. SegmentedButton<_ViewMode>(
  281. segments: [
  282. ButtonSegment(value: _ViewMode.ai, label: Text(AppStrings.t('ai_result_label')), icon: const Icon(Icons.smart_toy_outlined)),
  283. ButtonSegment(value: _ViewMode.corrected, label: Text(AppStrings.t('your_correction_label')), icon: const Icon(Icons.edit_outlined)),
  284. ],
  285. selected: {_viewMode},
  286. showSelectedIcon: false,
  287. style: SegmentedButton.styleFrom(
  288. selectedBackgroundColor: colorScheme.primary,
  289. selectedForegroundColor: colorScheme.onPrimary,
  290. ),
  291. onSelectionChanged: (selection) => setState(() => _viewMode = selection.first),
  292. ),
  293. const SizedBox(height: 8),
  294. ],
  295. Row(
  296. children: [
  297. Expanded(
  298. child: Text(
  299. _viewMode == _ViewMode.ai
  300. ? AppStrings.t('tap_to_correct_hint')
  301. : (_isSaving
  302. ? AppStrings.t('saving_label')
  303. : AppStrings.tp('last_corrected_label', {'timestamp': _lastCorrectedAt.toString().split('.')[0]})),
  304. style: TextStyle(fontSize: 12, color: colorScheme.onSurface.withValues(alpha: 0.6)),
  305. ),
  306. ),
  307. if (_viewMode == _ViewMode.corrected && _hasCorrection)
  308. TextButton.icon(
  309. onPressed: _isSaving ? null : _resetCorrection,
  310. icon: const Icon(Icons.restart_alt, size: 18),
  311. label: Text(AppStrings.t('reset_button')),
  312. ),
  313. ],
  314. ),
  315. const SizedBox(height: 8),
  316. Align(
  317. alignment: Alignment.centerLeft,
  318. child: OutlinedButton.icon(
  319. onPressed: _isSaving ? null : _openDetectionEditor,
  320. icon: const Icon(Icons.add_box_outlined, size: 18),
  321. label: Text(AppStrings.t('add_missed_bunch_button')),
  322. ),
  323. ),
  324. const SizedBox(height: 16),
  325. // Image with Bounding Boxes
  326. AspectRatio(
  327. aspectRatio: 1, // Assuming squared input 640x640
  328. child: Container(
  329. decoration: BoxDecoration(
  330. color: colorScheme.surfaceContainerHighest,
  331. borderRadius: BorderRadius.circular(12),
  332. ),
  333. child: record.imagePath.isNotEmpty && File(record.imagePath).existsSync()
  334. ? ClipRRect(
  335. borderRadius: BorderRadius.circular(12),
  336. child: Stack(
  337. children: [
  338. Positioned.fill(
  339. child: Image.file(File(record.imagePath), fit: BoxFit.contain),
  340. ),
  341. Positioned.fill(
  342. child: LayoutBuilder(
  343. builder: (context, constraints) {
  344. final boxes = activeDetections.map<Widget>((d) {
  345. return PalmBoundingBox(
  346. normalizedRect: d.normalizedBox,
  347. label: d.className,
  348. confidence: d.confidence,
  349. constraints: constraints,
  350. isManual: d.isManual,
  351. );
  352. }).toList();
  353. // Every box is tappable, in both views — tapping
  354. // one always jumps into (or stays in) correction mode.
  355. final tapTargets = activeDetections.asMap().entries.map<Widget>((entry) {
  356. final i = entry.key;
  357. final rect = entry.value.normalizedBox;
  358. return Positioned(
  359. left: rect.left * constraints.maxWidth,
  360. top: rect.top * constraints.maxHeight,
  361. width: rect.width * constraints.maxWidth,
  362. height: rect.height * constraints.maxHeight,
  363. child: GestureDetector(
  364. behavior: HitTestBehavior.opaque,
  365. onTap: () => _handleBoxTap(i),
  366. ),
  367. );
  368. }).toList();
  369. return Stack(children: [...boxes, ...tapTargets]);
  370. },
  371. ),
  372. ),
  373. ],
  374. ),
  375. )
  376. : Center(
  377. child: Icon(Icons.image, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.4)),
  378. ),
  379. ),
  380. ),
  381. const SizedBox(height: 24),
  382. _buildDetailItem(AppStrings.t('label_file_name'), fileName, accents[4], colorScheme),
  383. _buildDetailItem(AppStrings.t('label_primary_grade'), AppStrings.className(primaryGradeKey), getStatusColor(primaryGradeKey), colorScheme),
  384. _buildDetailItem(AppStrings.t('kpi_total_detections'), activeDetections.length.toString(), colorScheme.onSurface, colorScheme),
  385. _buildDetailItem(AppStrings.t('label_inference_time'), record.inferenceMs == null ? AppStrings.t('na_label') : "${record.inferenceMs} ms", accents[3], colorScheme),
  386. _buildDetailItem(AppStrings.t('label_timestamp'), record.timestamp.toString().split('.')[0], colorScheme.onSurface, colorScheme),
  387. if (tally.isNotEmpty) ...[
  388. const Divider(height: 32),
  389. Text(AppStrings.t('frame_summary_title'), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
  390. const SizedBox(height: 12),
  391. Wrap(
  392. spacing: 16,
  393. runSpacing: 8,
  394. children: tally.entries.map((e) {
  395. return Row(
  396. mainAxisSize: MainAxisSize.min,
  397. children: [
  398. Container(width: 12, height: 12, decoration: BoxDecoration(color: getMPOBColor(e.key), shape: BoxShape.circle)),
  399. const SizedBox(width: 6),
  400. Text("${AppStrings.className(e.key)}: ${e.value}", style: TextStyle(fontSize: 13, color: colorScheme.onSurface)),
  401. ],
  402. );
  403. }).toList(),
  404. ),
  405. ],
  406. const Divider(height: 32),
  407. Text(
  408. _viewMode == _ViewMode.ai ? AppStrings.t('analytical_breakdown_title') : AppStrings.t('corrected_breakdown_title'),
  409. style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface),
  410. ),
  411. const SizedBox(height: 12),
  412. ...activeDetections.map((d) => Padding(
  413. padding: const EdgeInsets.only(bottom: 8),
  414. child: Row(
  415. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  416. children: [
  417. Row(
  418. children: [
  419. Container(
  420. width: 12,
  421. height: 12,
  422. decoration: BoxDecoration(
  423. color: getStatusColor(d.className),
  424. shape: BoxShape.circle,
  425. ),
  426. ),
  427. const SizedBox(width: 8),
  428. Text(AppStrings.className(d.className), style: TextStyle(fontWeight: FontWeight.w500, color: colorScheme.onSurface)),
  429. ],
  430. ),
  431. Text("${(d.confidence * 100).toStringAsFixed(1)}%", style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.6))),
  432. ],
  433. ),
  434. )),
  435. const SizedBox(height: 24),
  436. Text(
  437. AppStrings.t('industrial_summary_text'),
  438. style: TextStyle(fontStyle: FontStyle.italic, color: colorScheme.onSurface.withValues(alpha: 0.6)),
  439. ),
  440. const SizedBox(height: 24),
  441. ],
  442. ),
  443. );
  444. }
  445. Widget _buildDetailItem(String label, String value, Color valueColor, ColorScheme colorScheme) {
  446. return Padding(
  447. padding: const EdgeInsets.only(bottom: 12),
  448. child: Row(
  449. crossAxisAlignment: CrossAxisAlignment.start,
  450. children: [
  451. Text("$label: ", style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16, color: colorScheme.onSurface)),
  452. Expanded(
  453. child: Text(value, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: valueColor)),
  454. ),
  455. ],
  456. ),
  457. );
  458. }
  459. }