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

+66 -209
View File
@@ -1,11 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../config/app_config.dart';
import '../auth/auth_provider.dart';
import 'scan_mode_provider.dart';
import '../documents/pending_documents_provider.dart';
import 'camera_drawer_helpers.dart';
import 'camera_drawer_mode_toggle.dart';
/// Main navigation drawer for the camera screen.
///
/// Split during task 8.1 (AGENTS.md §3 — 256-line threshold on touched files):
/// - [CameraDrawerModeToggle] owns the DO/Product mode toggle pill and
/// [scanModeProvider] read/write (including the new doModeColor cue).
/// - [camera_drawer_helpers.dart] contains stateless UI helpers
/// (section header, drawer item, dialogs).
class CameraDrawer extends ConsumerStatefulWidget {
const CameraDrawer({super.key});
@@ -33,19 +42,16 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
@override
Widget build(BuildContext context) {
final activeMode = ref.watch(scanModeProvider);
return Drawer(
backgroundColor: Colors.white,
child: SafeArea(
child: Column(
children: [
// Close Button & Profile Row
// ── Profile header ─────────────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Column(
children: [
// Close Button
Align(
alignment: Alignment.topRight,
child: IconButton(
@@ -54,7 +60,6 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
),
),
const SizedBox(height: 8),
// Profile Row
Row(
children: [
Container(
@@ -68,10 +73,7 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
child: ClipOval(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Image.asset(
'assets/logo.png',
fit: BoxFit.contain,
),
child: Image.asset('assets/logo.png', fit: BoxFit.contain),
),
),
),
@@ -109,136 +111,22 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
const Divider(height: 1, thickness: 1),
const SizedBox(height: 20),
// Mode Toggle Section (between DO and Product Scan)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mode',
style: AppConfig.bodySmall.copyWith(
color: Colors.grey.shade500,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Container(
height: 48,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
ref.read(scanModeProvider.notifier).state = 'DO';
},
child: Container(
decoration: BoxDecoration(
color: activeMode == 'DO' ? AppConfig.primaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: activeMode == 'DO'
? [
BoxShadow(
color: AppConfig.primaryColor.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: [],
),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
activeMode == 'DO' ? Icons.description : Icons.description_outlined,
size: 16,
color: activeMode == 'DO' ? Colors.white : Colors.grey.shade500,
),
const SizedBox(width: 6),
Text(
'DO Scan',
style: AppConfig.bodyMedium.copyWith(
fontWeight: activeMode == 'DO' ? FontWeight.bold : FontWeight.normal,
color: activeMode == 'DO' ? Colors.white : Colors.grey.shade600,
),
),
],
),
),
),
),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
ref.read(scanModeProvider.notifier).state = 'Product';
},
child: Container(
decoration: BoxDecoration(
color: activeMode == 'Product' ? AppConfig.primaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: activeMode == 'Product'
? [
BoxShadow(
color: AppConfig.primaryColor.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: [],
),
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
activeMode == 'Product' ? Icons.inventory_2 : Icons.inventory_2_outlined,
size: 16,
color: activeMode == 'Product' ? Colors.white : Colors.grey.shade500,
),
const SizedBox(width: 6),
Text(
'Product Scan',
style: AppConfig.bodyMedium.copyWith(
fontWeight: activeMode == 'Product' ? FontWeight.bold : FontWeight.normal,
color: activeMode == 'Product' ? Colors.white : Colors.grey.shade600,
),
),
],
),
),
),
),
],
),
),
),
],
),
),
// ── Mode toggle (DO/Product) ────────────────────────────────
const CameraDrawerModeToggle(),
const SizedBox(height: 20),
// Menu Items List with Grouping Sections
// ── Menu items ─────────────────────────────────────────────
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: [
_buildSectionHeader('DASHBOARD'),
_buildDrawerItem(
buildDrawerSectionHeader('DASHBOARD'),
buildDrawerItem(
icon: Icons.home_outlined,
title: 'Home',
onTap: () => Navigator.pop(context),
),
_buildDrawerItem(
buildDrawerItem(
icon: Icons.history_outlined,
title: 'History',
onTap: () {
@@ -247,58 +135,55 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
},
),
const SizedBox(height: 16),
_buildSectionHeader('USER PROFILE'),
_buildDrawerItem(
buildDrawerSectionHeader('USER PROFILE'),
buildDrawerItem(
icon: Icons.person_outline,
title: 'Profile',
onTap: () {
Navigator.pop(context);
_showNotImplemented('Profile');
showDrawerNotImplemented(context, 'Profile');
},
),
_buildDrawerItem(
buildDrawerItem(
icon: Icons.notifications_none_outlined,
title: 'Notifications',
onTap: () {
Navigator.pop(context);
_showNotImplemented('Notifications');
showDrawerNotImplemented(context, 'Notifications');
},
),
const SizedBox(height: 16),
_buildSectionHeader('SUPPORT & SETTINGS'),
_buildDrawerItem(
buildDrawerSectionHeader('SUPPORT & SETTINGS'),
buildDrawerItem(
icon: Icons.settings_outlined,
title: 'Settings',
onTap: () {
Navigator.pop(context);
_showNotImplemented('Settings');
showDrawerNotImplemented(context, 'Settings');
},
),
_buildDrawerItem(
buildDrawerItem(
icon: Icons.help_outline,
title: 'Help',
onTap: () {
Navigator.pop(context);
_showHelpDialog();
showDrawerHelpDialog(context);
},
),
],
),
),
// Logout at the bottom
// ── Logout ─────────────────────────────────────────────────
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16.0),
child: _buildDrawerItem(
child: buildDrawerItem(
icon: Icons.logout_outlined,
title: 'Logout',
textColor: AppConfig.errorColor,
iconColor: AppConfig.errorColor,
onTap: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
onTap: _handleLogout,
),
),
],
@@ -307,70 +192,42 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8.0, bottom: 8.0),
child: Text(
title,
style: AppConfig.bodySmall.copyWith(
color: Colors.grey.shade400,
fontWeight: FontWeight.bold,
fontSize: 11,
letterSpacing: 1.0,
),
),
);
}
/// Warns before logout if the pending-documents queue still has unsynced
/// items — a plain logout wipes the local Hive cache and clears the queue
/// (see AuthNotifier.logout()), silently discarding any in-flight
/// uploads, unreviewed scans, or failed syncs otherwise.
Future<void> _handleLogout() async {
final pendingCount = ref.read(pendingDocumentsProvider).length;
Widget _buildDrawerItem({
required IconData icon,
required String title,
required VoidCallback onTap,
Color? textColor,
Color? iconColor,
}) {
return ListTile(
leading: Icon(icon, color: iconColor ?? Colors.black87),
title: Text(
title,
style: AppConfig.bodyLarge.copyWith(
color: textColor ?? Colors.black87,
fontWeight: FontWeight.w500,
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
onTap: onTap,
);
}
void _showNotImplemented(String featureName) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$featureName feature coming soon!'),
behavior: SnackBarBehavior.floating,
),
);
}
void _showHelpDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Tentang Prima Mart'),
content: const SingleChildScrollView(
child: Text(
'www.primafreshmart.com merupakan sarana belanja online dari toko Prima Mart yang sudah melayani konsumen selama 10 tahun dan memiliki hampir 1200 gerai di Pulau Jawa, Bali dan Sumatera. Inovasi terbaru dari PT. Primafood International sebagai bagian dari PT. Charoen Pokphand Indonesia, Tbk.\n\nPT. Primafood International\nJl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.',
if (pendingCount > 0) {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Ada Dokumen Belum Tersinkron'),
content: Text(
'Ada $pendingCount dokumen yang belum selesai diunggah atau '
'disinkronkan ke server. Jika Anda logout sekarang, dokumen '
'tersebut akan hilang.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Batal'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text('Ya, Logout', style: TextStyle(color: AppConfig.errorColor)),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
)
],
),
);
);
if (confirmed != true) return;
}
if (!mounted) return;
await ref.read(authProvider.notifier).logout();
if (!mounted) return;
context.go('/login');
}
}