feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification

Fixes reported from APK field testing: DO/Product scan mode was inconsistent
between the camera drawer and documents screen (now one shared provider,
with an orange/green color cue); unconfirmed scans leaked into history with
placeholder data before the user tapped confirm (backend now gates
GET /documents on a new `confirmed` column, flipped only by PUT); and
Product Scan ran the GPU classifier twice, once at upload and again on
review (now a single pass at upload, persisted and read directly by the
editor). Also removes the unused "Hubungkan ke PO" field and fabricated
PO/SO/DO placeholder values from the Product Scan flow, closes out the
per-document-polling and save-recovery tasks (6.1/6.3), and splits several
touched files to stay under the repo's 256-line guideline.

Full detail in docs/iteration-log.md and backend/docs/iteration-log.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-10 15:19:32 +07:00
1 parent 2febe0c886
commit ada6488592
67 files changed
+5705 -1142

No files matched your search

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import '../../../config/app_config.dart';
class ProductDropdownCard extends StatelessWidget {
final String selectedSku;
final List<Map<String, dynamic>> matches;
final bool hasAutoMatch;
final ValueChanged<String?> onSkuChanged;
const ProductDropdownCard({
super.key,
required this.selectedSku,
required this.matches,
required this.hasAutoMatch,
required this.onSkuChanged,
});
@override
Widget build(BuildContext context) {
final typedMatches = matches.cast<Map<String, dynamic>>().toList();
final activeMatch = typedMatches.firstWhere(
(m) => m['no_sku'] == selectedSku,
orElse: () => typedMatches.isNotEmpty ? typedMatches.first : <String, dynamic>{'no_sku': '', 'nama_item': 'Unknown', 'confidence': 0.0},
);
final rawConfidence = activeMatch['confidence'] ?? activeMatch['score'] ?? activeMatch['yoloSimilarity'];
final double confidence = rawConfidence is num
? rawConfidence.toDouble()
: (rawConfidence != null
? double.tryParse(rawConfidence.toString()) ?? 0.0
: 0.0);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI Product Classification', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: selectedSku.isNotEmpty && typedMatches.any((m) => m['no_sku'] == selectedSku) ? selectedSku : null,
decoration: const InputDecoration(
labelText: 'Pilih Produk / SKU',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
isExpanded: true,
style: AppConfig.bodyMedium,
items: typedMatches.map((match) {
return DropdownMenuItem<String>(
value: match['no_sku'],
child: Text(
'${match['no_sku']} - ${match['nama_item']}',
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
);
}).toList(),
onChanged: onSkuChanged,
),
const SizedBox(height: AppConfig.spaceSmall),
// Product name for the selected SKU - informational only, not a
// form field (mirrors ProductExpiryCard's "Tanggal terdeteksi
// otomatis dari OCR" info row below the batch dropdown).
Row(
children: [
const Icon(Icons.inventory_2_outlined, size: 16, color: AppConfig.textSecondary),
const SizedBox(width: AppConfig.spaceXS),
Expanded(
child: Text(
(activeMatch['nama_item'] as String?)?.isNotEmpty == true
? activeMatch['nama_item'] as String
: '-',
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary, fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: AppConfig.spaceLarge),
if (hasAutoMatch) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Confidence Score', style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary)),
Text(
'${(confidence * 100).toStringAsFixed(1)}%',
style: const TextStyle(fontWeight: FontWeight.bold, color: AppConfig.successColor),
),
],
),
const SizedBox(height: AppConfig.spaceSmall),
LinearProgressIndicator(
value: confidence,
color: AppConfig.successColor,
backgroundColor: Colors.grey.shade200,
),
] else
Row(
children: [
const Icon(Icons.info_outline, size: 16, color: AppConfig.textSecondary),
const SizedBox(width: AppConfig.spaceXS),
Expanded(
child: Text(
'Tidak ada rekomendasi otomatis — pilih SKU secara manual.',
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../../../config/app_config.dart';
class ProductExpiryCard extends StatelessWidget {
final String selectedSku;
final String? selectedBatchOption;
final bool isManualDate;
final Map<String, List<String>> skuBatches;
final TextEditingController expDateCtrl;
final TextEditingController notesCtrl;
final ValueChanged<String?> onBatchChanged;
final VoidCallback onSelectDate;
const ProductExpiryCard({
super.key,
required this.selectedSku,
required this.selectedBatchOption,
required this.isManualDate,
required this.skuBatches,
required this.expDateCtrl,
required this.notesCtrl,
required this.onBatchChanged,
required this.onSelectDate,
});
@override
Widget build(BuildContext context) {
final batches = skuBatches[selectedSku] ?? [];
final dropdownItems = <DropdownMenuItem<String>>[];
for (int i = 0; i < batches.length; i++) {
dropdownItems.add(DropdownMenuItem(
value: 'Batch ${i + 1}: ${batches[i]}',
child: Text('Batch ${i + 1}: ${batches[i]}'),
));
}
dropdownItems.add(const DropdownMenuItem(value: 'manual', child: Text('Set Tanggal Manual...')));
// Ensure value matches an item or falls back
String? dropdownValue = isManualDate ? 'manual' : selectedBatchOption;
if (dropdownValue != null && dropdownValue != 'manual' && !dropdownItems.any((item) => item.value == dropdownValue)) {
dropdownValue = dropdownItems.isNotEmpty ? dropdownItems.first.value : null;
}
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI OCR Expiry Extraction', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceLarge),
DropdownButtonFormField<String>(
value: dropdownValue,
decoration: const InputDecoration(
labelText: 'Pilih Batch Expiry',
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
isExpanded: true,
style: AppConfig.bodyMedium,
items: dropdownItems,
onChanged: onBatchChanged,
),
if (isManualDate) ...[
const SizedBox(height: AppConfig.spaceLarge),
TextFormField(
controller: expDateCtrl,
decoration: InputDecoration(
labelText: 'Expired Date Manual (DD/MM/YYYY)',
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today, color: AppConfig.primaryColor),
onPressed: onSelectDate,
),
),
validator: (val) => val == null || val.isEmpty ? 'Tanggal kadaluwarsa harus diisi' : null,
),
],
const SizedBox(height: AppConfig.spaceLarge),
Row(
children: [
Icon(
isManualDate ? Icons.edit_calendar : Icons.auto_awesome,
size: 16,
color: isManualDate ? Colors.deepOrange : AppConfig.successColor,
),
const SizedBox(width: AppConfig.spaceXS),
Text(
isManualDate ? 'Tanggal diinput manual' : 'Tanggal terdeteksi otomatis dari OCR',
style: AppConfig.bodySmall.copyWith(
color: isManualDate ? Colors.deepOrange : AppConfig.successColor,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: AppConfig.spaceLarge),
TextFormField(
controller: notesCtrl,
decoration: const InputDecoration(labelText: 'Catatan Tambahan'),
maxLines: 2,
),
],
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../../config/app_config.dart';
class ProductPreviewCard extends StatelessWidget {
final String imagePath;
const ProductPreviewCard({super.key, required this.imagePath});
@override
Widget build(BuildContext context) {
if (imagePath.isEmpty) return const SizedBox.shrink();
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: AppConfig.paddingLarge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI Crop Region / Preview', style: AppConfig.h2),
const SizedBox(height: AppConfig.spaceMedium),
Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
child: Image.file(
File(imagePath),
fit: BoxFit.cover,
alignment: const Alignment(0, 0.4),
),
),
),
],
),
),
);
}
}