feat(app): implement Product Scan review flow, dynamic batch expiry picker, custom PDFs, PO relationships, and aligned card layouts

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-07-09 12:36:06 +07:00
1 parent 9ff4a4a922
commit 577be04308
69 files changed
+5645 -1657

No files matched your search

+374
View File
@@ -0,0 +1,374 @@
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';
class CameraDrawer extends ConsumerStatefulWidget {
const CameraDrawer({super.key});
@override
ConsumerState<CameraDrawer> createState() => _CameraDrawerState();
}
class _CameraDrawerState extends ConsumerState<CameraDrawer> {
String _storeName = 'Store Name';
@override
void initState() {
super.initState();
_loadStoreName();
}
Future<void> _loadStoreName() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_storeName = prefs.getString('nama_toko') ?? 'Store Name';
});
}
}
@override
Widget build(BuildContext context) {
final activeMode = ref.watch(scanModeProvider);
return Drawer(
backgroundColor: Colors.white,
child: SafeArea(
child: Column(
children: [
// Close Button & Profile Row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Column(
children: [
// Close Button
Align(
alignment: Alignment.topRight,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.grey),
onPressed: () => Navigator.pop(context),
),
),
const SizedBox(height: 8),
// Profile Row
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: Colors.grey.shade200, width: 1),
),
child: ClipOval(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Image.asset(
'assets/logo.png',
fit: BoxFit.contain,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Prima Fresh Mart',
style: AppConfig.h3.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
_storeName,
style: AppConfig.bodySmall.copyWith(
color: Colors.grey.shade600,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
],
),
),
const SizedBox(height: 16),
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(
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(
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,
),
),
],
),
),
),
),
],
),
),
),
],
),
),
const SizedBox(height: 20),
// Menu Items List with Grouping Sections
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: [
_buildSectionHeader('DASHBOARD'),
_buildDrawerItem(
icon: Icons.home_outlined,
title: 'Home',
onTap: () => Navigator.pop(context),
),
_buildDrawerItem(
icon: Icons.history_outlined,
title: 'History',
onTap: () {
Navigator.pop(context);
context.push('/documents');
},
),
const SizedBox(height: 16),
_buildSectionHeader('USER PROFILE'),
_buildDrawerItem(
icon: Icons.person_outline,
title: 'Profile',
onTap: () {
Navigator.pop(context);
_showNotImplemented('Profile');
},
),
_buildDrawerItem(
icon: Icons.notifications_none_outlined,
title: 'Notifications',
onTap: () {
Navigator.pop(context);
_showNotImplemented('Notifications');
},
),
const SizedBox(height: 16),
_buildSectionHeader('SUPPORT & SETTINGS'),
_buildDrawerItem(
icon: Icons.settings_outlined,
title: 'Settings',
onTap: () {
Navigator.pop(context);
_showNotImplemented('Settings');
},
),
_buildDrawerItem(
icon: Icons.help_outline,
title: 'Help',
onTap: () {
Navigator.pop(context);
_showHelpDialog();
},
),
],
),
),
// Logout at the bottom
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16.0),
child: _buildDrawerItem(
icon: Icons.logout_outlined,
title: 'Logout',
textColor: AppConfig.errorColor,
iconColor: AppConfig.errorColor,
onTap: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
),
),
],
),
),
);
}
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,
),
),
);
}
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.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
)
],
),
);
}
}
+87 -139
View File
@@ -7,8 +7,9 @@ import 'package:geolocator/geolocator.dart';
import '../../core/location/location_service.dart';
import '../../core/utils/exif_helper.dart';
import '../../config/app_config.dart';
import '../auth/auth_provider.dart';
import 'image_preview_screen.dart';
import 'scan_mode_provider.dart';
import 'camera_drawer.dart';
class CameraScreen extends ConsumerStatefulWidget {
const CameraScreen({super.key});
@@ -52,13 +53,8 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
});
}
},
onError: (error) {
debugPrint('Location stream error: $error');
},
);
} catch (e) {
debugPrint('Background location fetch failed: $e');
}
} catch (_) {}
}
Future<void> _takePicture() async {
@@ -78,6 +74,7 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
_isCapturing = true;
});
// Use pre-fetched location if available, otherwise fetch quick position
Position? position = _currentPosition;
if (position == null) {
try {
@@ -92,12 +89,7 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
}
} catch (e) {
debugPrint('Error picking image from camera: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal membuka kamera: $e')),
);
}
debugPrint('Error taking picture: $e');
}
}
@@ -166,23 +158,17 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
@override
Widget build(BuildContext context) {
final activeMode = ref.watch(scanModeProvider);
return Scaffold(
backgroundColor: AppConfig.backgroundColor,
appBar: AppBar(
title: const Text('Pemindai Dokumen OCR'),
actions: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
],
title: Text(activeMode == 'DO' ? 'Pemindai Dokumen OCR' : 'Pemindai Produk PFM'),
),
drawer: _buildDrawer(),
drawer: const CameraDrawer(),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: AppConfig.screenPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -191,7 +177,7 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(AppConfig.borderRadiusXL),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
@@ -201,50 +187,84 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
],
),
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: AppConfig.paddingXL,
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppConfig.primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
child: activeMode == 'DO'
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppConfig.primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.document_scanner,
size: 48,
color: AppConfig.primaryColor,
),
),
const SizedBox(height: AppConfig.spaceXL),
Text(
'Pemindai DO',
style: AppConfig.h3,
),
const SizedBox(height: AppConfig.spaceMedium),
Text(
'Ambil foto lembar Delivery Order (DO) Prima Fresh Mart untuk mengekstrak data PO, SO, DO, dan item barang secara otomatis.',
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: AppConfig.spaceXL),
_buildGuidelineRow(Icons.lightbulb_outline, 'Pastikan pencahayaan cukup dan terang.'),
const SizedBox(height: AppConfig.spaceSmall),
_buildGuidelineRow(Icons.crop_free, 'Posisikan seluruh halaman dokumen di dalam foto.'),
const SizedBox(height: AppConfig.spaceSmall),
_buildGuidelineRow(Icons.camera_enhance_outlined, 'Pegang HP dengan stabil agar tidak buram.'),
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppConfig.primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.inventory_2_outlined,
size: 48,
color: AppConfig.primaryColor,
),
),
const SizedBox(height: AppConfig.spaceXL),
Text(
'Pemindai Produk',
style: AppConfig.h3,
),
const SizedBox(height: AppConfig.spaceMedium),
Text(
'Ambil foto kemasan produk Prima Fresh Mart untuk mengidentifikasi SKU, nama item, dan mendeteksi tanggal kadaluwarsa secara otomatis.',
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: AppConfig.spaceXL),
_buildGuidelineRow(Icons.lightbulb_outline, 'Pastikan kode SKU atau teks kadaluwarsa terlihat jelas.'),
const SizedBox(height: AppConfig.spaceSmall),
_buildGuidelineRow(Icons.center_focus_strong, 'Posisikan produk di tengah kamera.'),
const SizedBox(height: AppConfig.spaceSmall),
_buildGuidelineRow(Icons.flash_on, 'Gunakan pencahayaan yang cukup agar tidak buram.'),
],
),
child: const Icon(
Icons.document_scanner,
size: 48,
color: AppConfig.primaryColor,
),
),
const SizedBox(height: 24),
Text(
'Pindai Dokumen DO',
style: AppConfig.h2,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Ambil foto lembar Delivery Order (DO) Prima Fresh Mart untuk mengekstrak data PO, SO, DO, dan item barang secara otomatis.',
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Quick Guidelines
_buildGuidelineRow(Icons.lightbulb_outline, 'Pastikan pencahayaan cukup dan terang.'),
const SizedBox(height: 8),
_buildGuidelineRow(Icons.crop_free, 'Posisikan seluruh halaman dokumen di dalam foto.'),
const SizedBox(height: 8),
_buildGuidelineRow(Icons.camera_enhance_outlined, 'Pegang HP dengan stabil agar tidak buram.'),
],
),
),
),
),
),
const SizedBox(height: 24),
const SizedBox(height: AppConfig.spaceXL),
// Action Buttons
ElevatedButton.icon(
@@ -256,7 +276,7 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
const SizedBox(height: AppConfig.spaceMedium),
OutlinedButton.icon(
onPressed: _isCapturing ? null : _pickFromGallery,
icon: const Icon(Icons.photo_library),
@@ -266,12 +286,12 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
side: const BorderSide(color: AppConfig.primaryColor),
foregroundColor: AppConfig.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
),
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
const SizedBox(height: AppConfig.spaceMedium),
TextButton.icon(
onPressed: _isCapturing ? null : () => context.push('/documents'),
icon: const Icon(Icons.history_edu),
@@ -291,7 +311,7 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
return Row(
children: [
Icon(icon, size: 18, color: AppConfig.secondaryColor),
const SizedBox(width: 8),
const SizedBox(width: AppConfig.spaceSmall),
Expanded(
child: Text(
text,
@@ -301,76 +321,4 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
],
);
}
Widget _buildDrawer() {
return Drawer(
backgroundColor: AppConfig.backgroundColor,
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: const BoxDecoration(
color: AppConfig.primaryColor,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Icon(
Icons.store,
size: 48,
color: Colors.white,
),
const SizedBox(height: 12),
Text(
'Aplikasi Pemindai OCR',
style: AppConfig.h2.copyWith(color: Colors.white),
),
],
),
),
ListTile(
leading: const Icon(Icons.info),
title: Text('Tentang Kami', style: AppConfig.bodyLarge),
onTap: () {
Navigator.pop(context); // Close drawer
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Tentang Prima Mart'),
content: 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.',
style: AppConfig.bodyMedium,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
)
],
),
);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text('Versi Aplikasi', style: AppConfig.bodyLarge),
subtitle: Text('v${AppConfig.appVersion}', style: AppConfig.bodySmall),
onTap: () {},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: AppConfig.errorColor),
title: Text('Keluar', style: AppConfig.bodyLarge.copyWith(color: AppConfig.errorColor)),
onTap: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
),
],
),
);
}
}
@@ -0,0 +1,3 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
final scanModeProvider = StateProvider<String>((ref) => 'DO');