import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import '../models/palm_record.dart'; import '../models/palm_correction.dart'; import '../services/database_helper.dart'; import '../constants/mpob_colors.dart'; import '../theme/app_theme.dart'; import '../widgets/palm_bounding_box.dart'; import '../widgets/class_picker_sheet.dart'; import '../services/app_strings.dart'; import '../theme/locale_controller.dart'; import 'detection_editor_screen.dart'; /// Detection data parsed from the JSON-serialized `detections` list stored /// on a [PalmRecord] (or a [PalmCorrection]'s corrected detections). class DetectionData { final String className; final double confidence; final Rect normalizedBox; final bool isManual; const DetectionData({ required this.className, required this.confidence, required this.normalizedBox, this.isManual = false, }); factory DetectionData.fromMap(Map m) { return DetectionData( className: (m['className'] ?? m['class'] ?? 'Unknown').toString(), confidence: (m['confidence'] as num?)?.toDouble() ?? 0.0, normalizedBox: Rect.fromLTRB( (m['x1'] as num?)?.toDouble() ?? 0.0, (m['y1'] as num?)?.toDouble() ?? 0.0, (m['x2'] as num?)?.toDouble() ?? 1.0, (m['y2'] as num?)?.toDouble() ?? 1.0, ), isManual: m['isManual'] == true, ); } Map toMap() { return { 'className': className, 'classIndex': mpobClasses.indexOf(className), 'confidence': confidence, 'x1': normalizedBox.left, 'y1': normalizedBox.top, 'x2': normalizedBox.right, 'y2': normalizedBox.bottom, 'isManual': isManual, }; } DetectionData copyWith({String? className, bool? isManual}) { return DetectionData( className: className ?? this.className, confidence: confidence, normalizedBox: normalizedBox, isManual: isManual ?? this.isManual, ); } } /// Full-screen record detail view with prev/next navigation between the /// frames of the same batch (or a single-frame list for standalone records). class RecordDetailScreen extends StatefulWidget { final List frames; final int initialIndex; const RecordDetailScreen({super.key, required this.frames, required this.initialIndex}); @override State createState() => _RecordDetailScreenState(); } class _RecordDetailScreenState extends State { late final PageController _pageController; late int _index; @override void initState() { super.initState(); _index = widget.initialIndex; _pageController = PageController(initialPage: widget.initialIndex); } @override void dispose() { _pageController.dispose(); super.dispose(); } void _goTo(int index) { _pageController.animateToPage( index, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, ); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: LocaleController.localeCode, builder: (context, _, _) => _buildScaffold(context), ); } Widget _buildScaffold(BuildContext context) { final isFirst = _index == 0; final isLast = _index == widget.frames.length - 1; return Scaffold( appBar: AppBar( title: Text(widget.frames.length > 1 ? AppStrings.tp('frame_of_title', {'current': '${_index + 1}', 'total': '${widget.frames.length}'}) : AppStrings.t('record_details_title')), actions: widget.frames.length > 1 ? [ IconButton( icon: const Icon(Icons.chevron_left), onPressed: isFirst ? null : () => _goTo(_index - 1), ), IconButton( icon: const Icon(Icons.chevron_right), onPressed: isLast ? null : () => _goTo(_index + 1), ), ] : null, ), body: PageView.builder( controller: _pageController, itemCount: widget.frames.length, onPageChanged: (i) => setState(() => _index = i), itemBuilder: (context, i) => _RecordDetailBody(record: widget.frames[i]), ), ); } } enum _ViewMode { ai, corrected } class _RecordDetailBody extends StatefulWidget { final PalmRecord record; const _RecordDetailBody({required this.record}); @override State<_RecordDetailBody> createState() => _RecordDetailBodyState(); } class _RecordDetailBodyState extends State<_RecordDetailBody> { final DatabaseHelper _dbHelper = DatabaseHelper(); _ViewMode _viewMode = _ViewMode.ai; late List _aiDetections; late List _correctionDraft; DateTime? _lastCorrectedAt; bool _isLoading = true; bool _isSaving = false; bool get _hasCorrection => _lastCorrectedAt != null; @override void initState() { super.initState(); _aiDetections = widget.record.detections.map((d) => DetectionData.fromMap(d)).toList(); _loadExistingCorrection(); } Future _loadExistingCorrection() async { final recordId = widget.record.id; if (recordId == null) { setState(() { _correctionDraft = List.of(_aiDetections); _isLoading = false; }); return; } final existing = await _dbHelper.getLatestCorrection(recordId); if (!mounted) return; setState(() { if (existing != null) { _correctionDraft = existing.correctedDetections.map((d) => DetectionData.fromMap(d)).toList(); _lastCorrectedAt = existing.timestamp; _viewMode = _ViewMode.corrected; } else { _correctionDraft = List.of(_aiDetections); } _isLoading = false; }); } Future _editDetection(int index) async { final result = await showClassPickerSheet( context, currentClass: _correctionDraft[index].className, showRemoveOption: true, ); if (result == null) return; if (result.remove) { setState(() => _correctionDraft.removeAt(index)); await _persistCorrection(); } else if (result.className != null) { setState(() { _correctionDraft[index] = _correctionDraft[index].copyWith(className: result.className); }); await _persistCorrection(); } } Future _handleBoxTap(int index) async { if (_viewMode != _ViewMode.corrected) { setState(() => _viewMode = _ViewMode.corrected); } await _editDetection(index); } /// Opens the full-screen editor for placing boxes the AI missed entirely. /// Always available — even a photo with zero AI detections needs a way /// into correction mode. Future _openDetectionEditor() async { if (_viewMode != _ViewMode.corrected) { setState(() => _viewMode = _ViewMode.corrected); } final added = await Navigator.push>( context, MaterialPageRoute( builder: (context) => DetectionEditorScreen( imagePath: widget.record.imagePath, existingDetections: _correctionDraft, ), ), ); if (added != null && added.isNotEmpty) { setState(() => _correctionDraft.addAll(added)); await _persistCorrection(); } } /// Writes the current [_correctionDraft] as a new correction row. Called /// automatically after every box edit and after a reset — there is no /// separate manual "save" step to forget. Future _persistCorrection() async { final recordId = widget.record.id; if (recordId == null || _correctionDraft.isEmpty) return; setState(() => _isSaving = true); final correction = PalmCorrection( originalRecordId: recordId, correctedClass: _correctionDraft.first.className, correctedDetections: _correctionDraft.map((d) => d.toMap()).toList(), timestamp: DateTime.now(), ); await _dbHelper.insertCorrection(correction); if (!mounted) return; setState(() { _lastCorrectedAt = correction.timestamp; _isSaving = false; }); } /// Wipes any saved correction for this photo entirely — back to a clean /// "no correction yet" state, so the correction tab hides again. Future _resetCorrection() async { final recordId = widget.record.id; if (recordId != null) { await _dbHelper.deleteCorrectionsForRecord(recordId); } if (!mounted) return; setState(() { _correctionDraft = List.of(_aiDetections); _lastCorrectedAt = null; _viewMode = _ViewMode.ai; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppStrings.t('correction_reset_snackbar'))), ); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: LocaleController.localeCode, builder: (context, _, _) => _buildBody(context), ); } Widget _buildBody(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final accents = AppTheme.chartAccents; if (_isLoading) { return const Center(child: CircularProgressIndicator()); } final record = widget.record; final fileName = p.basename(record.imagePath); final activeDetections = _viewMode == _ViewMode.ai ? _aiDetections : _correctionDraft; final primaryGradeKey = activeDetections.isEmpty ? record.ripenessClass : activeDetections.first.className; final tally = {}; for (final d in activeDetections) { tally[d.className] = (tally[d.className] ?? 0) + 1; } return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_hasCorrection) ...[ SegmentedButton<_ViewMode>( segments: [ ButtonSegment(value: _ViewMode.ai, label: Text(AppStrings.t('ai_result_label')), icon: const Icon(Icons.smart_toy_outlined)), ButtonSegment(value: _ViewMode.corrected, label: Text(AppStrings.t('your_correction_label')), icon: const Icon(Icons.edit_outlined)), ], selected: {_viewMode}, showSelectedIcon: false, style: SegmentedButton.styleFrom( selectedBackgroundColor: colorScheme.primary, selectedForegroundColor: colorScheme.onPrimary, ), onSelectionChanged: (selection) => setState(() => _viewMode = selection.first), ), const SizedBox(height: 8), ], Row( children: [ Expanded( child: Text( _viewMode == _ViewMode.ai ? AppStrings.t('tap_to_correct_hint') : (_isSaving ? AppStrings.t('saving_label') : AppStrings.tp('last_corrected_label', {'timestamp': _lastCorrectedAt.toString().split('.')[0]})), style: TextStyle(fontSize: 12, color: colorScheme.onSurface.withValues(alpha: 0.6)), ), ), if (_viewMode == _ViewMode.corrected && _hasCorrection) TextButton.icon( onPressed: _isSaving ? null : _resetCorrection, icon: const Icon(Icons.restart_alt, size: 18), label: Text(AppStrings.t('reset_button')), ), ], ), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: OutlinedButton.icon( onPressed: _isSaving ? null : _openDetectionEditor, icon: const Icon(Icons.add_box_outlined, size: 18), label: Text(AppStrings.t('add_missed_bunch_button')), ), ), const SizedBox(height: 16), // Image with Bounding Boxes AspectRatio( aspectRatio: 1, // Assuming squared input 640x640 child: Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: record.imagePath.isNotEmpty && File(record.imagePath).existsSync() ? ClipRRect( borderRadius: BorderRadius.circular(12), child: Stack( children: [ Positioned.fill( child: Image.file(File(record.imagePath), fit: BoxFit.contain), ), Positioned.fill( child: LayoutBuilder( builder: (context, constraints) { final boxes = activeDetections.map((d) { return PalmBoundingBox( normalizedRect: d.normalizedBox, label: d.className, confidence: d.confidence, constraints: constraints, isManual: d.isManual, ); }).toList(); // Every box is tappable, in both views — tapping // one always jumps into (or stays in) correction mode. final tapTargets = activeDetections.asMap().entries.map((entry) { final i = entry.key; final rect = entry.value.normalizedBox; return Positioned( left: rect.left * constraints.maxWidth, top: rect.top * constraints.maxHeight, width: rect.width * constraints.maxWidth, height: rect.height * constraints.maxHeight, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _handleBoxTap(i), ), ); }).toList(); return Stack(children: [...boxes, ...tapTargets]); }, ), ), ], ), ) : Center( child: Icon(Icons.image, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.4)), ), ), ), const SizedBox(height: 24), _buildDetailItem(AppStrings.t('label_file_name'), fileName, accents[4], colorScheme), _buildDetailItem(AppStrings.t('label_primary_grade'), AppStrings.className(primaryGradeKey), getStatusColor(primaryGradeKey), colorScheme), _buildDetailItem(AppStrings.t('kpi_total_detections'), activeDetections.length.toString(), colorScheme.onSurface, colorScheme), _buildDetailItem(AppStrings.t('label_inference_time'), record.inferenceMs == null ? AppStrings.t('na_label') : "${record.inferenceMs} ms", accents[3], colorScheme), _buildDetailItem(AppStrings.t('label_timestamp'), record.timestamp.toString().split('.')[0], colorScheme.onSurface, colorScheme), if (tally.isNotEmpty) ...[ const Divider(height: 32), Text(AppStrings.t('frame_summary_title'), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface)), const SizedBox(height: 12), Wrap( spacing: 16, runSpacing: 8, children: tally.entries.map((e) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container(width: 12, height: 12, decoration: BoxDecoration(color: getMPOBColor(e.key), shape: BoxShape.circle)), const SizedBox(width: 6), Text("${AppStrings.className(e.key)}: ${e.value}", style: TextStyle(fontSize: 13, color: colorScheme.onSurface)), ], ); }).toList(), ), ], const Divider(height: 32), Text( _viewMode == _ViewMode.ai ? AppStrings.t('analytical_breakdown_title') : AppStrings.t('corrected_breakdown_title'), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: colorScheme.onSurface), ), const SizedBox(height: 12), ...activeDetections.map((d) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Container( width: 12, height: 12, decoration: BoxDecoration( color: getStatusColor(d.className), shape: BoxShape.circle, ), ), const SizedBox(width: 8), Text(AppStrings.className(d.className), style: TextStyle(fontWeight: FontWeight.w500, color: colorScheme.onSurface)), ], ), Text("${(d.confidence * 100).toStringAsFixed(1)}%", style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.6))), ], ), )), const SizedBox(height: 24), Text( AppStrings.t('industrial_summary_text'), style: TextStyle(fontStyle: FontStyle.italic, color: colorScheme.onSurface.withValues(alpha: 0.6)), ), const SizedBox(height: 24), ], ), ); } Widget _buildDetailItem(String label, String value, Color valueColor, ColorScheme colorScheme) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("$label: ", style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16, color: colorScheme.onSurface)), Expanded( child: Text(value, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: valueColor)), ), ], ), ); } }