| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486 |
- import 'dart:io';
- import 'dart:ui';
- 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 '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<String, dynamic> 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<String, dynamic> 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<PalmRecord> frames;
- final int initialIndex;
- const RecordDetailScreen({super.key, required this.frames, required this.initialIndex});
- @override
- State<RecordDetailScreen> createState() => _RecordDetailScreenState();
- }
- class _RecordDetailScreenState extends State<RecordDetailScreen> {
- 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) {
- final isFirst = _index == 0;
- final isLast = _index == widget.frames.length - 1;
- return Scaffold(
- appBar: AppBar(
- title: Text(widget.frames.length > 1 ? "Frame ${_index + 1} of ${widget.frames.length}" : "Record Details"),
- 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<DetectionData> _aiDetections;
- late List<DetectionData> _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<void> _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<void> _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<void> _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<void> _openDetectionEditor() async {
- if (_viewMode != _ViewMode.corrected) {
- setState(() => _viewMode = _ViewMode.corrected);
- }
- final added = await Navigator.push<List<DetectionData>>(
- 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<void> _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<void> _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(
- const SnackBar(content: Text("Correction reset.")),
- );
- }
- @override
- Widget build(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 primaryGrade = activeDetections.isEmpty ? record.ripenessClass : activeDetections.first.className;
- final tally = <String, int>{};
- 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: const [
- ButtonSegment(value: _ViewMode.ai, label: Text("AI Result"), icon: Icon(Icons.smart_toy_outlined)),
- ButtonSegment(value: _ViewMode.corrected, label: Text("Your Correction"), icon: 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
- ? "Tap a bunch on the photo to correct its grade."
- : (_isSaving ? "Saving..." : "Last corrected: ${_lastCorrectedAt.toString().split('.')[0]}"),
- style: TextStyle(fontSize: 12, color: colorScheme.onSurface.withOpacity(0.6)),
- ),
- ),
- if (_viewMode == _ViewMode.corrected && _hasCorrection)
- TextButton.icon(
- onPressed: _isSaving ? null : _resetCorrection,
- icon: const Icon(Icons.restart_alt, size: 18),
- label: const Text("Reset"),
- ),
- ],
- ),
- 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: const Text("Add Missed Bunch"),
- ),
- ),
- 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<Widget>((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<Widget>((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.withOpacity(0.4)),
- ),
- ),
- ),
- const SizedBox(height: 24),
- _buildDetailItem("File Name", fileName, accents[4], colorScheme),
- _buildDetailItem("Primary Grade", primaryGrade, getStatusColor(primaryGrade), colorScheme),
- _buildDetailItem("Total Detections", activeDetections.length.toString(), colorScheme.onSurface, colorScheme),
- _buildDetailItem("Inference Time", record.inferenceMs == null ? "N/A" : "${record.inferenceMs} ms", accents[3], colorScheme),
- _buildDetailItem("Timestamp", record.timestamp.toString().split('.')[0], colorScheme.onSurface, colorScheme),
- if (tally.isNotEmpty) ...[
- const Divider(height: 32),
- Text("Frame Summary", 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("${e.key}: ${e.value}", style: TextStyle(fontSize: 13, color: colorScheme.onSurface)),
- ],
- );
- }).toList(),
- ),
- ],
- const Divider(height: 32),
- Text(
- _viewMode == _ViewMode.ai ? "Analytical Breakdown" : "Corrected Breakdown (tap a bunch above to edit or remove)",
- 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(d.className, style: TextStyle(fontWeight: FontWeight.w500, color: colorScheme.onSurface)),
- ],
- ),
- Text("${(d.confidence * 100).toStringAsFixed(1)}%", style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6))),
- ],
- ),
- )),
- const SizedBox(height: 24),
- Text(
- "Industrial Summary: This image has been verified for harvest quality. All detected bunches are categorized according to ripeness standards.",
- style: TextStyle(fontStyle: FontStyle.italic, color: colorScheme.onSurface.withOpacity(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)),
- ),
- ],
- ),
- );
- }
- }
|