chore: normalize line endings (CRLF -> LF)
No content changes: git diff --ignore-all-space over these files is empty. The churn came from editing on Windows against a repo checked out with LF.
This commit is contained in:
1 parent
15566a6951
commit
caf8e98378
315 files changed
+86950
-86950
No files matched your search
+231
-231
@@ -1,231 +1,231 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class AppConfig {
|
||||
// ─── API Configuration ────────────────────────────────────────────────────
|
||||
//
|
||||
// Dual-Mode Configuration, tried in this order at every app startup:
|
||||
// 1. Local/LAN backend (fast, low-latency - the common case when the phone
|
||||
// and backend are on the same Wi-Fi).
|
||||
// 2. Public Ngrok tunnel (fallback for when the phone is off that network,
|
||||
// or the LAN URL below is stale).
|
||||
// Whichever responds first with a real backend (not a dead route/tunnel
|
||||
// error page) wins; if neither does, LAN is used as the default so error
|
||||
// messages point at the expected local backend rather than a dead tunnel.
|
||||
//
|
||||
|
||||
// LAN / WiFi / Emulator URL (port 8000 = nginx gateway)
|
||||
// - Physical Device on same Wi-Fi: use the backend machine's current LAN IP
|
||||
// - Android Emulator: use 'http://10.0.2.2:8000/api/v1'
|
||||
// - iOS Simulator: use 'http://localhost:8000/api/v1'
|
||||
static const String _lanBaseUrl = 'http://192.168.80.84:8000/api/v1';
|
||||
|
||||
// Ngrok public tunnel URL (update ini setiap ngrok di-restart)
|
||||
static const String _ngrokBaseUrl = 'https://unlocated-waylon-potently.ngrok-free.dev/api/v1';
|
||||
|
||||
// Dynamic Base URL determined at startup
|
||||
static String apiBaseUrl = _lanBaseUrl;
|
||||
|
||||
/// Dynamic URL resolver: tries the LAN backend first, then the public
|
||||
/// Ngrok tunnel, and picks whichever responds with a real backend.
|
||||
static Future<void> initializeApiBaseUrl() async {
|
||||
debugPrint('AppConfig: Checking local/LAN backend at $_lanBaseUrl...');
|
||||
if (await _isBackendReachable(_lanBaseUrl)) {
|
||||
apiBaseUrl = _lanBaseUrl;
|
||||
debugPrint('AppConfig: LAN backend is ACTIVE. API Base URL: $apiBaseUrl');
|
||||
return;
|
||||
}
|
||||
debugPrint('AppConfig: LAN backend not reachable, trying public Ngrok tunnel at $_ngrokBaseUrl...');
|
||||
|
||||
if (await _isBackendReachable(_ngrokBaseUrl)) {
|
||||
apiBaseUrl = _ngrokBaseUrl;
|
||||
debugPrint('AppConfig: Ngrok tunnel is ACTIVE. API Base URL: $apiBaseUrl');
|
||||
return;
|
||||
}
|
||||
|
||||
apiBaseUrl = _lanBaseUrl;
|
||||
debugPrint('AppConfig: Neither LAN nor Ngrok reachable. Defaulting to LAN API Base URL: $apiBaseUrl');
|
||||
}
|
||||
|
||||
/// Probes [baseUrl]/auth/login with a throwaway request to check whether a
|
||||
/// real backend answers behind it - not a timed-out connection, a dead
|
||||
/// tunnel, or a router/error page pretending to be a response.
|
||||
static Future<bool> _isBackendReachable(String baseUrl) async {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 2);
|
||||
try {
|
||||
final uri = Uri.parse('$baseUrl/auth/login');
|
||||
final request = await client.postUrl(uri);
|
||||
|
||||
// Bypasses the Ngrok browser-warning interstitial; harmless for LAN.
|
||||
request.headers.add('ngrok-skip-browser-warning', 'true');
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.write('{}');
|
||||
|
||||
final response = await request.close();
|
||||
|
||||
final contentType = response.headers.contentType?.mimeType ?? '';
|
||||
final ngrokErrorCode = response.headers.value('ngrok-error-code');
|
||||
|
||||
debugPrint('AppConfig: Probe $baseUrl -> status ${response.statusCode}, '
|
||||
'Content-Type: $contentType, Ngrok-Error-Code: $ngrokErrorCode');
|
||||
|
||||
// A real backend behind this URL:
|
||||
// 1. Should not return an ngrok-error-code header (dead/offline tunnel).
|
||||
// 2. The response content-type should be JSON (our login API returns JSON).
|
||||
// 3. Status code should not be 502, 503, or 504 (gateway/proxy errors).
|
||||
return ngrokErrorCode == null &&
|
||||
contentType.contains('json') &&
|
||||
response.statusCode != 502 &&
|
||||
response.statusCode != 503 &&
|
||||
response.statusCode != 504;
|
||||
} catch (e) {
|
||||
debugPrint('AppConfig: Probe $baseUrl failed: $e');
|
||||
return false;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static const String loginEndpoint = '/auth/login';
|
||||
static const String uploadDocumentEndpoint = '/documents/upload';
|
||||
static const String fetchDocumentsEndpoint = '/documents';
|
||||
static const String masterSkusEndpoint = '/master/skus';
|
||||
static const String scanProductEndpoint = '/scan-product';
|
||||
|
||||
|
||||
// App Constants
|
||||
static const String appName = 'Prima Mart Scanner';
|
||||
static const String appVersion = '1.0.0';
|
||||
|
||||
// Colors - Prima Fresh Mart Theme
|
||||
static const Color primaryColor = Color(0xFF6EBE4A); // Prima Green
|
||||
static const Color secondaryColor = Color(0xFF005DAA); // Prima Blue
|
||||
static const Color accentColor = Color(0xFFFFCC00); // Prima Yellow
|
||||
static const Color backgroundColor = Color(0xFFF8F9FA); // Off-white
|
||||
static const Color surfaceColor = Colors.white;
|
||||
static const Color errorColor = Color(0xFFD32F2F);
|
||||
static const Color successColor = Color(0xFF388E3C);
|
||||
// DO mode color — orange cue used throughout for the DO Scan mode:
|
||||
// document_card.dart top-label, documents_tab_switcher.dart active DO tab,
|
||||
// camera_drawer_mode_toggle.dart DO segment decoration. Formalized as a
|
||||
// shared constant so all three call sites pull from one place (was an
|
||||
// ad-hoc Colors.orange.shade700 inline in document_card.dart).
|
||||
static const Color doModeColor = Color(0xFFF57C00); // == Colors.orange.shade700
|
||||
|
||||
// Text Colors
|
||||
static const Color textPrimary = Color(0xFF212121);
|
||||
static const Color textSecondary = Color(0xFF757575);
|
||||
static const Color textOnPrimary = Colors.white;
|
||||
|
||||
// Spacing System (Margin & Padding)
|
||||
static const double spaceXS = 4.0;
|
||||
static const double spaceSmall = 8.0;
|
||||
static const double spaceMedium = 12.0;
|
||||
static const double spaceLarge = 16.0;
|
||||
static const double spaceXL = 24.0;
|
||||
static const double spaceXXL = 32.0;
|
||||
|
||||
static const EdgeInsets paddingXS = EdgeInsets.all(spaceXS);
|
||||
static const EdgeInsets paddingSmall = EdgeInsets.all(spaceSmall);
|
||||
static const EdgeInsets paddingMedium = EdgeInsets.all(spaceMedium);
|
||||
static const EdgeInsets paddingLarge = EdgeInsets.all(spaceLarge);
|
||||
static const EdgeInsets paddingXL = EdgeInsets.all(spaceXL);
|
||||
static const EdgeInsets screenPadding = EdgeInsets.symmetric(horizontal: spaceXL, vertical: spaceLarge);
|
||||
|
||||
// Border Radii
|
||||
static const double borderRadiusSmall = 8.0;
|
||||
static const double borderRadiusMedium = 12.0;
|
||||
static const double borderRadiusLarge = 16.0;
|
||||
static const double borderRadiusXL = 24.0;
|
||||
|
||||
// Typography Config
|
||||
// Using Google Fonts - Inter for clean, modern look
|
||||
static TextStyle get h1 => GoogleFonts.inter(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get h2 => GoogleFonts.inter(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get h3 => GoogleFonts.inter(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get bodyLarge => GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get bodyMedium => GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get bodySmall => GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: textSecondary,
|
||||
);
|
||||
|
||||
// Theme Data Builder
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
primaryColor: primaryColor,
|
||||
scaffoldBackgroundColor: backgroundColor,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
surface: surfaceColor,
|
||||
error: errorColor,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: surfaceColor,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
iconTheme: const IconThemeData(color: textPrimary),
|
||||
titleTextStyle: h2.copyWith(color: textPrimary),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: textOnPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
textStyle: bodyLarge.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: surfaceColor,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: primaryColor, width: 2),
|
||||
),
|
||||
labelStyle: bodyMedium.copyWith(color: textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class AppConfig {
|
||||
// ─── API Configuration ────────────────────────────────────────────────────
|
||||
//
|
||||
// Dual-Mode Configuration, tried in this order at every app startup:
|
||||
// 1. Local/LAN backend (fast, low-latency - the common case when the phone
|
||||
// and backend are on the same Wi-Fi).
|
||||
// 2. Public Ngrok tunnel (fallback for when the phone is off that network,
|
||||
// or the LAN URL below is stale).
|
||||
// Whichever responds first with a real backend (not a dead route/tunnel
|
||||
// error page) wins; if neither does, LAN is used as the default so error
|
||||
// messages point at the expected local backend rather than a dead tunnel.
|
||||
//
|
||||
|
||||
// LAN / WiFi / Emulator URL (port 8000 = nginx gateway)
|
||||
// - Physical Device on same Wi-Fi: use the backend machine's current LAN IP
|
||||
// - Android Emulator: use 'http://10.0.2.2:8000/api/v1'
|
||||
// - iOS Simulator: use 'http://localhost:8000/api/v1'
|
||||
static const String _lanBaseUrl = 'http://192.168.80.84:8000/api/v1';
|
||||
|
||||
// Ngrok public tunnel URL (update ini setiap ngrok di-restart)
|
||||
static const String _ngrokBaseUrl = 'https://unlocated-waylon-potently.ngrok-free.dev/api/v1';
|
||||
|
||||
// Dynamic Base URL determined at startup
|
||||
static String apiBaseUrl = _lanBaseUrl;
|
||||
|
||||
/// Dynamic URL resolver: tries the LAN backend first, then the public
|
||||
/// Ngrok tunnel, and picks whichever responds with a real backend.
|
||||
static Future<void> initializeApiBaseUrl() async {
|
||||
debugPrint('AppConfig: Checking local/LAN backend at $_lanBaseUrl...');
|
||||
if (await _isBackendReachable(_lanBaseUrl)) {
|
||||
apiBaseUrl = _lanBaseUrl;
|
||||
debugPrint('AppConfig: LAN backend is ACTIVE. API Base URL: $apiBaseUrl');
|
||||
return;
|
||||
}
|
||||
debugPrint('AppConfig: LAN backend not reachable, trying public Ngrok tunnel at $_ngrokBaseUrl...');
|
||||
|
||||
if (await _isBackendReachable(_ngrokBaseUrl)) {
|
||||
apiBaseUrl = _ngrokBaseUrl;
|
||||
debugPrint('AppConfig: Ngrok tunnel is ACTIVE. API Base URL: $apiBaseUrl');
|
||||
return;
|
||||
}
|
||||
|
||||
apiBaseUrl = _lanBaseUrl;
|
||||
debugPrint('AppConfig: Neither LAN nor Ngrok reachable. Defaulting to LAN API Base URL: $apiBaseUrl');
|
||||
}
|
||||
|
||||
/// Probes [baseUrl]/auth/login with a throwaway request to check whether a
|
||||
/// real backend answers behind it - not a timed-out connection, a dead
|
||||
/// tunnel, or a router/error page pretending to be a response.
|
||||
static Future<bool> _isBackendReachable(String baseUrl) async {
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 2);
|
||||
try {
|
||||
final uri = Uri.parse('$baseUrl/auth/login');
|
||||
final request = await client.postUrl(uri);
|
||||
|
||||
// Bypasses the Ngrok browser-warning interstitial; harmless for LAN.
|
||||
request.headers.add('ngrok-skip-browser-warning', 'true');
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.write('{}');
|
||||
|
||||
final response = await request.close();
|
||||
|
||||
final contentType = response.headers.contentType?.mimeType ?? '';
|
||||
final ngrokErrorCode = response.headers.value('ngrok-error-code');
|
||||
|
||||
debugPrint('AppConfig: Probe $baseUrl -> status ${response.statusCode}, '
|
||||
'Content-Type: $contentType, Ngrok-Error-Code: $ngrokErrorCode');
|
||||
|
||||
// A real backend behind this URL:
|
||||
// 1. Should not return an ngrok-error-code header (dead/offline tunnel).
|
||||
// 2. The response content-type should be JSON (our login API returns JSON).
|
||||
// 3. Status code should not be 502, 503, or 504 (gateway/proxy errors).
|
||||
return ngrokErrorCode == null &&
|
||||
contentType.contains('json') &&
|
||||
response.statusCode != 502 &&
|
||||
response.statusCode != 503 &&
|
||||
response.statusCode != 504;
|
||||
} catch (e) {
|
||||
debugPrint('AppConfig: Probe $baseUrl failed: $e');
|
||||
return false;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static const String loginEndpoint = '/auth/login';
|
||||
static const String uploadDocumentEndpoint = '/documents/upload';
|
||||
static const String fetchDocumentsEndpoint = '/documents';
|
||||
static const String masterSkusEndpoint = '/master/skus';
|
||||
static const String scanProductEndpoint = '/scan-product';
|
||||
|
||||
|
||||
// App Constants
|
||||
static const String appName = 'Prima Mart Scanner';
|
||||
static const String appVersion = '1.0.0';
|
||||
|
||||
// Colors - Prima Fresh Mart Theme
|
||||
static const Color primaryColor = Color(0xFF6EBE4A); // Prima Green
|
||||
static const Color secondaryColor = Color(0xFF005DAA); // Prima Blue
|
||||
static const Color accentColor = Color(0xFFFFCC00); // Prima Yellow
|
||||
static const Color backgroundColor = Color(0xFFF8F9FA); // Off-white
|
||||
static const Color surfaceColor = Colors.white;
|
||||
static const Color errorColor = Color(0xFFD32F2F);
|
||||
static const Color successColor = Color(0xFF388E3C);
|
||||
// DO mode color — orange cue used throughout for the DO Scan mode:
|
||||
// document_card.dart top-label, documents_tab_switcher.dart active DO tab,
|
||||
// camera_drawer_mode_toggle.dart DO segment decoration. Formalized as a
|
||||
// shared constant so all three call sites pull from one place (was an
|
||||
// ad-hoc Colors.orange.shade700 inline in document_card.dart).
|
||||
static const Color doModeColor = Color(0xFFF57C00); // == Colors.orange.shade700
|
||||
|
||||
// Text Colors
|
||||
static const Color textPrimary = Color(0xFF212121);
|
||||
static const Color textSecondary = Color(0xFF757575);
|
||||
static const Color textOnPrimary = Colors.white;
|
||||
|
||||
// Spacing System (Margin & Padding)
|
||||
static const double spaceXS = 4.0;
|
||||
static const double spaceSmall = 8.0;
|
||||
static const double spaceMedium = 12.0;
|
||||
static const double spaceLarge = 16.0;
|
||||
static const double spaceXL = 24.0;
|
||||
static const double spaceXXL = 32.0;
|
||||
|
||||
static const EdgeInsets paddingXS = EdgeInsets.all(spaceXS);
|
||||
static const EdgeInsets paddingSmall = EdgeInsets.all(spaceSmall);
|
||||
static const EdgeInsets paddingMedium = EdgeInsets.all(spaceMedium);
|
||||
static const EdgeInsets paddingLarge = EdgeInsets.all(spaceLarge);
|
||||
static const EdgeInsets paddingXL = EdgeInsets.all(spaceXL);
|
||||
static const EdgeInsets screenPadding = EdgeInsets.symmetric(horizontal: spaceXL, vertical: spaceLarge);
|
||||
|
||||
// Border Radii
|
||||
static const double borderRadiusSmall = 8.0;
|
||||
static const double borderRadiusMedium = 12.0;
|
||||
static const double borderRadiusLarge = 16.0;
|
||||
static const double borderRadiusXL = 24.0;
|
||||
|
||||
// Typography Config
|
||||
// Using Google Fonts - Inter for clean, modern look
|
||||
static TextStyle get h1 => GoogleFonts.inter(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get h2 => GoogleFonts.inter(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get h3 => GoogleFonts.inter(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get bodyLarge => GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get bodyMedium => GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: textPrimary,
|
||||
);
|
||||
|
||||
static TextStyle get bodySmall => GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: textSecondary,
|
||||
);
|
||||
|
||||
// Theme Data Builder
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
primaryColor: primaryColor,
|
||||
scaffoldBackgroundColor: backgroundColor,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
surface: surfaceColor,
|
||||
error: errorColor,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: surfaceColor,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
iconTheme: const IconThemeData(color: textPrimary),
|
||||
titleTextStyle: h2.copyWith(color: textPrimary),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: textOnPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
textStyle: bodyLarge.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: surfaceColor,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: primaryColor, width: 2),
|
||||
),
|
||||
labelStyle: bodyMedium.copyWith(color: textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,116 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
class LocationService {
|
||||
static Future<Position?> Function()? mockDeterminator;
|
||||
static Stream<Position>? mockStream;
|
||||
|
||||
static Stream<Position> getPositionStream() {
|
||||
if (mockStream != null) {
|
||||
return mockStream!;
|
||||
}
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 1, // update only when moved 1 meter
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Position?> determinePosition() async {
|
||||
if (mockDeterminator != null) {
|
||||
return mockDeterminator!();
|
||||
}
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
debugPrint('Location services are disabled.');
|
||||
return null;
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
debugPrint('Location permission denied.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
debugPrint('Location permission denied forever.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strategy 1: Try balanced/network-based location (fast, works indoors)
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.medium,
|
||||
timeLimit: const Duration(seconds: 7),
|
||||
);
|
||||
debugPrint(
|
||||
'GPS balanced/indoor fix: ${position.latitude}, ${position.longitude} '
|
||||
'(accuracy: ${position.accuracy.toStringAsFixed(1)}m)',
|
||||
);
|
||||
return position;
|
||||
} catch (e) {
|
||||
debugPrint('getCurrentPosition balanced failed: $e. Trying high accuracy...');
|
||||
}
|
||||
|
||||
// Strategy 2: Try high accuracy (GPS satellites) with forceAndroidLocationManager to bypass cache
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
forceAndroidLocationManager: true,
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
);
|
||||
debugPrint(
|
||||
'GPS high fix: ${position.latitude}, ${position.longitude} '
|
||||
'(accuracy: ${position.accuracy.toStringAsFixed(1)}m)',
|
||||
);
|
||||
return position;
|
||||
} catch (e) {
|
||||
debugPrint('getCurrentPosition high failed: $e. Checking last known position...');
|
||||
}
|
||||
|
||||
// Strategy 3: Use last known position ONLY if it is super recent (within 2 minutes)
|
||||
final lastKnown = await Geolocator.getLastKnownPosition();
|
||||
if (lastKnown != null) {
|
||||
final age = DateTime.now().difference(lastKnown.timestamp);
|
||||
if (age.inMinutes <= 2) {
|
||||
debugPrint(
|
||||
'Using recent last-known position (${age.inSeconds}s old): '
|
||||
'${lastKnown.latitude}, ${lastKnown.longitude}',
|
||||
);
|
||||
return lastKnown;
|
||||
}
|
||||
debugPrint(
|
||||
'Last-known position is too stale (${age.inMinutes}m old) — '
|
||||
'retrying low accuracy GPS fix...',
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy 4: Last resort — bypass Google Fused Location and try low accuracy
|
||||
// forceAndroidLocationManager forces a real hardware GPS read if enabled
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.low,
|
||||
forceAndroidLocationManager: true,
|
||||
timeLimit: const Duration(seconds: 5),
|
||||
);
|
||||
debugPrint(
|
||||
'GPS low fix via LocationManager: ${position.latitude}, ${position.longitude} '
|
||||
'(accuracy: ${position.accuracy.toStringAsFixed(1)}m)',
|
||||
);
|
||||
return position;
|
||||
} catch (e) {
|
||||
debugPrint('All GPS strategies failed: $e');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error in LocationService: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
class LocationService {
|
||||
static Future<Position?> Function()? mockDeterminator;
|
||||
static Stream<Position>? mockStream;
|
||||
|
||||
static Stream<Position> getPositionStream() {
|
||||
if (mockStream != null) {
|
||||
return mockStream!;
|
||||
}
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 1, // update only when moved 1 meter
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Position?> determinePosition() async {
|
||||
if (mockDeterminator != null) {
|
||||
return mockDeterminator!();
|
||||
}
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
debugPrint('Location services are disabled.');
|
||||
return null;
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
debugPrint('Location permission denied.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
debugPrint('Location permission denied forever.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strategy 1: Try balanced/network-based location (fast, works indoors)
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.medium,
|
||||
timeLimit: const Duration(seconds: 7),
|
||||
);
|
||||
debugPrint(
|
||||
'GPS balanced/indoor fix: ${position.latitude}, ${position.longitude} '
|
||||
'(accuracy: ${position.accuracy.toStringAsFixed(1)}m)',
|
||||
);
|
||||
return position;
|
||||
} catch (e) {
|
||||
debugPrint('getCurrentPosition balanced failed: $e. Trying high accuracy...');
|
||||
}
|
||||
|
||||
// Strategy 2: Try high accuracy (GPS satellites) with forceAndroidLocationManager to bypass cache
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
forceAndroidLocationManager: true,
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
);
|
||||
debugPrint(
|
||||
'GPS high fix: ${position.latitude}, ${position.longitude} '
|
||||
'(accuracy: ${position.accuracy.toStringAsFixed(1)}m)',
|
||||
);
|
||||
return position;
|
||||
} catch (e) {
|
||||
debugPrint('getCurrentPosition high failed: $e. Checking last known position...');
|
||||
}
|
||||
|
||||
// Strategy 3: Use last known position ONLY if it is super recent (within 2 minutes)
|
||||
final lastKnown = await Geolocator.getLastKnownPosition();
|
||||
if (lastKnown != null) {
|
||||
final age = DateTime.now().difference(lastKnown.timestamp);
|
||||
if (age.inMinutes <= 2) {
|
||||
debugPrint(
|
||||
'Using recent last-known position (${age.inSeconds}s old): '
|
||||
'${lastKnown.latitude}, ${lastKnown.longitude}',
|
||||
);
|
||||
return lastKnown;
|
||||
}
|
||||
debugPrint(
|
||||
'Last-known position is too stale (${age.inMinutes}m old) — '
|
||||
'retrying low accuracy GPS fix...',
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy 4: Last resort — bypass Google Fused Location and try low accuracy
|
||||
// forceAndroidLocationManager forces a real hardware GPS read if enabled
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.low,
|
||||
forceAndroidLocationManager: true,
|
||||
timeLimit: const Duration(seconds: 5),
|
||||
);
|
||||
debugPrint(
|
||||
'GPS low fix via LocationManager: ${position.latitude}, ${position.longitude} '
|
||||
'(accuracy: ${position.accuracy.toStringAsFixed(1)}m)',
|
||||
);
|
||||
return position;
|
||||
} catch (e) {
|
||||
debugPrint('All GPS strategies failed: $e');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error in LocationService: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient() {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
// Upload triggers a synchronous OCR pass server-side that can take up to
|
||||
// 210s worst case (see v1/documents/upload/route.ts's AbortSignal.timeout).
|
||||
// Keep a safety margin above that so a legitimately slow-but-successful
|
||||
// parse isn't killed client-side as a false "connection timeout".
|
||||
receiveTimeout: const Duration(seconds: 240),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
_setupInterceptors();
|
||||
}
|
||||
|
||||
void _setupInterceptors() {
|
||||
// Request interceptor to append JWT token
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('auth_token');
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
));
|
||||
|
||||
// Logging interceptor for debugging
|
||||
_dio.interceptors.add(LogInterceptor(
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
));
|
||||
|
||||
// Standardized Error Interceptor: wraps every failure into an ApiException
|
||||
// (attached as DioException.error) so call sites read one consistent shape
|
||||
// instead of reverse-engineering DioException.toString().
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onError: (DioException e, handler) {
|
||||
final apiException = ApiException.fromDioException(e);
|
||||
final customError = DioException(
|
||||
requestOptions: e.requestOptions,
|
||||
response: e.response,
|
||||
type: e.type,
|
||||
error: apiException,
|
||||
);
|
||||
return handler.next(customError);
|
||||
},
|
||||
));
|
||||
|
||||
// Dummy Response Interceptor for Development
|
||||
// TODO: Remove this interceptor before production release
|
||||
// _dio.interceptors.add(DummyResponseInterceptor());
|
||||
}
|
||||
|
||||
Dio get client => _dio;
|
||||
}
|
||||
|
||||
// Single shared instance so every screen/notifier reuses the same Dio HTTP client
|
||||
// (connection keep-alive) instead of paying a fresh TCP/TLS handshake per call site.
|
||||
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient() {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
// Upload triggers a synchronous OCR pass server-side that can take up to
|
||||
// 210s worst case (see v1/documents/upload/route.ts's AbortSignal.timeout).
|
||||
// Keep a safety margin above that so a legitimately slow-but-successful
|
||||
// parse isn't killed client-side as a false "connection timeout".
|
||||
receiveTimeout: const Duration(seconds: 240),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
_setupInterceptors();
|
||||
}
|
||||
|
||||
void _setupInterceptors() {
|
||||
// Request interceptor to append JWT token
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('auth_token');
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
));
|
||||
|
||||
// Logging interceptor for debugging
|
||||
_dio.interceptors.add(LogInterceptor(
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
));
|
||||
|
||||
// Standardized Error Interceptor: wraps every failure into an ApiException
|
||||
// (attached as DioException.error) so call sites read one consistent shape
|
||||
// instead of reverse-engineering DioException.toString().
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onError: (DioException e, handler) {
|
||||
final apiException = ApiException.fromDioException(e);
|
||||
final customError = DioException(
|
||||
requestOptions: e.requestOptions,
|
||||
response: e.response,
|
||||
type: e.type,
|
||||
error: apiException,
|
||||
);
|
||||
return handler.next(customError);
|
||||
},
|
||||
));
|
||||
|
||||
// Dummy Response Interceptor for Development
|
||||
// TODO: Remove this interceptor before production release
|
||||
// _dio.interceptors.add(DummyResponseInterceptor());
|
||||
}
|
||||
|
||||
Dio get client => _dio;
|
||||
}
|
||||
|
||||
// Single shared instance so every screen/notifier reuses the same Dio HTTP client
|
||||
// (connection keep-alive) instead of paying a fresh TCP/TLS handshake per call site.
|
||||
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
|
||||
@@ -1,97 +1,97 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Mirrors backend/pfm-web-app/src/lib/http-status.ts - keep the two in sync.
|
||||
const Map<int, String> _httpReasonPhrases = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
405: 'Method Not Allowed',
|
||||
409: 'Conflict',
|
||||
413: 'Payload Too Large',
|
||||
422: 'Unprocessable Entity',
|
||||
429: 'Too Many Requests',
|
||||
500: 'Internal Server Error',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
504: 'Gateway Timeout',
|
||||
};
|
||||
|
||||
/// Structured representation of an API/network failure, built from the
|
||||
/// backend's `{ status: "error", error: { statusCode, code, message } }`
|
||||
/// envelope or synthesized for network-level failures (timeout, no
|
||||
/// connection). This is the single object every catch block should read
|
||||
/// instead of scraping `DioException.toString()`.
|
||||
class ApiException implements Exception {
|
||||
final int? statusCode;
|
||||
final String code;
|
||||
final String message;
|
||||
|
||||
const ApiException({this.statusCode, required this.code, required this.message});
|
||||
|
||||
String get reasonPhrase {
|
||||
if (statusCode == null) return code;
|
||||
return _httpReasonPhrases[statusCode] ?? 'Error';
|
||||
}
|
||||
|
||||
/// Formatted like an HTTP status line, e.g. "404 Not Found: Document not found".
|
||||
String get displayMessage {
|
||||
if (statusCode == null) return message;
|
||||
return '$statusCode $reasonPhrase: $message';
|
||||
}
|
||||
|
||||
/// Unwraps any thrown object into an [ApiException]. This is the one call
|
||||
/// every catch block should use to get a consistent, displayable error.
|
||||
static ApiException from(Object error) {
|
||||
if (error is ApiException) return error;
|
||||
if (error is DioException) {
|
||||
if (error.error is ApiException) return error.error as ApiException;
|
||||
return fromDioException(error);
|
||||
}
|
||||
// Strip Dart's default `Exception: `/`Error: ` toString() prefix so plain
|
||||
// `throw Exception("...")` call sites still display just the message.
|
||||
final raw = error.toString();
|
||||
final message = raw.replaceFirst(RegExp(r'^(Exception|Error): '), '');
|
||||
return ApiException(code: 'UNKNOWN', message: message);
|
||||
}
|
||||
|
||||
static ApiException fromDioException(DioException e) {
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout) {
|
||||
return const ApiException(code: 'TIMEOUT', message: 'Koneksi timeout. Silakan coba lagi.');
|
||||
}
|
||||
|
||||
if (e.type == DioExceptionType.connectionError) {
|
||||
return const ApiException(code: 'NETWORK_ERROR', message: 'Tidak ada koneksi internet.');
|
||||
}
|
||||
|
||||
final response = e.response;
|
||||
if (response != null) {
|
||||
final data = response.data;
|
||||
if (data is Map && data['error'] is Map) {
|
||||
final errorBody = data['error'] as Map;
|
||||
return ApiException(
|
||||
statusCode: (errorBody['statusCode'] as num?)?.toInt() ?? response.statusCode,
|
||||
code: errorBody['code']?.toString() ?? 'UNKNOWN',
|
||||
message: errorBody['message']?.toString() ?? 'Tidak diketahui',
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for responses that don't (yet) use the standardized envelope.
|
||||
String fallbackMessage = 'Tidak diketahui';
|
||||
if (data is Map && data['message'] != null) {
|
||||
fallbackMessage = data['message'].toString();
|
||||
} else if (data is String && data.isNotEmpty) {
|
||||
fallbackMessage = data.length > 100 ? '${data.substring(0, 100)}...' : data;
|
||||
}
|
||||
return ApiException(
|
||||
statusCode: response.statusCode,
|
||||
code: 'UNKNOWN',
|
||||
message: fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
return const ApiException(code: 'UNKNOWN', message: 'Terjadi kesalahan yang tidak terduga');
|
||||
}
|
||||
}
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Mirrors backend/pfm-web-app/src/lib/http-status.ts - keep the two in sync.
|
||||
const Map<int, String> _httpReasonPhrases = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
405: 'Method Not Allowed',
|
||||
409: 'Conflict',
|
||||
413: 'Payload Too Large',
|
||||
422: 'Unprocessable Entity',
|
||||
429: 'Too Many Requests',
|
||||
500: 'Internal Server Error',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
504: 'Gateway Timeout',
|
||||
};
|
||||
|
||||
/// Structured representation of an API/network failure, built from the
|
||||
/// backend's `{ status: "error", error: { statusCode, code, message } }`
|
||||
/// envelope or synthesized for network-level failures (timeout, no
|
||||
/// connection). This is the single object every catch block should read
|
||||
/// instead of scraping `DioException.toString()`.
|
||||
class ApiException implements Exception {
|
||||
final int? statusCode;
|
||||
final String code;
|
||||
final String message;
|
||||
|
||||
const ApiException({this.statusCode, required this.code, required this.message});
|
||||
|
||||
String get reasonPhrase {
|
||||
if (statusCode == null) return code;
|
||||
return _httpReasonPhrases[statusCode] ?? 'Error';
|
||||
}
|
||||
|
||||
/// Formatted like an HTTP status line, e.g. "404 Not Found: Document not found".
|
||||
String get displayMessage {
|
||||
if (statusCode == null) return message;
|
||||
return '$statusCode $reasonPhrase: $message';
|
||||
}
|
||||
|
||||
/// Unwraps any thrown object into an [ApiException]. This is the one call
|
||||
/// every catch block should use to get a consistent, displayable error.
|
||||
static ApiException from(Object error) {
|
||||
if (error is ApiException) return error;
|
||||
if (error is DioException) {
|
||||
if (error.error is ApiException) return error.error as ApiException;
|
||||
return fromDioException(error);
|
||||
}
|
||||
// Strip Dart's default `Exception: `/`Error: ` toString() prefix so plain
|
||||
// `throw Exception("...")` call sites still display just the message.
|
||||
final raw = error.toString();
|
||||
final message = raw.replaceFirst(RegExp(r'^(Exception|Error): '), '');
|
||||
return ApiException(code: 'UNKNOWN', message: message);
|
||||
}
|
||||
|
||||
static ApiException fromDioException(DioException e) {
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout) {
|
||||
return const ApiException(code: 'TIMEOUT', message: 'Koneksi timeout. Silakan coba lagi.');
|
||||
}
|
||||
|
||||
if (e.type == DioExceptionType.connectionError) {
|
||||
return const ApiException(code: 'NETWORK_ERROR', message: 'Tidak ada koneksi internet.');
|
||||
}
|
||||
|
||||
final response = e.response;
|
||||
if (response != null) {
|
||||
final data = response.data;
|
||||
if (data is Map && data['error'] is Map) {
|
||||
final errorBody = data['error'] as Map;
|
||||
return ApiException(
|
||||
statusCode: (errorBody['statusCode'] as num?)?.toInt() ?? response.statusCode,
|
||||
code: errorBody['code']?.toString() ?? 'UNKNOWN',
|
||||
message: errorBody['message']?.toString() ?? 'Tidak diketahui',
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for responses that don't (yet) use the standardized envelope.
|
||||
String fallbackMessage = 'Tidak diketahui';
|
||||
if (data is Map && data['message'] != null) {
|
||||
fallbackMessage = data['message'].toString();
|
||||
} else if (data is String && data.isNotEmpty) {
|
||||
fallbackMessage = data.length > 100 ? '${data.substring(0, 100)}...' : data;
|
||||
}
|
||||
return ApiException(
|
||||
statusCode: response.statusCode,
|
||||
code: 'UNKNOWN',
|
||||
message: fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
return const ApiException(code: 'UNKNOWN', message: 'Terjadi kesalahan yang tidak terduga');
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,70 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Interceptor to simulate a 5-second delayed OCR processing response.
|
||||
/// This will only intercept POST requests to the upload document endpoint.
|
||||
class DummyResponseInterceptor extends Interceptor {
|
||||
@override
|
||||
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
// Check if it's the upload document endpoint
|
||||
if (options.path.contains('/documents/upload')) {
|
||||
debugPrint('Simulating 5-second processing delay for OCR...');
|
||||
|
||||
// Simulate 5 seconds delay
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
|
||||
// Create a dummy successful response based on user schema requirements
|
||||
final dummyData = {
|
||||
'status': 'success',
|
||||
'message': 'Document processed successfully',
|
||||
'data': {
|
||||
'header': {
|
||||
'tanggal': '16 April 2026',
|
||||
'no_po': 'PO/26/0000140731',
|
||||
'no_so': '1691869400',
|
||||
'no_do': '1659896184'
|
||||
},
|
||||
'shipment': {
|
||||
'kepada_yth': 'PT.PRIMAFOOD INTERNATIONAL DKI AREA',
|
||||
'order_untuk': '',
|
||||
'alamat': 'JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430',
|
||||
'plat_truk': 'B 9310 BXT'
|
||||
},
|
||||
'items': [
|
||||
{
|
||||
'nomor_sku': '12080101',
|
||||
'nama_barang': '920 FIESTA SPICY CHICK 400 G',
|
||||
'banyak': '1 BOX',
|
||||
'jumlah': '12 PAC'
|
||||
},
|
||||
{
|
||||
'nomor_sku': '13010513',
|
||||
'nama_barang': '304 Champ Chicken Sausage 375 Gr 15 Pack',
|
||||
'banyak': '1 BOX',
|
||||
'jumlah': '15 PAC'
|
||||
},
|
||||
{
|
||||
'nomor_sku': '13030502',
|
||||
'nama_barang': '298 Champ Chicken Ball 500 GR Fr',
|
||||
'banyak': '1 BOX',
|
||||
'jumlah': '10 PAC'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
debugPrint('Dummy response generated');
|
||||
|
||||
return handler.resolve(
|
||||
Response(
|
||||
requestOptions: options,
|
||||
data: dummyData,
|
||||
statusCode: 200,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// For other requests, continue normally
|
||||
super.onRequest(options, handler);
|
||||
}
|
||||
}
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Interceptor to simulate a 5-second delayed OCR processing response.
|
||||
/// This will only intercept POST requests to the upload document endpoint.
|
||||
class DummyResponseInterceptor extends Interceptor {
|
||||
@override
|
||||
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
// Check if it's the upload document endpoint
|
||||
if (options.path.contains('/documents/upload')) {
|
||||
debugPrint('Simulating 5-second processing delay for OCR...');
|
||||
|
||||
// Simulate 5 seconds delay
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
|
||||
// Create a dummy successful response based on user schema requirements
|
||||
final dummyData = {
|
||||
'status': 'success',
|
||||
'message': 'Document processed successfully',
|
||||
'data': {
|
||||
'header': {
|
||||
'tanggal': '16 April 2026',
|
||||
'no_po': 'PO/26/0000140731',
|
||||
'no_so': '1691869400',
|
||||
'no_do': '1659896184'
|
||||
},
|
||||
'shipment': {
|
||||
'kepada_yth': 'PT.PRIMAFOOD INTERNATIONAL DKI AREA',
|
||||
'order_untuk': '',
|
||||
'alamat': 'JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430',
|
||||
'plat_truk': 'B 9310 BXT'
|
||||
},
|
||||
'items': [
|
||||
{
|
||||
'nomor_sku': '12080101',
|
||||
'nama_barang': '920 FIESTA SPICY CHICK 400 G',
|
||||
'banyak': '1 BOX',
|
||||
'jumlah': '12 PAC'
|
||||
},
|
||||
{
|
||||
'nomor_sku': '13010513',
|
||||
'nama_barang': '304 Champ Chicken Sausage 375 Gr 15 Pack',
|
||||
'banyak': '1 BOX',
|
||||
'jumlah': '15 PAC'
|
||||
},
|
||||
{
|
||||
'nomor_sku': '13030502',
|
||||
'nama_barang': '298 Champ Chicken Ball 500 GR Fr',
|
||||
'banyak': '1 BOX',
|
||||
'jumlah': '10 PAC'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
debugPrint('Dummy response generated');
|
||||
|
||||
return handler.resolve(
|
||||
Response(
|
||||
requestOptions: options,
|
||||
data: dummyData,
|
||||
statusCode: 200,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// For other requests, continue normally
|
||||
super.onRequest(options, handler);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,47 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../features/splash/splash_screen.dart';
|
||||
import '../../features/auth/login_screen.dart';
|
||||
import '../../features/camera/camera_screen.dart';
|
||||
import '../../features/documents/documents_screen.dart';
|
||||
import '../../features/editor/editor_screen.dart';
|
||||
import '../../features/editor/product_editor_screen.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
initialLocation: '/splash',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/camera',
|
||||
builder: (context, state) => const CameraScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/documents',
|
||||
builder: (context, state) => const DocumentsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/editor',
|
||||
builder: (context, state) {
|
||||
final pendingId = state.extra as String?;
|
||||
return EditorScreen(pendingId: pendingId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/product-editor',
|
||||
builder: (context, state) {
|
||||
final pendingId = state.extra as String?;
|
||||
return ProductEditorScreen(pendingId: pendingId);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../features/splash/splash_screen.dart';
|
||||
import '../../features/auth/login_screen.dart';
|
||||
import '../../features/camera/camera_screen.dart';
|
||||
import '../../features/documents/documents_screen.dart';
|
||||
import '../../features/editor/editor_screen.dart';
|
||||
import '../../features/editor/product_editor_screen.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
initialLocation: '/splash',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/camera',
|
||||
builder: (context, state) => const CameraScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/documents',
|
||||
builder: (context, state) => const DocumentsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/editor',
|
||||
builder: (context, state) {
|
||||
final pendingId = state.extra as String?;
|
||||
return EditorScreen(pendingId: pendingId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/product-editor',
|
||||
builder: (context, state) {
|
||||
final pendingId = state.extra as String?;
|
||||
return ProductEditorScreen(pendingId: pendingId);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -1,63 +1,63 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
class LocalStorage {
|
||||
static const String documentBoxName = 'documents_box';
|
||||
static const String pendingDocumentsBoxName = 'pending_documents_box';
|
||||
|
||||
Future<void> init() async {
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Note: In a real flutter run with build_runner, you would register adapters here
|
||||
// Hive.registerAdapter(DocumentModelAdapter());
|
||||
// Hive.registerAdapter(DocumentItemAdapter());
|
||||
|
||||
await Hive.openBox<Map>(documentBoxName); // Using Map directly for scaffold without build_runner generated files
|
||||
await Hive.openBox<Map>(pendingDocumentsBoxName);
|
||||
}
|
||||
|
||||
Box get documentBox => Hive.box<Map>(documentBoxName);
|
||||
Box get pendingDocumentsBox => Hive.box<Map>(pendingDocumentsBoxName);
|
||||
|
||||
Future<void> saveDocument(DocumentModel doc) async {
|
||||
await documentBox.put(doc.id, doc.toJson());
|
||||
}
|
||||
|
||||
List<DocumentModel> getAllDocuments() {
|
||||
final box = documentBox;
|
||||
final List<DocumentModel> docs = [];
|
||||
for (var key in box.keys) {
|
||||
final data = box.get(key);
|
||||
if (data != null) {
|
||||
docs.add(DocumentModel.fromJson(Map<String, dynamic>.from(data)));
|
||||
}
|
||||
}
|
||||
// Sort descending by creation date
|
||||
docs.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return docs;
|
||||
}
|
||||
|
||||
/// Persists one entry of the in-flight pending-documents queue, keyed by its
|
||||
/// local id, so an OS-level app kill doesn't lose it (see `PendingDocumentsNotifier`).
|
||||
Future<void> savePendingDocument(Map<String, dynamic> data) async {
|
||||
await pendingDocumentsBox.put(data['id'], data);
|
||||
}
|
||||
|
||||
Future<void> removePendingDocument(String id) async {
|
||||
await pendingDocumentsBox.delete(id);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> getAllPendingDocuments() {
|
||||
final box = pendingDocumentsBox;
|
||||
return box.keys
|
||||
.map((key) => box.get(key))
|
||||
.whereType<Map>()
|
||||
.map((data) => Map<String, dynamic>.from(data))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
await documentBox.clear();
|
||||
await pendingDocumentsBox.clear();
|
||||
}
|
||||
}
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
class LocalStorage {
|
||||
static const String documentBoxName = 'documents_box';
|
||||
static const String pendingDocumentsBoxName = 'pending_documents_box';
|
||||
|
||||
Future<void> init() async {
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Note: In a real flutter run with build_runner, you would register adapters here
|
||||
// Hive.registerAdapter(DocumentModelAdapter());
|
||||
// Hive.registerAdapter(DocumentItemAdapter());
|
||||
|
||||
await Hive.openBox<Map>(documentBoxName); // Using Map directly for scaffold without build_runner generated files
|
||||
await Hive.openBox<Map>(pendingDocumentsBoxName);
|
||||
}
|
||||
|
||||
Box get documentBox => Hive.box<Map>(documentBoxName);
|
||||
Box get pendingDocumentsBox => Hive.box<Map>(pendingDocumentsBoxName);
|
||||
|
||||
Future<void> saveDocument(DocumentModel doc) async {
|
||||
await documentBox.put(doc.id, doc.toJson());
|
||||
}
|
||||
|
||||
List<DocumentModel> getAllDocuments() {
|
||||
final box = documentBox;
|
||||
final List<DocumentModel> docs = [];
|
||||
for (var key in box.keys) {
|
||||
final data = box.get(key);
|
||||
if (data != null) {
|
||||
docs.add(DocumentModel.fromJson(Map<String, dynamic>.from(data)));
|
||||
}
|
||||
}
|
||||
// Sort descending by creation date
|
||||
docs.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return docs;
|
||||
}
|
||||
|
||||
/// Persists one entry of the in-flight pending-documents queue, keyed by its
|
||||
/// local id, so an OS-level app kill doesn't lose it (see `PendingDocumentsNotifier`).
|
||||
Future<void> savePendingDocument(Map<String, dynamic> data) async {
|
||||
await pendingDocumentsBox.put(data['id'], data);
|
||||
}
|
||||
|
||||
Future<void> removePendingDocument(String id) async {
|
||||
await pendingDocumentsBox.delete(id);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> getAllPendingDocuments() {
|
||||
final box = pendingDocumentsBox;
|
||||
return box.keys
|
||||
.map((key) => box.get(key))
|
||||
.whereType<Map>()
|
||||
.map((data) => Map<String, dynamic>.from(data))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
await documentBox.clear();
|
||||
await pendingDocumentsBox.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,85 +1,85 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:exif/exif.dart';
|
||||
|
||||
class ExifHelper {
|
||||
/// Extracts GPS coordinates (latitude and longitude) from an image file's EXIF metadata.
|
||||
/// Returns a map with 'latitude' and 'longitude', or null if not found or invalid.
|
||||
static Future<Map<String, double>?> extractGps(String imagePath) async {
|
||||
try {
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) {
|
||||
debugPrint('EXIF extract: File does not exist at $imagePath');
|
||||
return null;
|
||||
}
|
||||
|
||||
final bytes = await file.readAsBytes();
|
||||
final tags = await readExifFromBytes(bytes);
|
||||
|
||||
if (tags.isEmpty) {
|
||||
debugPrint('EXIF extract: No EXIF tags found in image');
|
||||
return null;
|
||||
}
|
||||
|
||||
final latTag = tags['GPS GPSLatitude'];
|
||||
final latRefTag = tags['GPS GPSLatitudeRef'];
|
||||
final lngTag = tags['GPS GPSLongitude'];
|
||||
final lngRefTag = tags['GPS GPSLongitudeRef'];
|
||||
|
||||
if (latTag == null || latRefTag == null || lngTag == null || lngRefTag == null) {
|
||||
debugPrint('EXIF extract: GPS tags missing in EXIF metadata');
|
||||
return null;
|
||||
}
|
||||
|
||||
final double lat = _convertRatioListToDouble(latTag.values.toList());
|
||||
final double lng = _convertRatioListToDouble(lngTag.values.toList());
|
||||
|
||||
final latRef = latRefTag.toString().trim().toUpperCase();
|
||||
final lngRef = lngRefTag.toString().trim().toUpperCase();
|
||||
|
||||
double finalLat = lat;
|
||||
if (latRef == 'S') finalLat = -finalLat;
|
||||
|
||||
double finalLng = lng;
|
||||
if (lngRef == 'W') finalLng = -finalLng;
|
||||
|
||||
debugPrint('EXIF extract success: parsed coordinates Lat: $finalLat, Lng: $finalLng');
|
||||
return {
|
||||
'latitude': finalLat,
|
||||
'longitude': finalLng,
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing EXIF metadata: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static double _convertRatioListToDouble(dynamic values) {
|
||||
if (values == null || values.length < 3) return 0.0;
|
||||
|
||||
double degrees = _ratioToDouble(values[0]);
|
||||
double minutes = _ratioToDouble(values[1]);
|
||||
double seconds = _ratioToDouble(values[2]);
|
||||
|
||||
return degrees + (minutes / 60.0) + (seconds / 3600.0);
|
||||
}
|
||||
|
||||
static double _ratioToDouble(dynamic val) {
|
||||
if (val is Ratio) {
|
||||
return val.numerator / val.denominator;
|
||||
}
|
||||
if (val is num) {
|
||||
return val.toDouble();
|
||||
}
|
||||
final str = val.toString().trim();
|
||||
if (str.contains('/')) {
|
||||
final parts = str.split('/');
|
||||
if (parts.length == 2) {
|
||||
final numVal = double.tryParse(parts[0]) ?? 0.0;
|
||||
final denVal = double.tryParse(parts[1]) ?? 1.0;
|
||||
return numVal / denVal;
|
||||
}
|
||||
}
|
||||
return double.tryParse(str) ?? 0.0;
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:exif/exif.dart';
|
||||
|
||||
class ExifHelper {
|
||||
/// Extracts GPS coordinates (latitude and longitude) from an image file's EXIF metadata.
|
||||
/// Returns a map with 'latitude' and 'longitude', or null if not found or invalid.
|
||||
static Future<Map<String, double>?> extractGps(String imagePath) async {
|
||||
try {
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) {
|
||||
debugPrint('EXIF extract: File does not exist at $imagePath');
|
||||
return null;
|
||||
}
|
||||
|
||||
final bytes = await file.readAsBytes();
|
||||
final tags = await readExifFromBytes(bytes);
|
||||
|
||||
if (tags.isEmpty) {
|
||||
debugPrint('EXIF extract: No EXIF tags found in image');
|
||||
return null;
|
||||
}
|
||||
|
||||
final latTag = tags['GPS GPSLatitude'];
|
||||
final latRefTag = tags['GPS GPSLatitudeRef'];
|
||||
final lngTag = tags['GPS GPSLongitude'];
|
||||
final lngRefTag = tags['GPS GPSLongitudeRef'];
|
||||
|
||||
if (latTag == null || latRefTag == null || lngTag == null || lngRefTag == null) {
|
||||
debugPrint('EXIF extract: GPS tags missing in EXIF metadata');
|
||||
return null;
|
||||
}
|
||||
|
||||
final double lat = _convertRatioListToDouble(latTag.values.toList());
|
||||
final double lng = _convertRatioListToDouble(lngTag.values.toList());
|
||||
|
||||
final latRef = latRefTag.toString().trim().toUpperCase();
|
||||
final lngRef = lngRefTag.toString().trim().toUpperCase();
|
||||
|
||||
double finalLat = lat;
|
||||
if (latRef == 'S') finalLat = -finalLat;
|
||||
|
||||
double finalLng = lng;
|
||||
if (lngRef == 'W') finalLng = -finalLng;
|
||||
|
||||
debugPrint('EXIF extract success: parsed coordinates Lat: $finalLat, Lng: $finalLng');
|
||||
return {
|
||||
'latitude': finalLat,
|
||||
'longitude': finalLng,
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing EXIF metadata: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static double _convertRatioListToDouble(dynamic values) {
|
||||
if (values == null || values.length < 3) return 0.0;
|
||||
|
||||
double degrees = _ratioToDouble(values[0]);
|
||||
double minutes = _ratioToDouble(values[1]);
|
||||
double seconds = _ratioToDouble(values[2]);
|
||||
|
||||
return degrees + (minutes / 60.0) + (seconds / 3600.0);
|
||||
}
|
||||
|
||||
static double _ratioToDouble(dynamic val) {
|
||||
if (val is Ratio) {
|
||||
return val.numerator / val.denominator;
|
||||
}
|
||||
if (val is num) {
|
||||
return val.toDouble();
|
||||
}
|
||||
final str = val.toString().trim();
|
||||
if (str.contains('/')) {
|
||||
final parts = str.split('/');
|
||||
if (parts.length == 2) {
|
||||
final numVal = double.tryParse(parts[0]) ?? 0.0;
|
||||
final denVal = double.tryParse(parts[1]) ?? 1.0;
|
||||
return numVal / denVal;
|
||||
}
|
||||
}
|
||||
return double.tryParse(str) ?? 0.0;
|
||||
}
|
||||
}
|
||||
+236
-236
@@ -1,236 +1,236 @@
|
||||
class MasterSku {
|
||||
static const Map<String, String> data = {
|
||||
'11048006': 'BEBEK PARTING-NEW(*)',
|
||||
'11110059': 'CEKER BERKUKU FROZEN PACK 1 KG(*)',
|
||||
'11110074': 'CEKER 1 KG FROZEN (20 PAC/KARUNG)(*)',
|
||||
'11140051': 'AMPELA FROZEN PACK 1 KG(*)',
|
||||
'11140062': 'AMPELA 1 KG FROZEN (20 PAC/KARUNG)(*)',
|
||||
'11148002': 'AMPELA BEBEK FROZEN 1 KG/PACK (NEW)(*)',
|
||||
'11150052': 'HATI FROZEN PACK 1 KG(*)',
|
||||
'11150055': 'JANTUNG FROZEN PACK 1 KG(*)',
|
||||
'11150064': 'HATI 1 KG FROZEN (20 PAC/KARUNG)(*)',
|
||||
'11150065': 'JANTUNG 1 KG FROZEN (20 PAC/KARUNG)(*)',
|
||||
'11310012': 'AYAM SIZE 0 (0.6-0.7)KG(*)',
|
||||
'11310013': 'AYAM SIZE1 FROZEN (0.75-0.8) KG(*)',
|
||||
'11310014': 'AYAM SIZE 2 FROZEN (0.8-0.9)KG(*)',
|
||||
'11310016': 'AYAM SIZE Z PR FROZ(*)',
|
||||
'11310017': 'AYAM SIZE 0 PR FROZEN(*)',
|
||||
'11310018': 'AYAM SIZE 1 PR FROZEN(*)',
|
||||
'11310019': 'AYAM SIZE 2 PR FROZEN(*)',
|
||||
'11310021': 'AYAM SIZE BESAR (B) FROZ (1-1.1)KG/PC(*)',
|
||||
'11310022': 'AYAM SIZE A PR (0.9-1)KG/PC(*)',
|
||||
'11310024': 'AYAM SIZE A FROZEN (0.9-1)KG/PC(*)',
|
||||
'11310025': 'AYAM SIZE SUPER (C) FROZ(1.1-1.2)KG/ PC(*)',
|
||||
'11310026': 'AYAM SIZE JUMBO (D) FROZ (1.2- 1.3)KG/PC(*)',
|
||||
'11318301': 'BEBEK MUDA-BD1(1.0-1.1 KG)-NEW(*)',
|
||||
'11318306': 'CP DUCK PEKING 1.5-1.6 KG/PC(*)',
|
||||
'11318308': 'BEBEK PEKING SPR BD5(1.7 -1.8 )Kg-NEW(*)',
|
||||
'11410043': 'PARTING 10 SIZE D FRESH BENSU 1.25 KG/PAC(*)',
|
||||
'11420055': 'PARTING 12 ALL SIZE FROZ/PAC(*)',
|
||||
'11600053': 'BONELESS LEG FROZEN 1 KG(*)',
|
||||
'11620056': 'SBL (FILLET PAHA) 1 KG(*)',
|
||||
'11640053': 'PAHA UTUH (1 KG)(*)',
|
||||
'11650053': 'PAHA ATAS 1 KG(*)',
|
||||
'11660050': 'PAHA BAWAH (1 KG)(*)',
|
||||
'11690053': 'SBB (FILLET DADA )1 KG(*)',
|
||||
'11690081': 'SBB JUMBO FZ (2.0 - 2.2 KG/PAC)(*)',
|
||||
'11710051': 'DADA UTUH (1 KG)(*)',
|
||||
'11720055': 'FULL WING FROZ PACK 1 KG(*)',
|
||||
'11730050': 'MIDDLE WING FROZ PACK 1 KG(*)',
|
||||
'11750050': 'FILLET MITRA 1 KG(*)',
|
||||
'11818300': 'CP-BEBEK GORENG 400GR/PAC',
|
||||
'11840002': 'AYAM JANTAN BKKL SZ 0 (600-700) GR/PC(*)',
|
||||
'11959937': 'SATE AYAM FRESHMART 360 GR (PAC)',
|
||||
'12010111': 'FIESTA CRISPY BUBBLE 400 GR/PAC',
|
||||
'12010112': 'FIESTA CHICKEN NUGGET 400 GR/PAC',
|
||||
'12010113': 'FIESTA CHICKEN NUGGET 200 GR/PAC',
|
||||
'12010115': 'FIESTA NUGGET ZOO 400 GR/PAC',
|
||||
'12010116': 'FIESTA NUGGET DINO 400 GR/PAC',
|
||||
'12010117': 'FIESTA NUGGET HAPPY STAR 400 GR/PAC',
|
||||
'12010119': 'FIESTA NUGGET CHEESE 123 400 GR/PAC',
|
||||
'12010121': 'FIESTA NUGGET PIZZABC 400 GR/PAC',
|
||||
'12010122': 'FIESTA CHEESY LOVER 400 GR/PAC',
|
||||
'12010123': 'FIESTA GARLIC CHEESE 400 GR/PAC',
|
||||
'12010124': 'FIESTA CHEESY CHIC W/BROCCOLI 400 GR/PAC',
|
||||
'12010127': 'FIESTA SPICY NUGGET 400 GR/PAC',
|
||||
'12010128': 'FIESTA VOLCANO CHEESE 400 GR/PAC',
|
||||
'12010129': 'FIESTA CHEESY BOMBS CHICKEN NUGGET 400 GR',
|
||||
'12010402': 'GOLDEN FIESTA NUGGET W/PINEAPPLE SAUCE 500 GR',
|
||||
'12010509': 'CHAMP CRUNCHY NUGGET 450 GR/PAC',
|
||||
'12010510': 'CHAMP NUGGET AYAM 225 GR/PAC',
|
||||
'12010511': 'CHAMP NUGGET AYAM 450 GR/PAC',
|
||||
'12010512': 'CHAMP NUGGET AYAM 900 GR/PAC',
|
||||
'12010513': 'CHAMP NUGGET ABC KOMBINASI 225 GR/PAC',
|
||||
'12010514': 'CHAMP NUGGET ABC KOMBINASI 450 GR/PAC',
|
||||
'12010515': 'CHAMP KOIN KOMBINASI 450 GR/PAC',
|
||||
'12010516': 'CHAMP KOIN KOMBINASI 200 GR/PAC',
|
||||
'12010517': 'CHAMP NUGGET STICK 225 GR/PAC',
|
||||
'12010518': 'CHAMP NUGGET STICK 450 GR/PAC',
|
||||
'12010519': 'CHAMP NUGGET STICK 900 GR/PAC',
|
||||
'12010520': 'CHAMP CHICKEN NUGGET BENTUK 123 450 GR/PAC',
|
||||
'12010521': 'CHAMP NUGGET HOTZZ LEVEL 5 450 GR/PAC',
|
||||
'12010606': 'CHAMP CRUNCHY NUGGET 225 GR/PAC',
|
||||
'12010707': 'CHAMP MITRA NUGGET COIN 200 GR (NEW)',
|
||||
'12010801': 'OKEY NUGGET 500GR',
|
||||
'12012201': 'ASIMO NUGGET KOMBINASI 500 GR/PAC',
|
||||
'12012202': 'ASIMO NUGGET KOMBINASI 1 KG/PAC',
|
||||
'12012203': 'ASIMO NUGGET KOMBINASI 250 GR/PAC',
|
||||
'12012501': 'AKUMO CHICKEN NAGET 250 GR',
|
||||
'12012502': 'AKUMO CHICKEN NUGGET 500 GR',
|
||||
'12012503': 'AKUMO CHICKEN NUGGET 1000 GR',
|
||||
'12012504': 'AKUMO COIN 200 GR/PAC',
|
||||
'12012505': 'AKUMO KOIN 400 GR/PAC',
|
||||
'12020102': 'FIESTA SPICY WING 400 GR/PAC',
|
||||
'12020401': 'GOLDEN FIESTA SP WING 500 GR',
|
||||
'12030101': 'FIESTA STIKIE 400 GR/PAC',
|
||||
'12030102': 'FIESTA STIKIE 200 GR/PAC',
|
||||
'12030403': 'GOLDEN FIESTA STIKIE W/ SWEET CHILLI SAUCE 500GR',
|
||||
'12030801': 'OKEY STICK 1000 GR',
|
||||
'12030802': 'OKEY STICK 500GR',
|
||||
'12032201': 'ASIMO STICK KOMBINASI 500 GR/PAC',
|
||||
'12032202': 'ASIMO STICK KOMBINASI 1000 GR/PAC',
|
||||
'12032203': 'ASIMO STIK KOMBINASI 250 GR/PAC',
|
||||
'12032501': 'AKUMO CHICKEN STICK 250 GR',
|
||||
'12032502': 'AKUMO CHICKEN STIK 500 GR',
|
||||
'12032503': 'AKUMO CHICKEN STICK 1000 GR',
|
||||
'12040101': 'FIESTA SCHNITZEL 400 GR/PAC',
|
||||
'12040102': 'FIESTA CRISPY BUBBLE KATSU 400 GR/PAC',
|
||||
'12040404': 'GOLDEN FIESTA CORDON BLEU BBQ SAUCE 500 GR',
|
||||
'12040406': 'GOLDEN FIESTA KATSU W/CHEESE SAUCE 500 GR/PAC',
|
||||
'12050103': 'FIESTA FRIED CHICKEN 400 GR/PAC',
|
||||
'12050104': 'FIESTA HOT & CRISPY FRIED CHICKEN 400 GR/PAC',
|
||||
'12050401': 'GOLDEN FIESTA CRISPY WING W/SP GLAZ SC 500 GR/PAC',
|
||||
'12060103': 'FIESTA KARAGE 200 GR/PAC',
|
||||
'12060104': 'FIESTA KARAGE 400 GR/PAC',
|
||||
'12060105': 'FIESTA SPICY KARAGE 400 GR/PAC',
|
||||
'12060402': 'GOLDEN FIESTA KARAGE CHILI SAUCE 500GR',
|
||||
'12070101': 'FIESTA POK-POK 400 GR/PAC (NEW)',
|
||||
'12080101': 'FIESTA SPICY CHICK 400 GR/PAC',
|
||||
'12130102': 'FIESTA CRISPY BURGER 360 GR (NEW)',
|
||||
'12130504': 'CHAMP BURGER 315 GR (NEW)',
|
||||
'12140105': 'FIESTA CHICK TOFU 400 GR/PAC',
|
||||
'12150201': 'FIESTA DS CRISPY CRUNCH 300 GR/PAC',
|
||||
'12150501': 'CHAMP CRUNCHY HOTZZ 300 GR/PAC',
|
||||
'12190103': 'FIESTA DELISTRIPE 400 GR/PAC',
|
||||
'12240102': 'FIESTA CHEESY ITALIAN R/BITES 400 GR/PAC',
|
||||
'12240103': 'FIESTA YAKINIKU R/BITES 400 GR/PAC',
|
||||
'13010101': 'FIESTA CHICK SSG 300 GR',
|
||||
'13010102': 'FIESTA CHICK SSG 500 GR',
|
||||
'13010103': 'FIESTA CHICK SSG 200 GR/PAC',
|
||||
'13010111': 'FIESTA SOSIS BRATWURST 300 GR',
|
||||
'13010112': 'FIESTA CHEESE SSG 300 GR',
|
||||
'13010113': 'FIESTA SOSIS CURRYWURST 300 GR',
|
||||
'13010114': 'FIESTA SSG BOCKWURST 300GR',
|
||||
'13010115': 'FIESTA SSG WIENER 300GR',
|
||||
'13010116': 'FIESTA SSG ORIGINAL 300 GR',
|
||||
'13010117': 'FIESTA SSG FRANKFURTER 300GR',
|
||||
'13010118': 'FIESTA RTG SSG 65 GR/PAC',
|
||||
'13010119': 'FIESTA RTG C/SPICY KOREAN 60 GR/PAC',
|
||||
'13010120': 'FIESTA RTG C/CHEESY MELTS 65 GR/PAC',
|
||||
'13010122': 'FIESTA RTG SAUSAGE WITH HOT LAVA 60G',
|
||||
'13010123': 'FIESTA RTG SAUSAGE WITH CHEESE LAVA 60G',
|
||||
'13010124': 'FIESTA RTG SAUSAGE HICKORY SAUCE 60GR',
|
||||
'13010125': 'FIESTA RTG SAUSAGE WITH MENTAI LAVA 60GR',
|
||||
'13010510': 'CHAMP CHICK SSG 75 GR',
|
||||
'13010513': 'CHAMP CHICK SSG 375 GR',
|
||||
'13010514': 'CHAMP CHICK SSG 1000 GR',
|
||||
'13010518': 'CHAMP SSG JUMBO BAKAR 500 GR/PAC',
|
||||
'13010519': 'CHAMP SSG BAKAR MINI 500 GR/PAC-INACT',
|
||||
'13010521': 'CHAMP CHICK SSG 150 GR/PAC (NEW)',
|
||||
'13010523': 'CHAMP CHICK SSG AYAM MADU 300 GR/PAC',
|
||||
'13010524': 'CHAMP SSG JUMBO BAKAR 500 GR/PAC (NEW)',
|
||||
'13010525': 'CHAMP SSG BAKAR MINI 500 GR/PAC (NEW)',
|
||||
'13010809': 'OKEY CHICK SSG 500GR-INACT',
|
||||
'13010815': 'OKEY SSG BAKAR JUMBO 500 GR/PAC (NEW)',
|
||||
'13010816': 'OKEY SSG BAKAR MINI 500 GR/PAC (NEW)',
|
||||
'13010817': 'OKEY SSG CHICK KOMBINASI 500 GR (EXTRA1)',
|
||||
'13010818': 'OKEY SSG CHICK KOMBINASI 1 KG (EXTR\$A2)',
|
||||
'13012205': 'ASIMO SOSIS AYAM KOMBINASI 375 GR (PAC)',
|
||||
'13012206': 'ASIMO SOSIS AYAM KOMBINASI 500 GR',
|
||||
'13012207': 'ASIMO SOSIS AYAM KOMBINASI 750 GR',
|
||||
'13012208': 'ASIMO SOSIS AYAM KOMBINASI 1000 GR',
|
||||
'13012209': 'ASIMO SSG CHICK KOMBINASI 500 GR (EXTRA1)',
|
||||
'13012210': 'ASIMO SSG CHICK KOMBINASI 1 KG (EXTR\$A2)',
|
||||
'13030101': 'FIESTA CHICK MEAT BALL 300 GR',
|
||||
'13030102': 'FIESTA CHICK MEATBALL 500 GR',
|
||||
'13030501': 'CHAMP CHICK MEATBALL 200 GR',
|
||||
'13030502': 'CHAMP CHICK MEATBALL 500 GR',
|
||||
'13050101': 'FIESTA SCB 250 GR',
|
||||
'13050105': 'FIESTA CHICKEN SLICE 300 GR',
|
||||
'13050106': 'FIESTA BEEF SLICE 300 GR',
|
||||
'13070501': 'CHAMP BEEF SSG SERBAGUNA 150 GR',
|
||||
'13070502': 'CHAMP BEEF SSG SERBAGUNA 375GR',
|
||||
'13070505': 'CHAMP BEEF SSG GORENG 375 GR',
|
||||
'13070506': 'CHAMP FRANKFURTER SSG 375GR',
|
||||
'13100512': 'CHAMP CHICK SSG S/SANTAP ORIG 546GR (CAN)',
|
||||
'13110504': 'CHAMP BEEF BALL 500GR',
|
||||
'13170510': 'CHAMP BEEF BBQ SSG S/SANTAP 546GR (CAN)',
|
||||
'15010101': 'FIESTA SHOESTRING 500 GR',
|
||||
'15010102': 'FIESTA SHOESTRING 1000 GR',
|
||||
'15010107': 'FIESTA FRENCH F SHOESTRING INSTITUSI 2KG',
|
||||
'15020101': 'FIESTA STRAIGHT CUT 500 GR',
|
||||
'15020102': 'FIESTA STRAIGHT CUT 1000 GR',
|
||||
'15030101': 'FIESTA CRINKLE CUT 500 GR',
|
||||
'15030102': 'FIESTA CRINKLE CUT 1000 GR',
|
||||
'15040101': 'FIESTA BATTER COATED 500 GR',
|
||||
'15040102': 'FIESTA BATTER COATED 1000 GR',
|
||||
'16060103': 'FIESTA CHICK SIOMAY 900GR',
|
||||
'16060113': 'FIESTA CHICK SIOMAY 180GR (NEW)',
|
||||
'16060114': 'FIESTA GYOZA 180 GR (NEW)',
|
||||
'16060119': 'FIESTA RTG SIOMAY 54 GR/PAC',
|
||||
'16060120': 'FIESTA KEECHO 400 GR/PAC',
|
||||
'16060121': 'FIESTA CHICKEN TOFU 400 GR/PAC (NEW)',
|
||||
'16060503': 'CHAMP CHICK&FISH SIOMAY 180 GR (NEW)',
|
||||
'17200109': 'FIESTA RTS C/TERIYAKI 300GR/PAC',
|
||||
'17200110': 'FIESTA RTS C/RENDANG 300GR/PAC',
|
||||
'17200111': 'FIESTA RTS C/W RUJAK SC 300GR/PAC',
|
||||
'17200112': 'FIESTA RTS C/W S/MATAH 300GR/PAC',
|
||||
'17210106': 'FIESTA RTS B/YAKINIKU 300GR/PAC',
|
||||
'17210107': 'FIESTA RTS B/RENDANG 300GR/PAC',
|
||||
'17210108': 'FIESTA RTS B/BLACKPEPPER 300GR/PAC',
|
||||
'17210109': 'FIESTA RTS B/BULGOGI 300GR/PAC',
|
||||
'18050102': 'FIESTA RTG BAKSO KEJU 60 GR/PAC',
|
||||
'18050103': 'FIESTA RTG BAKSO BAKAR BBQ 60 GR/PAC',
|
||||
'18050104': 'FIESTA RTG BEEF BALL WITH MENTAI LAVA 55GR',
|
||||
'18050105': 'FIESTA RTG BEEF BALL WITH CHEESE LAVA 55GR',
|
||||
'20010101': 'FIESTA CRISPY CRUMBS 200 GR',
|
||||
'20010102': 'FIESTA TP ROTI PUTIH 200 GR',
|
||||
'20040101': 'FIESTA RAMEN BEKU 570 GR/PAC',
|
||||
'20120102': 'FIESTA T/B AYAM GORENG 80 GR',
|
||||
'20120105': 'FIESTA T/B SERBAGUNA Â 80 GR',
|
||||
'20120106': 'FIESTA T/B KREMESÂ 80 GR',
|
||||
'20120115': 'FIESTA RACIK AYAM GORENG 20 GR/PAC',
|
||||
'20120116': 'FIESTA RACIK NASI GORENG 20 GR/PAC',
|
||||
'21000123': 'FIESTA RICE W/GEPREK CHICKEN 320GR/PAC',
|
||||
'21000124': 'FIESTA RICE W/CHICK RUJAK 320 GR/PAC',
|
||||
'21000125': 'FIESTA RICE W/KOREAN BBQ CHICK 320 GR/PAC',
|
||||
'21000126': 'NEW FIESTA CHICK RENDANG W RICE 320GR (PAC)',
|
||||
'21000127': 'NEW FIESTA CHICK TERIYAKI W RICE 320GR (PAC)',
|
||||
'21000128': 'NEW FIESTA CHICK TANDORI W RICE 320GR (PAC)',
|
||||
'21000129': 'NEW FIESTA RICE W KARAGE&SSS 320GR (PAC)',
|
||||
'21000130': 'NEW FIESTA RICE W/C CHEESE BULDAK 320GR (PAC)',
|
||||
'21000131': 'NEW FIESTA RICE W CHIC CURRY 320GR (PAC)',
|
||||
'21000132': 'NEW FIESTA RICE W CHICK DONBURI 320GR (PAC)',
|
||||
'21000133': 'NEW FIESTA RICE W CHICK SATAY 320GR (PAC)',
|
||||
'21000134': 'NEW FIESTA COCONUT RICE W SPICY CHICK 320GR (PAC)',
|
||||
'21000135': 'NEW FIESTA RICE W POPBITES S/MATAH 320GR (PAC)',
|
||||
'21000136': 'NEW FIESTA TUMERIC W POPBITES 320GR (PAC)',
|
||||
'21000137': 'FIESTA HAINAMESE CHICKEN RICE 320GR (PAC)',
|
||||
'21010101': 'FIESTA TRUFFLE GYUDON 320 GR/PAC',
|
||||
'21010102': 'NEW FIESTA BEEF YAKINIKU W RICE 320GR (PAC)',
|
||||
'21010103': 'NEW FIESTA BEEF BULGOGI W RICE 320GR (PAC)',
|
||||
'21010104': 'NEW FIESTA BEEF RENDANG W RICE 320GR (PAC)',
|
||||
'21010105': 'NEW FIESTA RICE W BEEF BLACKPEPPER 320GR (PAC)',
|
||||
'21200107': 'NEW FIESTA SPAGHETTI CARBONARA 300GR (PAC)',
|
||||
'21200108': 'NEW FIESTA SPAGHETTI CHIC BOLOGNESE 320GR (PAC)',
|
||||
'21200109': 'NEW FIESTA ITALIAN MEATBALL SPAGHETTI 320GR (PAC)',
|
||||
'21310103': 'NEW FIESTA SCB&S/SSG FRIED RICE 320GR (PAC)',
|
||||
'21500101': 'FIESTA CHICK SSG & C. BALL PIZZA 230GR/PAC',
|
||||
'21500102': 'FIESTA CHEESY BEEF PIZZA 230GR/PAC',
|
||||
'91000012': 'PHOTOCARD RTG',
|
||||
'1188002W': 'PAHA ATAS 25-30 G FZ (*)',
|
||||
'1195008A': 'RTC CHICKEN KALASAN 400 GR (PAC)',
|
||||
'1195008E': 'RTC CHICKEN TERIYAKI 400 GR (PAC)',
|
||||
'1195008X': 'RTC CHICKEN SPICY 400 GR (PAC)',
|
||||
};
|
||||
}
|
||||
class MasterSku {
|
||||
static const Map<String, String> data = {
|
||||
'11048006': 'BEBEK PARTING-NEW(*)',
|
||||
'11110059': 'CEKER BERKUKU FROZEN PACK 1 KG(*)',
|
||||
'11110074': 'CEKER 1 KG FROZEN (20 PAC/KARUNG)(*)',
|
||||
'11140051': 'AMPELA FROZEN PACK 1 KG(*)',
|
||||
'11140062': 'AMPELA 1 KG FROZEN (20 PAC/KARUNG)(*)',
|
||||
'11148002': 'AMPELA BEBEK FROZEN 1 KG/PACK (NEW)(*)',
|
||||
'11150052': 'HATI FROZEN PACK 1 KG(*)',
|
||||
'11150055': 'JANTUNG FROZEN PACK 1 KG(*)',
|
||||
'11150064': 'HATI 1 KG FROZEN (20 PAC/KARUNG)(*)',
|
||||
'11150065': 'JANTUNG 1 KG FROZEN (20 PAC/KARUNG)(*)',
|
||||
'11310012': 'AYAM SIZE 0 (0.6-0.7)KG(*)',
|
||||
'11310013': 'AYAM SIZE1 FROZEN (0.75-0.8) KG(*)',
|
||||
'11310014': 'AYAM SIZE 2 FROZEN (0.8-0.9)KG(*)',
|
||||
'11310016': 'AYAM SIZE Z PR FROZ(*)',
|
||||
'11310017': 'AYAM SIZE 0 PR FROZEN(*)',
|
||||
'11310018': 'AYAM SIZE 1 PR FROZEN(*)',
|
||||
'11310019': 'AYAM SIZE 2 PR FROZEN(*)',
|
||||
'11310021': 'AYAM SIZE BESAR (B) FROZ (1-1.1)KG/PC(*)',
|
||||
'11310022': 'AYAM SIZE A PR (0.9-1)KG/PC(*)',
|
||||
'11310024': 'AYAM SIZE A FROZEN (0.9-1)KG/PC(*)',
|
||||
'11310025': 'AYAM SIZE SUPER (C) FROZ(1.1-1.2)KG/ PC(*)',
|
||||
'11310026': 'AYAM SIZE JUMBO (D) FROZ (1.2- 1.3)KG/PC(*)',
|
||||
'11318301': 'BEBEK MUDA-BD1(1.0-1.1 KG)-NEW(*)',
|
||||
'11318306': 'CP DUCK PEKING 1.5-1.6 KG/PC(*)',
|
||||
'11318308': 'BEBEK PEKING SPR BD5(1.7 -1.8 )Kg-NEW(*)',
|
||||
'11410043': 'PARTING 10 SIZE D FRESH BENSU 1.25 KG/PAC(*)',
|
||||
'11420055': 'PARTING 12 ALL SIZE FROZ/PAC(*)',
|
||||
'11600053': 'BONELESS LEG FROZEN 1 KG(*)',
|
||||
'11620056': 'SBL (FILLET PAHA) 1 KG(*)',
|
||||
'11640053': 'PAHA UTUH (1 KG)(*)',
|
||||
'11650053': 'PAHA ATAS 1 KG(*)',
|
||||
'11660050': 'PAHA BAWAH (1 KG)(*)',
|
||||
'11690053': 'SBB (FILLET DADA )1 KG(*)',
|
||||
'11690081': 'SBB JUMBO FZ (2.0 - 2.2 KG/PAC)(*)',
|
||||
'11710051': 'DADA UTUH (1 KG)(*)',
|
||||
'11720055': 'FULL WING FROZ PACK 1 KG(*)',
|
||||
'11730050': 'MIDDLE WING FROZ PACK 1 KG(*)',
|
||||
'11750050': 'FILLET MITRA 1 KG(*)',
|
||||
'11818300': 'CP-BEBEK GORENG 400GR/PAC',
|
||||
'11840002': 'AYAM JANTAN BKKL SZ 0 (600-700) GR/PC(*)',
|
||||
'11959937': 'SATE AYAM FRESHMART 360 GR (PAC)',
|
||||
'12010111': 'FIESTA CRISPY BUBBLE 400 GR/PAC',
|
||||
'12010112': 'FIESTA CHICKEN NUGGET 400 GR/PAC',
|
||||
'12010113': 'FIESTA CHICKEN NUGGET 200 GR/PAC',
|
||||
'12010115': 'FIESTA NUGGET ZOO 400 GR/PAC',
|
||||
'12010116': 'FIESTA NUGGET DINO 400 GR/PAC',
|
||||
'12010117': 'FIESTA NUGGET HAPPY STAR 400 GR/PAC',
|
||||
'12010119': 'FIESTA NUGGET CHEESE 123 400 GR/PAC',
|
||||
'12010121': 'FIESTA NUGGET PIZZABC 400 GR/PAC',
|
||||
'12010122': 'FIESTA CHEESY LOVER 400 GR/PAC',
|
||||
'12010123': 'FIESTA GARLIC CHEESE 400 GR/PAC',
|
||||
'12010124': 'FIESTA CHEESY CHIC W/BROCCOLI 400 GR/PAC',
|
||||
'12010127': 'FIESTA SPICY NUGGET 400 GR/PAC',
|
||||
'12010128': 'FIESTA VOLCANO CHEESE 400 GR/PAC',
|
||||
'12010129': 'FIESTA CHEESY BOMBS CHICKEN NUGGET 400 GR',
|
||||
'12010402': 'GOLDEN FIESTA NUGGET W/PINEAPPLE SAUCE 500 GR',
|
||||
'12010509': 'CHAMP CRUNCHY NUGGET 450 GR/PAC',
|
||||
'12010510': 'CHAMP NUGGET AYAM 225 GR/PAC',
|
||||
'12010511': 'CHAMP NUGGET AYAM 450 GR/PAC',
|
||||
'12010512': 'CHAMP NUGGET AYAM 900 GR/PAC',
|
||||
'12010513': 'CHAMP NUGGET ABC KOMBINASI 225 GR/PAC',
|
||||
'12010514': 'CHAMP NUGGET ABC KOMBINASI 450 GR/PAC',
|
||||
'12010515': 'CHAMP KOIN KOMBINASI 450 GR/PAC',
|
||||
'12010516': 'CHAMP KOIN KOMBINASI 200 GR/PAC',
|
||||
'12010517': 'CHAMP NUGGET STICK 225 GR/PAC',
|
||||
'12010518': 'CHAMP NUGGET STICK 450 GR/PAC',
|
||||
'12010519': 'CHAMP NUGGET STICK 900 GR/PAC',
|
||||
'12010520': 'CHAMP CHICKEN NUGGET BENTUK 123 450 GR/PAC',
|
||||
'12010521': 'CHAMP NUGGET HOTZZ LEVEL 5 450 GR/PAC',
|
||||
'12010606': 'CHAMP CRUNCHY NUGGET 225 GR/PAC',
|
||||
'12010707': 'CHAMP MITRA NUGGET COIN 200 GR (NEW)',
|
||||
'12010801': 'OKEY NUGGET 500GR',
|
||||
'12012201': 'ASIMO NUGGET KOMBINASI 500 GR/PAC',
|
||||
'12012202': 'ASIMO NUGGET KOMBINASI 1 KG/PAC',
|
||||
'12012203': 'ASIMO NUGGET KOMBINASI 250 GR/PAC',
|
||||
'12012501': 'AKUMO CHICKEN NAGET 250 GR',
|
||||
'12012502': 'AKUMO CHICKEN NUGGET 500 GR',
|
||||
'12012503': 'AKUMO CHICKEN NUGGET 1000 GR',
|
||||
'12012504': 'AKUMO COIN 200 GR/PAC',
|
||||
'12012505': 'AKUMO KOIN 400 GR/PAC',
|
||||
'12020102': 'FIESTA SPICY WING 400 GR/PAC',
|
||||
'12020401': 'GOLDEN FIESTA SP WING 500 GR',
|
||||
'12030101': 'FIESTA STIKIE 400 GR/PAC',
|
||||
'12030102': 'FIESTA STIKIE 200 GR/PAC',
|
||||
'12030403': 'GOLDEN FIESTA STIKIE W/ SWEET CHILLI SAUCE 500GR',
|
||||
'12030801': 'OKEY STICK 1000 GR',
|
||||
'12030802': 'OKEY STICK 500GR',
|
||||
'12032201': 'ASIMO STICK KOMBINASI 500 GR/PAC',
|
||||
'12032202': 'ASIMO STICK KOMBINASI 1000 GR/PAC',
|
||||
'12032203': 'ASIMO STIK KOMBINASI 250 GR/PAC',
|
||||
'12032501': 'AKUMO CHICKEN STICK 250 GR',
|
||||
'12032502': 'AKUMO CHICKEN STIK 500 GR',
|
||||
'12032503': 'AKUMO CHICKEN STICK 1000 GR',
|
||||
'12040101': 'FIESTA SCHNITZEL 400 GR/PAC',
|
||||
'12040102': 'FIESTA CRISPY BUBBLE KATSU 400 GR/PAC',
|
||||
'12040404': 'GOLDEN FIESTA CORDON BLEU BBQ SAUCE 500 GR',
|
||||
'12040406': 'GOLDEN FIESTA KATSU W/CHEESE SAUCE 500 GR/PAC',
|
||||
'12050103': 'FIESTA FRIED CHICKEN 400 GR/PAC',
|
||||
'12050104': 'FIESTA HOT & CRISPY FRIED CHICKEN 400 GR/PAC',
|
||||
'12050401': 'GOLDEN FIESTA CRISPY WING W/SP GLAZ SC 500 GR/PAC',
|
||||
'12060103': 'FIESTA KARAGE 200 GR/PAC',
|
||||
'12060104': 'FIESTA KARAGE 400 GR/PAC',
|
||||
'12060105': 'FIESTA SPICY KARAGE 400 GR/PAC',
|
||||
'12060402': 'GOLDEN FIESTA KARAGE CHILI SAUCE 500GR',
|
||||
'12070101': 'FIESTA POK-POK 400 GR/PAC (NEW)',
|
||||
'12080101': 'FIESTA SPICY CHICK 400 GR/PAC',
|
||||
'12130102': 'FIESTA CRISPY BURGER 360 GR (NEW)',
|
||||
'12130504': 'CHAMP BURGER 315 GR (NEW)',
|
||||
'12140105': 'FIESTA CHICK TOFU 400 GR/PAC',
|
||||
'12150201': 'FIESTA DS CRISPY CRUNCH 300 GR/PAC',
|
||||
'12150501': 'CHAMP CRUNCHY HOTZZ 300 GR/PAC',
|
||||
'12190103': 'FIESTA DELISTRIPE 400 GR/PAC',
|
||||
'12240102': 'FIESTA CHEESY ITALIAN R/BITES 400 GR/PAC',
|
||||
'12240103': 'FIESTA YAKINIKU R/BITES 400 GR/PAC',
|
||||
'13010101': 'FIESTA CHICK SSG 300 GR',
|
||||
'13010102': 'FIESTA CHICK SSG 500 GR',
|
||||
'13010103': 'FIESTA CHICK SSG 200 GR/PAC',
|
||||
'13010111': 'FIESTA SOSIS BRATWURST 300 GR',
|
||||
'13010112': 'FIESTA CHEESE SSG 300 GR',
|
||||
'13010113': 'FIESTA SOSIS CURRYWURST 300 GR',
|
||||
'13010114': 'FIESTA SSG BOCKWURST 300GR',
|
||||
'13010115': 'FIESTA SSG WIENER 300GR',
|
||||
'13010116': 'FIESTA SSG ORIGINAL 300 GR',
|
||||
'13010117': 'FIESTA SSG FRANKFURTER 300GR',
|
||||
'13010118': 'FIESTA RTG SSG 65 GR/PAC',
|
||||
'13010119': 'FIESTA RTG C/SPICY KOREAN 60 GR/PAC',
|
||||
'13010120': 'FIESTA RTG C/CHEESY MELTS 65 GR/PAC',
|
||||
'13010122': 'FIESTA RTG SAUSAGE WITH HOT LAVA 60G',
|
||||
'13010123': 'FIESTA RTG SAUSAGE WITH CHEESE LAVA 60G',
|
||||
'13010124': 'FIESTA RTG SAUSAGE HICKORY SAUCE 60GR',
|
||||
'13010125': 'FIESTA RTG SAUSAGE WITH MENTAI LAVA 60GR',
|
||||
'13010510': 'CHAMP CHICK SSG 75 GR',
|
||||
'13010513': 'CHAMP CHICK SSG 375 GR',
|
||||
'13010514': 'CHAMP CHICK SSG 1000 GR',
|
||||
'13010518': 'CHAMP SSG JUMBO BAKAR 500 GR/PAC',
|
||||
'13010519': 'CHAMP SSG BAKAR MINI 500 GR/PAC-INACT',
|
||||
'13010521': 'CHAMP CHICK SSG 150 GR/PAC (NEW)',
|
||||
'13010523': 'CHAMP CHICK SSG AYAM MADU 300 GR/PAC',
|
||||
'13010524': 'CHAMP SSG JUMBO BAKAR 500 GR/PAC (NEW)',
|
||||
'13010525': 'CHAMP SSG BAKAR MINI 500 GR/PAC (NEW)',
|
||||
'13010809': 'OKEY CHICK SSG 500GR-INACT',
|
||||
'13010815': 'OKEY SSG BAKAR JUMBO 500 GR/PAC (NEW)',
|
||||
'13010816': 'OKEY SSG BAKAR MINI 500 GR/PAC (NEW)',
|
||||
'13010817': 'OKEY SSG CHICK KOMBINASI 500 GR (EXTRA1)',
|
||||
'13010818': 'OKEY SSG CHICK KOMBINASI 1 KG (EXTR\$A2)',
|
||||
'13012205': 'ASIMO SOSIS AYAM KOMBINASI 375 GR (PAC)',
|
||||
'13012206': 'ASIMO SOSIS AYAM KOMBINASI 500 GR',
|
||||
'13012207': 'ASIMO SOSIS AYAM KOMBINASI 750 GR',
|
||||
'13012208': 'ASIMO SOSIS AYAM KOMBINASI 1000 GR',
|
||||
'13012209': 'ASIMO SSG CHICK KOMBINASI 500 GR (EXTRA1)',
|
||||
'13012210': 'ASIMO SSG CHICK KOMBINASI 1 KG (EXTR\$A2)',
|
||||
'13030101': 'FIESTA CHICK MEAT BALL 300 GR',
|
||||
'13030102': 'FIESTA CHICK MEATBALL 500 GR',
|
||||
'13030501': 'CHAMP CHICK MEATBALL 200 GR',
|
||||
'13030502': 'CHAMP CHICK MEATBALL 500 GR',
|
||||
'13050101': 'FIESTA SCB 250 GR',
|
||||
'13050105': 'FIESTA CHICKEN SLICE 300 GR',
|
||||
'13050106': 'FIESTA BEEF SLICE 300 GR',
|
||||
'13070501': 'CHAMP BEEF SSG SERBAGUNA 150 GR',
|
||||
'13070502': 'CHAMP BEEF SSG SERBAGUNA 375GR',
|
||||
'13070505': 'CHAMP BEEF SSG GORENG 375 GR',
|
||||
'13070506': 'CHAMP FRANKFURTER SSG 375GR',
|
||||
'13100512': 'CHAMP CHICK SSG S/SANTAP ORIG 546GR (CAN)',
|
||||
'13110504': 'CHAMP BEEF BALL 500GR',
|
||||
'13170510': 'CHAMP BEEF BBQ SSG S/SANTAP 546GR (CAN)',
|
||||
'15010101': 'FIESTA SHOESTRING 500 GR',
|
||||
'15010102': 'FIESTA SHOESTRING 1000 GR',
|
||||
'15010107': 'FIESTA FRENCH F SHOESTRING INSTITUSI 2KG',
|
||||
'15020101': 'FIESTA STRAIGHT CUT 500 GR',
|
||||
'15020102': 'FIESTA STRAIGHT CUT 1000 GR',
|
||||
'15030101': 'FIESTA CRINKLE CUT 500 GR',
|
||||
'15030102': 'FIESTA CRINKLE CUT 1000 GR',
|
||||
'15040101': 'FIESTA BATTER COATED 500 GR',
|
||||
'15040102': 'FIESTA BATTER COATED 1000 GR',
|
||||
'16060103': 'FIESTA CHICK SIOMAY 900GR',
|
||||
'16060113': 'FIESTA CHICK SIOMAY 180GR (NEW)',
|
||||
'16060114': 'FIESTA GYOZA 180 GR (NEW)',
|
||||
'16060119': 'FIESTA RTG SIOMAY 54 GR/PAC',
|
||||
'16060120': 'FIESTA KEECHO 400 GR/PAC',
|
||||
'16060121': 'FIESTA CHICKEN TOFU 400 GR/PAC (NEW)',
|
||||
'16060503': 'CHAMP CHICK&FISH SIOMAY 180 GR (NEW)',
|
||||
'17200109': 'FIESTA RTS C/TERIYAKI 300GR/PAC',
|
||||
'17200110': 'FIESTA RTS C/RENDANG 300GR/PAC',
|
||||
'17200111': 'FIESTA RTS C/W RUJAK SC 300GR/PAC',
|
||||
'17200112': 'FIESTA RTS C/W S/MATAH 300GR/PAC',
|
||||
'17210106': 'FIESTA RTS B/YAKINIKU 300GR/PAC',
|
||||
'17210107': 'FIESTA RTS B/RENDANG 300GR/PAC',
|
||||
'17210108': 'FIESTA RTS B/BLACKPEPPER 300GR/PAC',
|
||||
'17210109': 'FIESTA RTS B/BULGOGI 300GR/PAC',
|
||||
'18050102': 'FIESTA RTG BAKSO KEJU 60 GR/PAC',
|
||||
'18050103': 'FIESTA RTG BAKSO BAKAR BBQ 60 GR/PAC',
|
||||
'18050104': 'FIESTA RTG BEEF BALL WITH MENTAI LAVA 55GR',
|
||||
'18050105': 'FIESTA RTG BEEF BALL WITH CHEESE LAVA 55GR',
|
||||
'20010101': 'FIESTA CRISPY CRUMBS 200 GR',
|
||||
'20010102': 'FIESTA TP ROTI PUTIH 200 GR',
|
||||
'20040101': 'FIESTA RAMEN BEKU 570 GR/PAC',
|
||||
'20120102': 'FIESTA T/B AYAM GORENG 80 GR',
|
||||
'20120105': 'FIESTA T/B SERBAGUNA Â 80 GR',
|
||||
'20120106': 'FIESTA T/B KREMESÂ 80 GR',
|
||||
'20120115': 'FIESTA RACIK AYAM GORENG 20 GR/PAC',
|
||||
'20120116': 'FIESTA RACIK NASI GORENG 20 GR/PAC',
|
||||
'21000123': 'FIESTA RICE W/GEPREK CHICKEN 320GR/PAC',
|
||||
'21000124': 'FIESTA RICE W/CHICK RUJAK 320 GR/PAC',
|
||||
'21000125': 'FIESTA RICE W/KOREAN BBQ CHICK 320 GR/PAC',
|
||||
'21000126': 'NEW FIESTA CHICK RENDANG W RICE 320GR (PAC)',
|
||||
'21000127': 'NEW FIESTA CHICK TERIYAKI W RICE 320GR (PAC)',
|
||||
'21000128': 'NEW FIESTA CHICK TANDORI W RICE 320GR (PAC)',
|
||||
'21000129': 'NEW FIESTA RICE W KARAGE&SSS 320GR (PAC)',
|
||||
'21000130': 'NEW FIESTA RICE W/C CHEESE BULDAK 320GR (PAC)',
|
||||
'21000131': 'NEW FIESTA RICE W CHIC CURRY 320GR (PAC)',
|
||||
'21000132': 'NEW FIESTA RICE W CHICK DONBURI 320GR (PAC)',
|
||||
'21000133': 'NEW FIESTA RICE W CHICK SATAY 320GR (PAC)',
|
||||
'21000134': 'NEW FIESTA COCONUT RICE W SPICY CHICK 320GR (PAC)',
|
||||
'21000135': 'NEW FIESTA RICE W POPBITES S/MATAH 320GR (PAC)',
|
||||
'21000136': 'NEW FIESTA TUMERIC W POPBITES 320GR (PAC)',
|
||||
'21000137': 'FIESTA HAINAMESE CHICKEN RICE 320GR (PAC)',
|
||||
'21010101': 'FIESTA TRUFFLE GYUDON 320 GR/PAC',
|
||||
'21010102': 'NEW FIESTA BEEF YAKINIKU W RICE 320GR (PAC)',
|
||||
'21010103': 'NEW FIESTA BEEF BULGOGI W RICE 320GR (PAC)',
|
||||
'21010104': 'NEW FIESTA BEEF RENDANG W RICE 320GR (PAC)',
|
||||
'21010105': 'NEW FIESTA RICE W BEEF BLACKPEPPER 320GR (PAC)',
|
||||
'21200107': 'NEW FIESTA SPAGHETTI CARBONARA 300GR (PAC)',
|
||||
'21200108': 'NEW FIESTA SPAGHETTI CHIC BOLOGNESE 320GR (PAC)',
|
||||
'21200109': 'NEW FIESTA ITALIAN MEATBALL SPAGHETTI 320GR (PAC)',
|
||||
'21310103': 'NEW FIESTA SCB&S/SSG FRIED RICE 320GR (PAC)',
|
||||
'21500101': 'FIESTA CHICK SSG & C. BALL PIZZA 230GR/PAC',
|
||||
'21500102': 'FIESTA CHEESY BEEF PIZZA 230GR/PAC',
|
||||
'91000012': 'PHOTOCARD RTG',
|
||||
'1188002W': 'PAHA ATAS 25-30 G FZ (*)',
|
||||
'1195008A': 'RTC CHICKEN KALASAN 400 GR (PAC)',
|
||||
'1195008E': 'RTC CHICKEN TERIYAKI 400 GR (PAC)',
|
||||
'1195008X': 'RTC CHICKEN SPICY 400 GR (PAC)',
|
||||
};
|
||||
}
|
||||
@@ -1,99 +1,99 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../main.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
|
||||
class AuthNotifier extends StateNotifier<bool> {
|
||||
AuthNotifier(this._ref) : super(false);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<bool> checkLoginState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('auth_token');
|
||||
if (token != null) {
|
||||
state = true;
|
||||
try {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
final response = await apiClient.client.get('/auth/me');
|
||||
if (response.data is Map && response.data['data'] is Map) {
|
||||
final profile = response.data['data']['profile'];
|
||||
if (profile != null) {
|
||||
await prefs.setString('nama_toko', profile['namaToko'] ?? '');
|
||||
await prefs.setString('kode_toko', profile['kodeToko'] ?? '');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If unauthorized, perform clean logout
|
||||
if (e.toString().contains('401') || e.toString().contains('Unauthorized')) {
|
||||
await logout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
state = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> login(String username, String password) async {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
try {
|
||||
final response = await apiClient.client.post(
|
||||
AppConfig.loginEndpoint,
|
||||
data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data is! Map || response.data['data'] is! Map) {
|
||||
throw Exception("Format respon server tidak valid (bukan JSON)");
|
||||
}
|
||||
|
||||
final token = response.data['data']['token'];
|
||||
final profile = response.data['data']['profile'];
|
||||
if (token == null) {
|
||||
throw Exception("Token otentikasi tidak ditemukan dalam respon");
|
||||
}
|
||||
|
||||
// Clear any pre-existing local data before saving the new token/profile
|
||||
final localStorage = _ref.read(localStorageProvider);
|
||||
await localStorage.clearAll();
|
||||
_ref.read(pendingDocumentsProvider.notifier).clearQueue();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('auth_token', token);
|
||||
if (profile != null) {
|
||||
await prefs.setString('nama_toko', profile['namaToko'] ?? '');
|
||||
await prefs.setString('kode_toko', profile['kodeToko'] ?? '');
|
||||
}
|
||||
|
||||
state = true;
|
||||
} catch (e) {
|
||||
state = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('auth_token');
|
||||
await prefs.remove('nama_toko');
|
||||
await prefs.remove('kode_toko');
|
||||
|
||||
final localStorage = _ref.read(localStorageProvider);
|
||||
await localStorage.clearAll();
|
||||
|
||||
// Clear pending documents queue in memory
|
||||
_ref.read(pendingDocumentsProvider.notifier).clearQueue();
|
||||
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, bool>((ref) {
|
||||
return AuthNotifier(ref);
|
||||
});
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../main.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
|
||||
class AuthNotifier extends StateNotifier<bool> {
|
||||
AuthNotifier(this._ref) : super(false);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<bool> checkLoginState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('auth_token');
|
||||
if (token != null) {
|
||||
state = true;
|
||||
try {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
final response = await apiClient.client.get('/auth/me');
|
||||
if (response.data is Map && response.data['data'] is Map) {
|
||||
final profile = response.data['data']['profile'];
|
||||
if (profile != null) {
|
||||
await prefs.setString('nama_toko', profile['namaToko'] ?? '');
|
||||
await prefs.setString('kode_toko', profile['kodeToko'] ?? '');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If unauthorized, perform clean logout
|
||||
if (e.toString().contains('401') || e.toString().contains('Unauthorized')) {
|
||||
await logout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
state = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> login(String username, String password) async {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
try {
|
||||
final response = await apiClient.client.post(
|
||||
AppConfig.loginEndpoint,
|
||||
data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data is! Map || response.data['data'] is! Map) {
|
||||
throw Exception("Format respon server tidak valid (bukan JSON)");
|
||||
}
|
||||
|
||||
final token = response.data['data']['token'];
|
||||
final profile = response.data['data']['profile'];
|
||||
if (token == null) {
|
||||
throw Exception("Token otentikasi tidak ditemukan dalam respon");
|
||||
}
|
||||
|
||||
// Clear any pre-existing local data before saving the new token/profile
|
||||
final localStorage = _ref.read(localStorageProvider);
|
||||
await localStorage.clearAll();
|
||||
_ref.read(pendingDocumentsProvider.notifier).clearQueue();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('auth_token', token);
|
||||
if (profile != null) {
|
||||
await prefs.setString('nama_toko', profile['namaToko'] ?? '');
|
||||
await prefs.setString('kode_toko', profile['kodeToko'] ?? '');
|
||||
}
|
||||
|
||||
state = true;
|
||||
} catch (e) {
|
||||
state = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('auth_token');
|
||||
await prefs.remove('nama_toko');
|
||||
await prefs.remove('kode_toko');
|
||||
|
||||
final localStorage = _ref.read(localStorageProvider);
|
||||
await localStorage.clearAll();
|
||||
|
||||
// Clear pending documents queue in memory
|
||||
_ref.read(pendingDocumentsProvider.notifier).clearQueue();
|
||||
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, bool>((ref) {
|
||||
return AuthNotifier(ref);
|
||||
});
|
||||
+151
-151
@@ -1,151 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _idController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
|
||||
void _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final id = _idController.text.trim();
|
||||
final pwd = _passwordController.text.trim();
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await ref.read(authProvider.notifier).login(id, pwd);
|
||||
if (mounted) {
|
||||
context.go('/camera');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ApiException.from(e).displayMessage,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppConfig.spaceXXL,
|
||||
vertical: AppConfig.spaceXL,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/logo.png',
|
||||
width: 120,
|
||||
height: 120,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
Icons.storefront,
|
||||
size: 80,
|
||||
color: AppConfig.primaryColor
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
'Selamat Datang Kembali',
|
||||
style: AppConfig.h1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
Text(
|
||||
'Masuk untuk menyinkronkan dokumen Anda ke server pusat',
|
||||
style: AppConfig.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXXL * 1.5),
|
||||
TextFormField(
|
||||
controller: _idController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'ID dari Admin',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Username/ID tidak boleh kosong';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kata Sandi',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Kata sandi tidak boleh kosong';
|
||||
}
|
||||
if (value.trim().length < 6) {
|
||||
return 'Kata sandi minimal 6 karakter';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXXL),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 3)
|
||||
)
|
||||
: const Text('Masuk'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
'Hubungi admin pusat untuk mendapatkan kredensial akun.',
|
||||
style: AppConfig.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _idController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
|
||||
void _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final id = _idController.text.trim();
|
||||
final pwd = _passwordController.text.trim();
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await ref.read(authProvider.notifier).login(id, pwd);
|
||||
if (mounted) {
|
||||
context.go('/camera');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ApiException.from(e).displayMessage,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppConfig.spaceXXL,
|
||||
vertical: AppConfig.spaceXL,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/logo.png',
|
||||
width: 120,
|
||||
height: 120,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
Icons.storefront,
|
||||
size: 80,
|
||||
color: AppConfig.primaryColor
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
'Selamat Datang Kembali',
|
||||
style: AppConfig.h1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
Text(
|
||||
'Masuk untuk menyinkronkan dokumen Anda ke server pusat',
|
||||
style: AppConfig.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXXL * 1.5),
|
||||
TextFormField(
|
||||
controller: _idController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'ID dari Admin',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Username/ID tidak boleh kosong';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kata Sandi',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Kata sandi tidak boleh kosong';
|
||||
}
|
||||
if (value.trim().length < 6) {
|
||||
return 'Kata sandi minimal 6 karakter';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXXL),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 3)
|
||||
)
|
||||
: const Text('Masuk'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
'Hubungi admin pusat untuk mendapatkan kredensial akun.',
|
||||
style: AppConfig.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,112 +1,112 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
class BlurResult {
|
||||
final double score; // Scaled score (0 - 100)
|
||||
final bool isBlur;
|
||||
final double rawScore;
|
||||
|
||||
BlurResult({required this.score, required this.isBlur, required this.rawScore});
|
||||
}
|
||||
|
||||
class BlurDetector {
|
||||
static BlurResult? mockResult;
|
||||
|
||||
/// We scale the raw variance of Laplacian score (0 - 500.0) to a scaled score (0 - 100.0).
|
||||
/// Center cropping and Gaussian blur isolate text sharpness.
|
||||
static const double maxRawScore = 500.0;
|
||||
static const double rawThreshold = 400.0; // Below this raw score is blurry (80% of 500.0)
|
||||
|
||||
static const double scaledThreshold = 80.0; // Corresponding scaled threshold (400 / 500 * 100)
|
||||
|
||||
static Future<BlurResult> checkBlur(String filePath) async {
|
||||
if (mockResult != null) {
|
||||
return mockResult!;
|
||||
}
|
||||
return await compute(_checkBlurIsolate, filePath);
|
||||
}
|
||||
|
||||
static BlurResult _checkBlurIsolate(String filePath) {
|
||||
try {
|
||||
final bytes = File(filePath).readAsBytesSync();
|
||||
final image = img.decodeImage(bytes);
|
||||
if (image == null) {
|
||||
return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
}
|
||||
|
||||
// 1. Resize to 300 width for fast calculation (reduces isolate time to <100ms)
|
||||
final resized = img.copyResize(image, width: 300);
|
||||
|
||||
// 2. Crop the center 60% area to focus purely on text and ignore document borders / backgrounds
|
||||
final cropWidth = (resized.width * 0.60).toInt();
|
||||
final cropHeight = (resized.height * 0.60).toInt();
|
||||
final startX = (resized.width - cropWidth) ~/ 2;
|
||||
final startY = (resized.height - cropHeight) ~/ 2;
|
||||
final cropped = img.copyCrop(resized, x: startX, y: startY, width: cropWidth, height: cropHeight);
|
||||
|
||||
// 3. Apply Gaussian Blur to filter out sensor noise/camera shaking noise
|
||||
final smoothed = img.gaussianBlur(cropped, radius: 1);
|
||||
|
||||
// 4. Convert to Grayscale
|
||||
final gray = img.grayscale(smoothed);
|
||||
|
||||
final width = gray.width;
|
||||
final height = gray.height;
|
||||
|
||||
// 5. Compute Laplacian values
|
||||
final laplacianValues = <double>[];
|
||||
double sum = 0.0;
|
||||
|
||||
for (int y = 1; y < height - 1; y++) {
|
||||
for (int x = 1; x < width - 1; x++) {
|
||||
final p = gray.getPixel(x, y);
|
||||
final pLeft = gray.getPixel(x - 1, y);
|
||||
final pRight = gray.getPixel(x + 1, y);
|
||||
final pTop = gray.getPixel(x, y - 1);
|
||||
final pBottom = gray.getPixel(x, y + 1);
|
||||
|
||||
// Get grayscale values directly (r = g = b)
|
||||
final lum = p.r.toDouble();
|
||||
final lumLeft = pLeft.r.toDouble();
|
||||
final lumRight = pRight.r.toDouble();
|
||||
final lumTop = pTop.r.toDouble();
|
||||
final lumBottom = pBottom.r.toDouble();
|
||||
|
||||
// 3x3 Laplacian: 4 * center - left - right - top - bottom
|
||||
final laplacian = (4 * lum) - lumLeft - lumRight - lumTop - lumBottom;
|
||||
laplacianValues.add(laplacian);
|
||||
sum += laplacian;
|
||||
}
|
||||
}
|
||||
|
||||
final count = laplacianValues.length;
|
||||
if (count == 0) return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
|
||||
final mean = sum / count;
|
||||
|
||||
// 6. Calculate Variance of Laplacian
|
||||
double varianceSum = 0.0;
|
||||
for (final val in laplacianValues) {
|
||||
varianceSum += (val - mean) * (val - mean);
|
||||
}
|
||||
|
||||
final rawScore = varianceSum / count;
|
||||
|
||||
// 7. Scale score to 0-100 range
|
||||
final scaledScore = (rawScore / maxRawScore * 100).clamp(0.0, 100.0);
|
||||
final isBlur = scaledScore < scaledThreshold;
|
||||
|
||||
debugPrint('Blur detection: Raw Score: $rawScore, Scaled Score: $scaledScore, IsBlur: $isBlur');
|
||||
|
||||
return BlurResult(
|
||||
score: scaledScore,
|
||||
isBlur: isBlur,
|
||||
rawScore: rawScore,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error detecting blur in isolate: $e');
|
||||
return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
class BlurResult {
|
||||
final double score; // Scaled score (0 - 100)
|
||||
final bool isBlur;
|
||||
final double rawScore;
|
||||
|
||||
BlurResult({required this.score, required this.isBlur, required this.rawScore});
|
||||
}
|
||||
|
||||
class BlurDetector {
|
||||
static BlurResult? mockResult;
|
||||
|
||||
/// We scale the raw variance of Laplacian score (0 - 500.0) to a scaled score (0 - 100.0).
|
||||
/// Center cropping and Gaussian blur isolate text sharpness.
|
||||
static const double maxRawScore = 500.0;
|
||||
static const double rawThreshold = 400.0; // Below this raw score is blurry (80% of 500.0)
|
||||
|
||||
static const double scaledThreshold = 80.0; // Corresponding scaled threshold (400 / 500 * 100)
|
||||
|
||||
static Future<BlurResult> checkBlur(String filePath) async {
|
||||
if (mockResult != null) {
|
||||
return mockResult!;
|
||||
}
|
||||
return await compute(_checkBlurIsolate, filePath);
|
||||
}
|
||||
|
||||
static BlurResult _checkBlurIsolate(String filePath) {
|
||||
try {
|
||||
final bytes = File(filePath).readAsBytesSync();
|
||||
final image = img.decodeImage(bytes);
|
||||
if (image == null) {
|
||||
return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
}
|
||||
|
||||
// 1. Resize to 300 width for fast calculation (reduces isolate time to <100ms)
|
||||
final resized = img.copyResize(image, width: 300);
|
||||
|
||||
// 2. Crop the center 60% area to focus purely on text and ignore document borders / backgrounds
|
||||
final cropWidth = (resized.width * 0.60).toInt();
|
||||
final cropHeight = (resized.height * 0.60).toInt();
|
||||
final startX = (resized.width - cropWidth) ~/ 2;
|
||||
final startY = (resized.height - cropHeight) ~/ 2;
|
||||
final cropped = img.copyCrop(resized, x: startX, y: startY, width: cropWidth, height: cropHeight);
|
||||
|
||||
// 3. Apply Gaussian Blur to filter out sensor noise/camera shaking noise
|
||||
final smoothed = img.gaussianBlur(cropped, radius: 1);
|
||||
|
||||
// 4. Convert to Grayscale
|
||||
final gray = img.grayscale(smoothed);
|
||||
|
||||
final width = gray.width;
|
||||
final height = gray.height;
|
||||
|
||||
// 5. Compute Laplacian values
|
||||
final laplacianValues = <double>[];
|
||||
double sum = 0.0;
|
||||
|
||||
for (int y = 1; y < height - 1; y++) {
|
||||
for (int x = 1; x < width - 1; x++) {
|
||||
final p = gray.getPixel(x, y);
|
||||
final pLeft = gray.getPixel(x - 1, y);
|
||||
final pRight = gray.getPixel(x + 1, y);
|
||||
final pTop = gray.getPixel(x, y - 1);
|
||||
final pBottom = gray.getPixel(x, y + 1);
|
||||
|
||||
// Get grayscale values directly (r = g = b)
|
||||
final lum = p.r.toDouble();
|
||||
final lumLeft = pLeft.r.toDouble();
|
||||
final lumRight = pRight.r.toDouble();
|
||||
final lumTop = pTop.r.toDouble();
|
||||
final lumBottom = pBottom.r.toDouble();
|
||||
|
||||
// 3x3 Laplacian: 4 * center - left - right - top - bottom
|
||||
final laplacian = (4 * lum) - lumLeft - lumRight - lumTop - lumBottom;
|
||||
laplacianValues.add(laplacian);
|
||||
sum += laplacian;
|
||||
}
|
||||
}
|
||||
|
||||
final count = laplacianValues.length;
|
||||
if (count == 0) return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
|
||||
final mean = sum / count;
|
||||
|
||||
// 6. Calculate Variance of Laplacian
|
||||
double varianceSum = 0.0;
|
||||
for (final val in laplacianValues) {
|
||||
varianceSum += (val - mean) * (val - mean);
|
||||
}
|
||||
|
||||
final rawScore = varianceSum / count;
|
||||
|
||||
// 7. Scale score to 0-100 range
|
||||
final scaledScore = (rawScore / maxRawScore * 100).clamp(0.0, 100.0);
|
||||
final isBlur = scaledScore < scaledThreshold;
|
||||
|
||||
debugPrint('Blur detection: Raw Score: $rawScore, Scaled Score: $scaledScore, IsBlur: $isBlur');
|
||||
|
||||
return BlurResult(
|
||||
score: scaledScore,
|
||||
isBlur: isBlur,
|
||||
rawScore: rawScore,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error detecting blur in isolate: $e');
|
||||
return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,233 +1,233 @@
|
||||
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 '../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});
|
||||
|
||||
@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) {
|
||||
return Drawer(
|
||||
backgroundColor: Colors.white,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Profile header ─────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.grey),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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 (DO/Product) ────────────────────────────────
|
||||
const CameraDrawerModeToggle(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Menu items ─────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: [
|
||||
buildDrawerSectionHeader('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),
|
||||
buildDrawerSectionHeader('USER PROFILE'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.person_outline,
|
||||
title: 'Profile',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Profile');
|
||||
},
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.notifications_none_outlined,
|
||||
title: 'Notifications',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Notifications');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
buildDrawerSectionHeader('SUPPORT & SETTINGS'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.settings_outlined,
|
||||
title: 'Settings',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Settings');
|
||||
},
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.help_outline,
|
||||
title: 'Help',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerHelpDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────
|
||||
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: _handleLogout,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
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 '../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});
|
||||
|
||||
@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) {
|
||||
return Drawer(
|
||||
backgroundColor: Colors.white,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Profile header ─────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.grey),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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 (DO/Product) ────────────────────────────────
|
||||
const CameraDrawerModeToggle(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Menu items ─────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: [
|
||||
buildDrawerSectionHeader('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),
|
||||
buildDrawerSectionHeader('USER PROFILE'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.person_outline,
|
||||
title: 'Profile',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Profile');
|
||||
},
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.notifications_none_outlined,
|
||||
title: 'Notifications',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Notifications');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
buildDrawerSectionHeader('SUPPORT & SETTINGS'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.settings_outlined,
|
||||
title: 'Settings',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Settings');
|
||||
},
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.help_outline,
|
||||
title: 'Help',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerHelpDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────
|
||||
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: _handleLogout,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
/// Stateless helper functions extracted from [CameraDrawer] to keep that
|
||||
/// file under the AGENTS.md §3 256-line threshold (touched in task 8.1).
|
||||
/// Contains only UI helpers with no side-effects or state.
|
||||
|
||||
Widget buildDrawerSectionHeader(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 showDrawerNotImplemented(BuildContext context, String featureName) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(' feature coming soon!'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showDrawerHelpDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => 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\n'
|
||||
'PT. Primafood International\n'
|
||||
'Jl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Tutup'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
/// Stateless helper functions extracted from [CameraDrawer] to keep that
|
||||
/// file under the AGENTS.md §3 256-line threshold (touched in task 8.1).
|
||||
/// Contains only UI helpers with no side-effects or state.
|
||||
|
||||
Widget buildDrawerSectionHeader(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 showDrawerNotImplemented(BuildContext context, String featureName) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(' feature coming soon!'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showDrawerHelpDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => 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\n'
|
||||
'PT. Primafood International\n'
|
||||
'Jl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Tutup'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,125 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
|
||||
/// DO / Product Scan mode toggle pill rendered in the [CameraDrawer].
|
||||
///
|
||||
/// Extracted from [CameraDrawer] to keep that file under the 256-line §3
|
||||
/// threshold (AGENTS.md §3, touched during task 8.1). Reads and writes
|
||||
/// [scanModeProvider] directly — the global source of truth shared with
|
||||
/// [DocumentsScreen] (task 8.1, docs/api-contract-map.md G11).
|
||||
///
|
||||
/// Color convention:
|
||||
/// - DO active → [AppConfig.doModeColor] (orange)
|
||||
/// - Product active → [AppConfig.primaryColor] (green)
|
||||
class CameraDrawerModeToggle extends ConsumerWidget {
|
||||
const CameraDrawerModeToggle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return 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: [
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'DO',
|
||||
label: 'DO Scan',
|
||||
activeIcon: Icons.description,
|
||||
inactiveIcon: Icons.description_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.doModeColor,
|
||||
),
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'Product',
|
||||
label: 'Product Scan',
|
||||
activeIcon: Icons.inventory_2,
|
||||
inactiveIcon: Icons.inventory_2_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegment({
|
||||
required WidgetRef ref,
|
||||
required String value,
|
||||
required String label,
|
||||
required IconData activeIcon,
|
||||
required IconData inactiveIcon,
|
||||
required String activeMode,
|
||||
required Color activeColor,
|
||||
}) {
|
||||
final isActive = activeMode == value;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => ref.read(scanModeProvider.notifier).state = value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? activeColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: activeColor.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: [],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isActive ? activeIcon : inactiveIcon,
|
||||
size: 16,
|
||||
color: isActive ? Colors.white : Colors.grey.shade500,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? Colors.white : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
|
||||
/// DO / Product Scan mode toggle pill rendered in the [CameraDrawer].
|
||||
///
|
||||
/// Extracted from [CameraDrawer] to keep that file under the 256-line §3
|
||||
/// threshold (AGENTS.md §3, touched during task 8.1). Reads and writes
|
||||
/// [scanModeProvider] directly — the global source of truth shared with
|
||||
/// [DocumentsScreen] (task 8.1, docs/api-contract-map.md G11).
|
||||
///
|
||||
/// Color convention:
|
||||
/// - DO active → [AppConfig.doModeColor] (orange)
|
||||
/// - Product active → [AppConfig.primaryColor] (green)
|
||||
class CameraDrawerModeToggle extends ConsumerWidget {
|
||||
const CameraDrawerModeToggle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return 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: [
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'DO',
|
||||
label: 'DO Scan',
|
||||
activeIcon: Icons.description,
|
||||
inactiveIcon: Icons.description_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.doModeColor,
|
||||
),
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'Product',
|
||||
label: 'Product Scan',
|
||||
activeIcon: Icons.inventory_2,
|
||||
inactiveIcon: Icons.inventory_2_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegment({
|
||||
required WidgetRef ref,
|
||||
required String value,
|
||||
required String label,
|
||||
required IconData activeIcon,
|
||||
required IconData inactiveIcon,
|
||||
required String activeMode,
|
||||
required Color activeColor,
|
||||
}) {
|
||||
final isActive = activeMode == value;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => ref.read(scanModeProvider.notifier).state = value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? activeColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: activeColor.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: [],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isActive ? activeIcon : inactiveIcon,
|
||||
size: 16,
|
||||
color: isActive ? Colors.white : Colors.grey.shade500,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? Colors.white : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,324 +1,324 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../../core/location/location_service.dart';
|
||||
import '../../core/utils/exif_helper.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'image_preview_screen.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
import 'camera_drawer.dart';
|
||||
|
||||
class CameraScreen extends ConsumerStatefulWidget {
|
||||
const CameraScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CameraScreen> createState() => CameraScreenState();
|
||||
}
|
||||
|
||||
class CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
bool _isCapturing = false;
|
||||
Position? _currentPosition;
|
||||
StreamSubscription<Position>? _locationSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_preFetchLocation();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_locationSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _preFetchLocation() async {
|
||||
try {
|
||||
final pos = await LocationService.determinePosition();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = pos;
|
||||
});
|
||||
}
|
||||
|
||||
// Live location updates
|
||||
_locationSubscription = LocationService.getPositionStream().listen(
|
||||
(Position freshPos) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = freshPos;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _takePicture() async {
|
||||
if (_isCapturing) return;
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
});
|
||||
|
||||
// Use pre-fetched location if available, otherwise fetch quick position
|
||||
Position? position = _currentPosition;
|
||||
if (position == null) {
|
||||
try {
|
||||
position = await LocationService.determinePosition().timeout(const Duration(seconds: 2));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isCapturing = false;
|
||||
});
|
||||
|
||||
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error taking picture: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFromGallery() async {
|
||||
if (_isCapturing) return;
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
});
|
||||
|
||||
Position? position = _currentPosition;
|
||||
try {
|
||||
// Attempt to extract GPS from EXIF tags
|
||||
final exifGps = await ExifHelper.extractGps(image.path);
|
||||
if (exifGps != null) {
|
||||
position = Position(
|
||||
latitude: exifGps['latitude']!,
|
||||
longitude: exifGps['longitude']!,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 0.0,
|
||||
altitude: 0.0,
|
||||
heading: 0.0,
|
||||
speed: 0.0,
|
||||
speedAccuracy: 0.0,
|
||||
altitudeAccuracy: 0.0,
|
||||
headingAccuracy: 0.0,
|
||||
);
|
||||
} else if (position == null) {
|
||||
position = await LocationService.determinePosition().timeout(const Duration(seconds: 2));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
setState(() {
|
||||
_isCapturing = false;
|
||||
});
|
||||
|
||||
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error picking image from gallery: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void navigateToPreview(String imagePath, {double? latitude, double? longitude}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ImagePreviewScreen(
|
||||
imagePath: imagePath,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppConfig.backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: Text(activeMode == 'DO' ? 'Pemindai Dokumen OCR' : 'Pemindai Produk PFM'),
|
||||
),
|
||||
drawer: const CameraDrawer(),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Upper Illustration Card
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusXL),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: AppConfig.paddingXL,
|
||||
child: SingleChildScrollView(
|
||||
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.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
|
||||
// Action Buttons
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isCapturing ? null : _takePicture,
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('Ambil Foto Kamera'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isCapturing ? null : _pickFromGallery,
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: const Text('Pilih Dari Galeri'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
side: const BorderSide(color: AppConfig.primaryColor),
|
||||
foregroundColor: AppConfig.primaryColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
),
|
||||
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
TextButton.icon(
|
||||
onPressed: _isCapturing ? null : () => context.push('/documents'),
|
||||
icon: const Icon(Icons.history_edu),
|
||||
label: const Text('Riwayat Dokumen'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppConfig.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGuidelineRow(IconData icon, String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppConfig.secondaryColor),
|
||||
const SizedBox(width: AppConfig.spaceSmall),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../../core/location/location_service.dart';
|
||||
import '../../core/utils/exif_helper.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'image_preview_screen.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
import 'camera_drawer.dart';
|
||||
|
||||
class CameraScreen extends ConsumerStatefulWidget {
|
||||
const CameraScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CameraScreen> createState() => CameraScreenState();
|
||||
}
|
||||
|
||||
class CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
bool _isCapturing = false;
|
||||
Position? _currentPosition;
|
||||
StreamSubscription<Position>? _locationSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_preFetchLocation();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_locationSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _preFetchLocation() async {
|
||||
try {
|
||||
final pos = await LocationService.determinePosition();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = pos;
|
||||
});
|
||||
}
|
||||
|
||||
// Live location updates
|
||||
_locationSubscription = LocationService.getPositionStream().listen(
|
||||
(Position freshPos) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = freshPos;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _takePicture() async {
|
||||
if (_isCapturing) return;
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
});
|
||||
|
||||
// Use pre-fetched location if available, otherwise fetch quick position
|
||||
Position? position = _currentPosition;
|
||||
if (position == null) {
|
||||
try {
|
||||
position = await LocationService.determinePosition().timeout(const Duration(seconds: 2));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isCapturing = false;
|
||||
});
|
||||
|
||||
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error taking picture: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFromGallery() async {
|
||||
if (_isCapturing) return;
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
});
|
||||
|
||||
Position? position = _currentPosition;
|
||||
try {
|
||||
// Attempt to extract GPS from EXIF tags
|
||||
final exifGps = await ExifHelper.extractGps(image.path);
|
||||
if (exifGps != null) {
|
||||
position = Position(
|
||||
latitude: exifGps['latitude']!,
|
||||
longitude: exifGps['longitude']!,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 0.0,
|
||||
altitude: 0.0,
|
||||
heading: 0.0,
|
||||
speed: 0.0,
|
||||
speedAccuracy: 0.0,
|
||||
altitudeAccuracy: 0.0,
|
||||
headingAccuracy: 0.0,
|
||||
);
|
||||
} else if (position == null) {
|
||||
position = await LocationService.determinePosition().timeout(const Duration(seconds: 2));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
setState(() {
|
||||
_isCapturing = false;
|
||||
});
|
||||
|
||||
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error picking image from gallery: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void navigateToPreview(String imagePath, {double? latitude, double? longitude}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ImagePreviewScreen(
|
||||
imagePath: imagePath,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppConfig.backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: Text(activeMode == 'DO' ? 'Pemindai Dokumen OCR' : 'Pemindai Produk PFM'),
|
||||
),
|
||||
drawer: const CameraDrawer(),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Upper Illustration Card
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusXL),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: AppConfig.paddingXL,
|
||||
child: SingleChildScrollView(
|
||||
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.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
|
||||
// Action Buttons
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isCapturing ? null : _takePicture,
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('Ambil Foto Kamera'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isCapturing ? null : _pickFromGallery,
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: const Text('Pilih Dari Galeri'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
side: const BorderSide(color: AppConfig.primaryColor),
|
||||
foregroundColor: AppConfig.primaryColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
),
|
||||
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
TextButton.icon(
|
||||
onPressed: _isCapturing ? null : () => context.push('/documents'),
|
||||
icon: const Icon(Icons.history_edu),
|
||||
label: const Text('Riwayat Dokumen'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppConfig.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGuidelineRow(IconData icon, String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppConfig.secondaryColor),
|
||||
const SizedBox(width: AppConfig.spaceSmall),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,380 +1,380 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import 'blur_detector.dart';
|
||||
|
||||
class DottedVerticalLine extends StatelessWidget {
|
||||
final double height;
|
||||
final Color color;
|
||||
|
||||
const DottedVerticalLine({super.key, required this.height, this.color = Colors.black45});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate((height / 4).floor(), (index) => Container(
|
||||
width: 1.5,
|
||||
height: 2,
|
||||
color: color,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BlurScoreBar extends StatelessWidget {
|
||||
final double score;
|
||||
final double threshold = 80.0;
|
||||
final double maxScore = 100.0;
|
||||
|
||||
const BlurScoreBar({super.key, required this.score});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double displayScore = score;
|
||||
const double displayThreshold = 80.0;
|
||||
const double maxDisplayScore = 100.0;
|
||||
|
||||
final double normalizedScore = displayScore / maxDisplayScore;
|
||||
final double thresholdRatio = displayThreshold / maxDisplayScore;
|
||||
final bool isSharp = displayScore >= displayThreshold;
|
||||
final Color barColor = isSharp ? AppConfig.successColor : AppConfig.errorColor;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Ketajaman Foto', style: AppConfig.bodyMedium.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
'${displayScore.toStringAsFixed(1)} / ${maxDisplayScore.toInt()}',
|
||||
style: AppConfig.bodyMedium.copyWith(color: barColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double totalWidth = constraints.maxWidth;
|
||||
final double thresholdPosition = totalWidth * thresholdRatio;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 1. Progress Bar Track
|
||||
Container(
|
||||
height: 20,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
// 2. Active Filled Bar
|
||||
FractionallySizedBox(
|
||||
widthFactor: normalizedScore,
|
||||
child: Container(
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: barColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 3. Vertical Dotted Threshold Line
|
||||
Positioned(
|
||||
left: thresholdPosition,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: const DottedVerticalLine(height: 20, color: Colors.black45),
|
||||
),
|
||||
// 4. Threshold Label
|
||||
Positioned(
|
||||
left: thresholdPosition - 40,
|
||||
top: 22,
|
||||
child: Text(
|
||||
'Batas Min (${displayThreshold.toInt()})',
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewScreen extends ConsumerStatefulWidget {
|
||||
final String imagePath;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
const ImagePreviewScreen({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ImagePreviewScreen> createState() => _ImagePreviewScreenState();
|
||||
}
|
||||
|
||||
class _ImagePreviewScreenState extends ConsumerState<ImagePreviewScreen> {
|
||||
bool _analyzing = true;
|
||||
BlurResult? _blurResult;
|
||||
late String _currentImagePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentImagePath = widget.imagePath;
|
||||
_detectBlur();
|
||||
}
|
||||
|
||||
Future<void> _detectBlur() async {
|
||||
try {
|
||||
final result = await BlurDetector.checkBlur(_currentImagePath);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_blurResult = result;
|
||||
_analyzing = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_analyzing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppConfig.backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text('Konfirmasi Foto'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Interactive photo view (with pinch-to-zoom support)
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
)
|
||||
],
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InteractiveViewer(
|
||||
maxScale: 4.0,
|
||||
child: Center(
|
||||
child: Hero(
|
||||
tag: 'doc_image_preview',
|
||||
child: Image.file(
|
||||
File(_currentImagePath),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom confirmation box
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, -5),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Periksa Kualitas Foto',
|
||||
style: AppConfig.h2.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Blur status area
|
||||
if (_analyzing)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppConfig.primaryColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Menganalisis ketajaman foto...',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (_blurResult != null) ...[
|
||||
BlurScoreBar(score: _blurResult!.score),
|
||||
_buildBlurBadge(_blurResult!)
|
||||
] else
|
||||
Text(
|
||||
'Pastikan seluruh area dokumen masuk ke dalam frame, tulisan dapat terbaca dengan jelas, dan hasil foto tidak blur.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Retake Button
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppConfig.textPrimary,
|
||||
side: BorderSide(color: Colors.grey.shade400),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('Ambil Ulang', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Confirm & Sync Button
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: !_analyzing ? () {
|
||||
ref.read(pendingDocumentsProvider.notifier).addDocument(
|
||||
_currentImagePath,
|
||||
latitude: widget.latitude,
|
||||
longitude: widget.longitude,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
context.go('/documents');
|
||||
} : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppConfig.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('Unggah Dokumen', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlurBadge(BlurResult result) {
|
||||
if (result.isBlur) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.errorColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppConfig.errorColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: AppConfig.errorColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Foto Terdeteksi Blur!',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.errorColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Diharuskan untuk mengambil ulang gambar.',
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.successColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppConfig.successColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, color: AppConfig.successColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Kualitas Foto Baik',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.successColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Fokus dan ketajaman gambar optimal.',
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import 'blur_detector.dart';
|
||||
|
||||
class DottedVerticalLine extends StatelessWidget {
|
||||
final double height;
|
||||
final Color color;
|
||||
|
||||
const DottedVerticalLine({super.key, required this.height, this.color = Colors.black45});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate((height / 4).floor(), (index) => Container(
|
||||
width: 1.5,
|
||||
height: 2,
|
||||
color: color,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BlurScoreBar extends StatelessWidget {
|
||||
final double score;
|
||||
final double threshold = 80.0;
|
||||
final double maxScore = 100.0;
|
||||
|
||||
const BlurScoreBar({super.key, required this.score});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double displayScore = score;
|
||||
const double displayThreshold = 80.0;
|
||||
const double maxDisplayScore = 100.0;
|
||||
|
||||
final double normalizedScore = displayScore / maxDisplayScore;
|
||||
final double thresholdRatio = displayThreshold / maxDisplayScore;
|
||||
final bool isSharp = displayScore >= displayThreshold;
|
||||
final Color barColor = isSharp ? AppConfig.successColor : AppConfig.errorColor;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Ketajaman Foto', style: AppConfig.bodyMedium.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
'${displayScore.toStringAsFixed(1)} / ${maxDisplayScore.toInt()}',
|
||||
style: AppConfig.bodyMedium.copyWith(color: barColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double totalWidth = constraints.maxWidth;
|
||||
final double thresholdPosition = totalWidth * thresholdRatio;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 1. Progress Bar Track
|
||||
Container(
|
||||
height: 20,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
// 2. Active Filled Bar
|
||||
FractionallySizedBox(
|
||||
widthFactor: normalizedScore,
|
||||
child: Container(
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: barColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 3. Vertical Dotted Threshold Line
|
||||
Positioned(
|
||||
left: thresholdPosition,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: const DottedVerticalLine(height: 20, color: Colors.black45),
|
||||
),
|
||||
// 4. Threshold Label
|
||||
Positioned(
|
||||
left: thresholdPosition - 40,
|
||||
top: 22,
|
||||
child: Text(
|
||||
'Batas Min (${displayThreshold.toInt()})',
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewScreen extends ConsumerStatefulWidget {
|
||||
final String imagePath;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
const ImagePreviewScreen({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ImagePreviewScreen> createState() => _ImagePreviewScreenState();
|
||||
}
|
||||
|
||||
class _ImagePreviewScreenState extends ConsumerState<ImagePreviewScreen> {
|
||||
bool _analyzing = true;
|
||||
BlurResult? _blurResult;
|
||||
late String _currentImagePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentImagePath = widget.imagePath;
|
||||
_detectBlur();
|
||||
}
|
||||
|
||||
Future<void> _detectBlur() async {
|
||||
try {
|
||||
final result = await BlurDetector.checkBlur(_currentImagePath);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_blurResult = result;
|
||||
_analyzing = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_analyzing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppConfig.backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text('Konfirmasi Foto'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Interactive photo view (with pinch-to-zoom support)
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
)
|
||||
],
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InteractiveViewer(
|
||||
maxScale: 4.0,
|
||||
child: Center(
|
||||
child: Hero(
|
||||
tag: 'doc_image_preview',
|
||||
child: Image.file(
|
||||
File(_currentImagePath),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom confirmation box
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, -5),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Periksa Kualitas Foto',
|
||||
style: AppConfig.h2.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Blur status area
|
||||
if (_analyzing)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppConfig.primaryColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Menganalisis ketajaman foto...',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (_blurResult != null) ...[
|
||||
BlurScoreBar(score: _blurResult!.score),
|
||||
_buildBlurBadge(_blurResult!)
|
||||
] else
|
||||
Text(
|
||||
'Pastikan seluruh area dokumen masuk ke dalam frame, tulisan dapat terbaca dengan jelas, dan hasil foto tidak blur.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Retake Button
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppConfig.textPrimary,
|
||||
side: BorderSide(color: Colors.grey.shade400),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('Ambil Ulang', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Confirm & Sync Button
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: !_analyzing ? () {
|
||||
ref.read(pendingDocumentsProvider.notifier).addDocument(
|
||||
_currentImagePath,
|
||||
latitude: widget.latitude,
|
||||
longitude: widget.longitude,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
context.go('/documents');
|
||||
} : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppConfig.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('Unggah Dokumen', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlurBadge(BlurResult result) {
|
||||
if (result.isBlur) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.errorColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppConfig.errorColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: AppConfig.errorColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Foto Terdeteksi Blur!',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.errorColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Diharuskan untuk mengambil ulang gambar.',
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.successColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppConfig.successColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, color: AppConfig.successColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Kualitas Foto Baik',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.successColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Fokus dan ketajaman gambar optimal.',
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final scanModeProvider = StateProvider<String>((ref) => 'DO');
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final scanModeProvider = StateProvider<String>((ref) => 'DO');
|
||||
@@ -1,163 +1,163 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
class DocumentCard extends StatelessWidget {
|
||||
final DocumentModel doc;
|
||||
final Future<void> Function(DocumentModel) onPrint;
|
||||
final bool isUnsynced;
|
||||
|
||||
const DocumentCard({
|
||||
super.key,
|
||||
required this.doc,
|
||||
required this.onPrint,
|
||||
this.isUnsynced = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isProductScan = doc.docType == 'Product';
|
||||
|
||||
// Header category text
|
||||
final categoryText = isProductScan
|
||||
? (doc.items.isNotEmpty ? doc.items.first.namaBarang : 'Product Scan')
|
||||
: (doc.namaPenerima.isNotEmpty ? doc.namaPenerima : 'Staff Toko');
|
||||
|
||||
// Category color: Product → green (primaryColor), DO → orange (doModeColor).
|
||||
// doModeColor is the shared constant for the DO orange cue across the app
|
||||
// (camera drawer, tab switcher, and here) — was an ad-hoc inline value.
|
||||
final categoryColor = isProductScan ? AppConfig.primaryColor : AppConfig.doModeColor;
|
||||
|
||||
// Sync status badge (see docs/api-contract-map.md G6 - a document can be
|
||||
// corrected locally after its PUT sync failed; the corrected copy is
|
||||
// shown here, so the badge must reflect that it hasn't reached the
|
||||
// server yet rather than always claiming "Terkonfirmasi").
|
||||
final badgeLabel = isUnsynced ? 'Belum Tersinkron' : 'Terkonfirmasi';
|
||||
final badgeColor = isUnsynced ? Colors.deepOrange : AppConfig.successColor;
|
||||
|
||||
// Bold title
|
||||
final boldTitle = isProductScan
|
||||
? doc.noDo
|
||||
: doc.noPo;
|
||||
|
||||
// Subtitle text. Product Scan no longer links to a PO document (field
|
||||
// removed - user request), so its subtitle is just the expiry date.
|
||||
final subtitleText = isProductScan
|
||||
? _formatDateString(doc.alamat.replaceAll("Product Scan: Expired ", ""))
|
||||
: '${doc.tanggal} • ${doc.items.length} Item';
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: AppConfig.spaceMedium),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
side: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => onPrint(doc),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
child: Padding(
|
||||
padding: AppConfig.paddingLarge,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Top category label
|
||||
Text(
|
||||
categoryText,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: categoryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXS),
|
||||
// Large bold title
|
||||
Text(
|
||||
boldTitle,
|
||||
style: AppConfig.bodyLarge.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppConfig.textPrimary,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXS),
|
||||
// Subtitle details
|
||||
Text(
|
||||
subtitleText,
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
color: AppConfig.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
// Right column with status badge and print button
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Status badge (Terkonfirmasi / Belum Tersinkron)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: badgeColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
// Print button styled as light-gray square container with print icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.print,
|
||||
size: 18,
|
||||
color: AppConfig.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateString(String input) {
|
||||
try {
|
||||
if (input.contains('/')) {
|
||||
final parts = input.split('/');
|
||||
if (parts.length == 3) {
|
||||
final day = int.parse(parts[0]);
|
||||
final month = int.parse(parts[1]);
|
||||
final year = int.parse(parts[2]);
|
||||
final date = DateTime(year, month, day);
|
||||
return DateFormat('dd MMMM yyyy').format(date);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
}
|
||||
return input;
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
class DocumentCard extends StatelessWidget {
|
||||
final DocumentModel doc;
|
||||
final Future<void> Function(DocumentModel) onPrint;
|
||||
final bool isUnsynced;
|
||||
|
||||
const DocumentCard({
|
||||
super.key,
|
||||
required this.doc,
|
||||
required this.onPrint,
|
||||
this.isUnsynced = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isProductScan = doc.docType == 'Product';
|
||||
|
||||
// Header category text
|
||||
final categoryText = isProductScan
|
||||
? (doc.items.isNotEmpty ? doc.items.first.namaBarang : 'Product Scan')
|
||||
: (doc.namaPenerima.isNotEmpty ? doc.namaPenerima : 'Staff Toko');
|
||||
|
||||
// Category color: Product → green (primaryColor), DO → orange (doModeColor).
|
||||
// doModeColor is the shared constant for the DO orange cue across the app
|
||||
// (camera drawer, tab switcher, and here) — was an ad-hoc inline value.
|
||||
final categoryColor = isProductScan ? AppConfig.primaryColor : AppConfig.doModeColor;
|
||||
|
||||
// Sync status badge (see docs/api-contract-map.md G6 - a document can be
|
||||
// corrected locally after its PUT sync failed; the corrected copy is
|
||||
// shown here, so the badge must reflect that it hasn't reached the
|
||||
// server yet rather than always claiming "Terkonfirmasi").
|
||||
final badgeLabel = isUnsynced ? 'Belum Tersinkron' : 'Terkonfirmasi';
|
||||
final badgeColor = isUnsynced ? Colors.deepOrange : AppConfig.successColor;
|
||||
|
||||
// Bold title
|
||||
final boldTitle = isProductScan
|
||||
? doc.noDo
|
||||
: doc.noPo;
|
||||
|
||||
// Subtitle text. Product Scan no longer links to a PO document (field
|
||||
// removed - user request), so its subtitle is just the expiry date.
|
||||
final subtitleText = isProductScan
|
||||
? _formatDateString(doc.alamat.replaceAll("Product Scan: Expired ", ""))
|
||||
: '${doc.tanggal} • ${doc.items.length} Item';
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: AppConfig.spaceMedium),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
side: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => onPrint(doc),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
child: Padding(
|
||||
padding: AppConfig.paddingLarge,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Top category label
|
||||
Text(
|
||||
categoryText,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: categoryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXS),
|
||||
// Large bold title
|
||||
Text(
|
||||
boldTitle,
|
||||
style: AppConfig.bodyLarge.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppConfig.textPrimary,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXS),
|
||||
// Subtitle details
|
||||
Text(
|
||||
subtitleText,
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
color: AppConfig.textSecondary,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
// Right column with status badge and print button
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Status badge (Terkonfirmasi / Belum Tersinkron)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: badgeColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
// Print button styled as light-gray square container with print icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.print,
|
||||
size: 18,
|
||||
color: AppConfig.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateString(String input) {
|
||||
try {
|
||||
if (input.contains('/')) {
|
||||
final parts = input.split('/');
|
||||
if (parts.length == 3) {
|
||||
final day = int.parse(parts[0]);
|
||||
final month = int.parse(parts[1]);
|
||||
final year = int.parse(parts[2]);
|
||||
final date = DateTime(year, month, day);
|
||||
return DateFormat('dd MMMM yyyy').format(date);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
}
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,33 @@
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
/// Result of merging the server's document list with local corrections that
|
||||
/// haven't made it back to the server yet.
|
||||
class MergedDocuments {
|
||||
final List<DocumentModel> docs;
|
||||
final Set<String> unsyncedIds;
|
||||
|
||||
const MergedDocuments(this.docs, this.unsyncedIds);
|
||||
}
|
||||
|
||||
/// Merges the server's authoritative document list with local corrections for
|
||||
/// documents whose `PUT` sync failed (`syncFailed` in the pending queue).
|
||||
///
|
||||
/// Without this, refreshing the documents list would silently overwrite a
|
||||
/// driver's corrected-but-unsynced edit with the server's stale pre-edit copy
|
||||
/// on every successful fetch - the correction is still recoverable via the
|
||||
/// pending queue's retry action, but the history list would show the wrong
|
||||
/// data (or the document would vanish from it entirely) until the retry
|
||||
/// succeeds. `unsyncedOverrides` wins over `serverDocs` for matching ids, and
|
||||
/// is kept even if the server list doesn't (yet) include that id at all.
|
||||
MergedDocuments mergeDocumentsWithUnsyncedOverrides({
|
||||
required List<DocumentModel> serverDocs,
|
||||
required Map<String, DocumentModel> unsyncedOverrides,
|
||||
}) {
|
||||
final merged = <String, DocumentModel>{
|
||||
for (final doc in serverDocs) doc.id: doc,
|
||||
};
|
||||
for (final entry in unsyncedOverrides.entries) {
|
||||
merged[entry.key] = entry.value;
|
||||
}
|
||||
return MergedDocuments(merged.values.toList(), unsyncedOverrides.keys.toSet());
|
||||
}
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
/// Result of merging the server's document list with local corrections that
|
||||
/// haven't made it back to the server yet.
|
||||
class MergedDocuments {
|
||||
final List<DocumentModel> docs;
|
||||
final Set<String> unsyncedIds;
|
||||
|
||||
const MergedDocuments(this.docs, this.unsyncedIds);
|
||||
}
|
||||
|
||||
/// Merges the server's authoritative document list with local corrections for
|
||||
/// documents whose `PUT` sync failed (`syncFailed` in the pending queue).
|
||||
///
|
||||
/// Without this, refreshing the documents list would silently overwrite a
|
||||
/// driver's corrected-but-unsynced edit with the server's stale pre-edit copy
|
||||
/// on every successful fetch - the correction is still recoverable via the
|
||||
/// pending queue's retry action, but the history list would show the wrong
|
||||
/// data (or the document would vanish from it entirely) until the retry
|
||||
/// succeeds. `unsyncedOverrides` wins over `serverDocs` for matching ids, and
|
||||
/// is kept even if the server list doesn't (yet) include that id at all.
|
||||
MergedDocuments mergeDocumentsWithUnsyncedOverrides({
|
||||
required List<DocumentModel> serverDocs,
|
||||
required Map<String, DocumentModel> unsyncedOverrides,
|
||||
}) {
|
||||
final merged = <String, DocumentModel>{
|
||||
for (final doc in serverDocs) doc.id: doc,
|
||||
};
|
||||
for (final entry in unsyncedOverrides.entries) {
|
||||
merged[entry.key] = entry.value;
|
||||
}
|
||||
return MergedDocuments(merged.values.toList(), unsyncedOverrides.keys.toSet());
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
class DocumentsEmptyState extends StatelessWidget {
|
||||
const DocumentsEmptyState({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_open,
|
||||
size: 64,
|
||||
color: AppConfig.textSecondary.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Text('Tidak ada dokumen ditemukan', style: AppConfig.bodyLarge),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
class DocumentsEmptyState extends StatelessWidget {
|
||||
const DocumentsEmptyState({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_open,
|
||||
size: 64,
|
||||
color: AppConfig.textSecondary.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Text('Tidak ada dokumen ditemukan', style: AppConfig.bodyLarge),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,224 +1,224 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../main.dart';
|
||||
import 'pending_documents_provider.dart';
|
||||
import '../editor/pdf_service.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import 'pending_document_card.dart';
|
||||
import 'document_card.dart';
|
||||
import '../camera/scan_mode_provider.dart';
|
||||
import 'documents_tab_switcher.dart';
|
||||
import 'documents_empty_state.dart';
|
||||
import 'document_sync_merge.dart';
|
||||
|
||||
class DocumentsScreen extends ConsumerStatefulWidget {
|
||||
const DocumentsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DocumentsScreen> createState() => _DocumentsScreenState();
|
||||
}
|
||||
|
||||
class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
List<DocumentModel> _allDocuments = [];
|
||||
List<DocumentModel> _filteredDocuments = [];
|
||||
Set<String> _unsyncedDocIds = {};
|
||||
// _selectedTab removed in task 8.1 -- scanModeProvider is the single source
|
||||
// of truth for the active tab, shared with CameraDrawer / CameraDrawerModeToggle.
|
||||
// Caching it locally (and seeding from the provider in initState) caused mode
|
||||
// desync when the camera drawer changed the mode after this screen was open.
|
||||
// See docs/api-contract-map.md G11, plans/next-enhancements.md §8.1.
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDocuments();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
Future<void> _loadDocuments() async {
|
||||
final localStorage = ref.read(localStorageProvider);
|
||||
final docs = localStorage.getAllDocuments();
|
||||
|
||||
setState(() {
|
||||
_allDocuments = docs;
|
||||
_filteredDocuments = docs;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final response = await apiClient.client.get(AppConfig.fetchDocumentsEndpoint);
|
||||
final List serverData = response.data['data'] ?? [];
|
||||
final List<DocumentModel> serverDocs = serverData
|
||||
.map((json) => DocumentModel.fromJson(Map<String, dynamic>.from(json)))
|
||||
.toList();
|
||||
|
||||
// A document whose editor save PUT failed (syncFailed) still has its
|
||||
// corrected version saved locally - don't let the server's stale
|
||||
// pre-edit copy silently overwrite it on refresh (see
|
||||
// docs/api-contract-map.md G6).
|
||||
final unsyncedOverrides = <String, DocumentModel>{
|
||||
for (final pending in ref.read(pendingDocumentsProvider))
|
||||
if (pending.status == PendingDocumentStatus.syncFailed && pending.document != null)
|
||||
pending.document!.id: pending.document!,
|
||||
};
|
||||
final merged = mergeDocumentsWithUnsyncedOverrides(
|
||||
serverDocs: serverDocs,
|
||||
unsyncedOverrides: unsyncedOverrides,
|
||||
);
|
||||
|
||||
await localStorage.documentBox.clear();
|
||||
for (var doc in merged.docs) {
|
||||
await localStorage.saveDocument(doc);
|
||||
}
|
||||
|
||||
final updatedDocs = localStorage.getAllDocuments();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_allDocuments = updatedDocs;
|
||||
_filteredDocuments = updatedDocs;
|
||||
_unsyncedDocIds = merged.unsyncedIds;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Offline or failed fetching: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredDocuments = _allDocuments.where((doc) {
|
||||
return doc.noDo.toLowerCase().contains(query) ||
|
||||
doc.noPo.toString().contains(query) ||
|
||||
doc.noSo.toString().contains(query) ||
|
||||
doc.tanggal.toLowerCase().contains(query) ||
|
||||
doc.kepadaYth.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _printReceipt(DocumentModel doc) async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Membuat PDF...'), duration: Duration(seconds: 1), backgroundColor: AppConfig.secondaryColor),
|
||||
);
|
||||
await PdfService.generateAndPrintReceipt(doc);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pendingDocs = ref.watch(pendingDocumentsProvider);
|
||||
// Single source of truth for the active tab -- shared with CameraDrawer
|
||||
// via scanModeProvider (task 8.1, docs/api-contract-map.md G11).
|
||||
final selectedTab = ref.watch(scanModeProvider);
|
||||
|
||||
final tabFilteredPendingDocs = pendingDocs.where((doc) {
|
||||
final isProduct = doc.scanMode == 'Product';
|
||||
if (selectedTab == 'DO') return !isProduct;
|
||||
if (selectedTab == 'Product') return isProduct;
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final tabFilteredDocs = _filteredDocuments.where((doc) {
|
||||
final isProduct = doc.docType == 'Product';
|
||||
if (selectedTab == 'DO') return !isProduct;
|
||||
if (selectedTab == 'Product') return isProduct;
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final Map<String, List<DocumentModel>> groupedDocs = {};
|
||||
for (var doc in tabFilteredDocs) {
|
||||
final date = doc.createdAt;
|
||||
final monthYearStr = DateFormat('MMMM yyyy').format(date);
|
||||
if (!groupedDocs.containsKey(monthYearStr)) {
|
||||
groupedDocs[monthYearStr] = [];
|
||||
}
|
||||
groupedDocs[monthYearStr]!.add(doc);
|
||||
}
|
||||
|
||||
final List<Widget> listItems = [];
|
||||
|
||||
if (tabFilteredPendingDocs.isNotEmpty) {
|
||||
listItems.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceSmall),
|
||||
child: Text('Tertunda & Diproses', style: AppConfig.h3),
|
||||
),
|
||||
);
|
||||
listItems.addAll(tabFilteredPendingDocs.map((doc) => PendingDocumentCard(
|
||||
doc: doc,
|
||||
onReload: () { if (mounted) _loadDocuments(); },
|
||||
)));
|
||||
listItems.add(const SizedBox(height: AppConfig.spaceLarge));
|
||||
listItems.add(const Divider());
|
||||
listItems.add(const SizedBox(height: AppConfig.spaceLarge));
|
||||
}
|
||||
|
||||
if (groupedDocs.isNotEmpty) {
|
||||
groupedDocs.forEach((monthYear, docs) {
|
||||
listItems.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: AppConfig.spaceLarge, bottom: AppConfig.spaceSmall),
|
||||
child: Text(
|
||||
monthYear,
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppConfig.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
listItems.addAll(docs.map((doc) => DocumentCard(
|
||||
doc: doc,
|
||||
onPrint: _printReceipt,
|
||||
isUnsynced: _unsyncedDocIds.contains(doc.id),
|
||||
)));
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Dokumen'),
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/camera')),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: AppConfig.paddingLarge,
|
||||
color: AppConfig.surfaceColor,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Cari berdasarkan No PO, No SO, DO, Tanggal...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
DocumentsTabSwitcher(
|
||||
selectedTab: selectedTab,
|
||||
onTabChanged: (tab) =>
|
||||
ref.read(scanModeProvider.notifier).state = tab,
|
||||
),
|
||||
Expanded(
|
||||
child: (tabFilteredPendingDocs.isEmpty && tabFilteredDocs.isEmpty)
|
||||
? const DocumentsEmptyState()
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge),
|
||||
children: listItems,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../main.dart';
|
||||
import 'pending_documents_provider.dart';
|
||||
import '../editor/pdf_service.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import 'pending_document_card.dart';
|
||||
import 'document_card.dart';
|
||||
import '../camera/scan_mode_provider.dart';
|
||||
import 'documents_tab_switcher.dart';
|
||||
import 'documents_empty_state.dart';
|
||||
import 'document_sync_merge.dart';
|
||||
|
||||
class DocumentsScreen extends ConsumerStatefulWidget {
|
||||
const DocumentsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DocumentsScreen> createState() => _DocumentsScreenState();
|
||||
}
|
||||
|
||||
class _DocumentsScreenState extends ConsumerState<DocumentsScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
List<DocumentModel> _allDocuments = [];
|
||||
List<DocumentModel> _filteredDocuments = [];
|
||||
Set<String> _unsyncedDocIds = {};
|
||||
// _selectedTab removed in task 8.1 -- scanModeProvider is the single source
|
||||
// of truth for the active tab, shared with CameraDrawer / CameraDrawerModeToggle.
|
||||
// Caching it locally (and seeding from the provider in initState) caused mode
|
||||
// desync when the camera drawer changed the mode after this screen was open.
|
||||
// See docs/api-contract-map.md G11, plans/next-enhancements.md §8.1.
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDocuments();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
Future<void> _loadDocuments() async {
|
||||
final localStorage = ref.read(localStorageProvider);
|
||||
final docs = localStorage.getAllDocuments();
|
||||
|
||||
setState(() {
|
||||
_allDocuments = docs;
|
||||
_filteredDocuments = docs;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final response = await apiClient.client.get(AppConfig.fetchDocumentsEndpoint);
|
||||
final List serverData = response.data['data'] ?? [];
|
||||
final List<DocumentModel> serverDocs = serverData
|
||||
.map((json) => DocumentModel.fromJson(Map<String, dynamic>.from(json)))
|
||||
.toList();
|
||||
|
||||
// A document whose editor save PUT failed (syncFailed) still has its
|
||||
// corrected version saved locally - don't let the server's stale
|
||||
// pre-edit copy silently overwrite it on refresh (see
|
||||
// docs/api-contract-map.md G6).
|
||||
final unsyncedOverrides = <String, DocumentModel>{
|
||||
for (final pending in ref.read(pendingDocumentsProvider))
|
||||
if (pending.status == PendingDocumentStatus.syncFailed && pending.document != null)
|
||||
pending.document!.id: pending.document!,
|
||||
};
|
||||
final merged = mergeDocumentsWithUnsyncedOverrides(
|
||||
serverDocs: serverDocs,
|
||||
unsyncedOverrides: unsyncedOverrides,
|
||||
);
|
||||
|
||||
await localStorage.documentBox.clear();
|
||||
for (var doc in merged.docs) {
|
||||
await localStorage.saveDocument(doc);
|
||||
}
|
||||
|
||||
final updatedDocs = localStorage.getAllDocuments();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_allDocuments = updatedDocs;
|
||||
_filteredDocuments = updatedDocs;
|
||||
_unsyncedDocIds = merged.unsyncedIds;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Offline or failed fetching: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_filteredDocuments = _allDocuments.where((doc) {
|
||||
return doc.noDo.toLowerCase().contains(query) ||
|
||||
doc.noPo.toString().contains(query) ||
|
||||
doc.noSo.toString().contains(query) ||
|
||||
doc.tanggal.toLowerCase().contains(query) ||
|
||||
doc.kepadaYth.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _printReceipt(DocumentModel doc) async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Membuat PDF...'), duration: Duration(seconds: 1), backgroundColor: AppConfig.secondaryColor),
|
||||
);
|
||||
await PdfService.generateAndPrintReceipt(doc);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pendingDocs = ref.watch(pendingDocumentsProvider);
|
||||
// Single source of truth for the active tab -- shared with CameraDrawer
|
||||
// via scanModeProvider (task 8.1, docs/api-contract-map.md G11).
|
||||
final selectedTab = ref.watch(scanModeProvider);
|
||||
|
||||
final tabFilteredPendingDocs = pendingDocs.where((doc) {
|
||||
final isProduct = doc.scanMode == 'Product';
|
||||
if (selectedTab == 'DO') return !isProduct;
|
||||
if (selectedTab == 'Product') return isProduct;
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final tabFilteredDocs = _filteredDocuments.where((doc) {
|
||||
final isProduct = doc.docType == 'Product';
|
||||
if (selectedTab == 'DO') return !isProduct;
|
||||
if (selectedTab == 'Product') return isProduct;
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final Map<String, List<DocumentModel>> groupedDocs = {};
|
||||
for (var doc in tabFilteredDocs) {
|
||||
final date = doc.createdAt;
|
||||
final monthYearStr = DateFormat('MMMM yyyy').format(date);
|
||||
if (!groupedDocs.containsKey(monthYearStr)) {
|
||||
groupedDocs[monthYearStr] = [];
|
||||
}
|
||||
groupedDocs[monthYearStr]!.add(doc);
|
||||
}
|
||||
|
||||
final List<Widget> listItems = [];
|
||||
|
||||
if (tabFilteredPendingDocs.isNotEmpty) {
|
||||
listItems.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceSmall),
|
||||
child: Text('Tertunda & Diproses', style: AppConfig.h3),
|
||||
),
|
||||
);
|
||||
listItems.addAll(tabFilteredPendingDocs.map((doc) => PendingDocumentCard(
|
||||
doc: doc,
|
||||
onReload: () { if (mounted) _loadDocuments(); },
|
||||
)));
|
||||
listItems.add(const SizedBox(height: AppConfig.spaceLarge));
|
||||
listItems.add(const Divider());
|
||||
listItems.add(const SizedBox(height: AppConfig.spaceLarge));
|
||||
}
|
||||
|
||||
if (groupedDocs.isNotEmpty) {
|
||||
groupedDocs.forEach((monthYear, docs) {
|
||||
listItems.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: AppConfig.spaceLarge, bottom: AppConfig.spaceSmall),
|
||||
child: Text(
|
||||
monthYear,
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppConfig.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
listItems.addAll(docs.map((doc) => DocumentCard(
|
||||
doc: doc,
|
||||
onPrint: _printReceipt,
|
||||
isUnsynced: _unsyncedDocIds.contains(doc.id),
|
||||
)));
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Dokumen'),
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.go('/camera')),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: AppConfig.paddingLarge,
|
||||
color: AppConfig.surfaceColor,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Cari berdasarkan No PO, No SO, DO, Tanggal...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
DocumentsTabSwitcher(
|
||||
selectedTab: selectedTab,
|
||||
onTabChanged: (tab) =>
|
||||
ref.read(scanModeProvider.notifier).state = tab,
|
||||
),
|
||||
Expanded(
|
||||
child: (tabFilteredPendingDocs.isEmpty && tabFilteredDocs.isEmpty)
|
||||
? const DocumentsEmptyState()
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppConfig.spaceLarge),
|
||||
children: listItems,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
class DocumentsTabSwitcher extends StatelessWidget {
|
||||
final String selectedTab;
|
||||
final ValueChanged<String> onTabChanged;
|
||||
|
||||
const DocumentsTabSwitcher({
|
||||
super.key,
|
||||
required this.selectedTab,
|
||||
required this.onTabChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: AppConfig.spaceLarge,
|
||||
vertical: AppConfig.spaceSmall,
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTabItem('DO', 'DO Scan', Icons.description, Icons.description_outlined),
|
||||
_buildTabItem('Product', 'Product Scan', Icons.inventory_2, Icons.inventory_2_outlined),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabItem(String value, String label, IconData activeIcon, IconData inactiveIcon) {
|
||||
final isActive = selectedTab == value;
|
||||
// Color cue: DO mode -> doModeColor (orange), Product mode -> primaryColor (green).
|
||||
// Applies to both the icon and the label text (user feedback: "icon dan
|
||||
// juga button turn color sesuai warna statenya") - mirrors the same
|
||||
// icon+color pairing already used in camera_drawer_mode_toggle.dart and
|
||||
// document_card.dart's top-label (task 8.1, docs/api-contract-map.md G11).
|
||||
// Generic/default chrome elsewhere (search icon, print icon, tooltips)
|
||||
// deliberately stays untouched.
|
||||
final activeColor = value == 'DO' ? AppConfig.doModeColor : AppConfig.primaryColor;
|
||||
final color = isActive ? activeColor : AppConfig.textSecondary;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onTabChanged(value),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(isActive ? activeIcon : inactiveIcon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
class DocumentsTabSwitcher extends StatelessWidget {
|
||||
final String selectedTab;
|
||||
final ValueChanged<String> onTabChanged;
|
||||
|
||||
const DocumentsTabSwitcher({
|
||||
super.key,
|
||||
required this.selectedTab,
|
||||
required this.onTabChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: AppConfig.spaceLarge,
|
||||
vertical: AppConfig.spaceSmall,
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTabItem('DO', 'DO Scan', Icons.description, Icons.description_outlined),
|
||||
_buildTabItem('Product', 'Product Scan', Icons.inventory_2, Icons.inventory_2_outlined),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabItem(String value, String label, IconData activeIcon, IconData inactiveIcon) {
|
||||
final isActive = selectedTab == value;
|
||||
// Color cue: DO mode -> doModeColor (orange), Product mode -> primaryColor (green).
|
||||
// Applies to both the icon and the label text (user feedback: "icon dan
|
||||
// juga button turn color sesuai warna statenya") - mirrors the same
|
||||
// icon+color pairing already used in camera_drawer_mode_toggle.dart and
|
||||
// document_card.dart's top-label (task 8.1, docs/api-contract-map.md G11).
|
||||
// Generic/default chrome elsewhere (search icon, print icon, tooltips)
|
||||
// deliberately stays untouched.
|
||||
final activeColor = value == 'DO' ? AppConfig.doModeColor : AppConfig.primaryColor;
|
||||
final color = isActive ? activeColor : AppConfig.textSecondary;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onTabChanged(value),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(isActive ? activeIcon : inactiveIcon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,175 +1,175 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'pending_documents_provider.dart';
|
||||
|
||||
class PendingDocumentCard extends ConsumerWidget {
|
||||
final PendingDocument doc;
|
||||
final VoidCallback onReload;
|
||||
|
||||
const PendingDocumentCard({
|
||||
super.key,
|
||||
required this.doc,
|
||||
required this.onReload,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Color statusColor;
|
||||
String statusText;
|
||||
IconData statusIcon;
|
||||
|
||||
switch (doc.status) {
|
||||
case PendingDocumentStatus.uploading:
|
||||
case PendingDocumentStatus.processing:
|
||||
statusColor = Colors.orange;
|
||||
statusText = 'Sedang memproses dokumen...';
|
||||
statusIcon = Icons.cloud_upload;
|
||||
break;
|
||||
case PendingDocumentStatus.success:
|
||||
statusColor = AppConfig.successColor;
|
||||
statusText = 'Ketuk untuk dikonfirmasi';
|
||||
statusIcon = Icons.check_circle;
|
||||
break;
|
||||
case PendingDocumentStatus.error:
|
||||
statusColor = AppConfig.errorColor;
|
||||
statusText = 'Gagal Memproses';
|
||||
statusIcon = Icons.error;
|
||||
break;
|
||||
case PendingDocumentStatus.syncFailed:
|
||||
statusColor = Colors.deepOrange;
|
||||
statusText = 'Belum tersinkron ke server';
|
||||
statusIcon = Icons.sync_problem;
|
||||
break;
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: AppConfig.spaceMedium),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
side: BorderSide(color: statusColor.withOpacity(0.5)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (doc.status == PendingDocumentStatus.success) {
|
||||
final routePath = doc.scanMode == 'Product' ? '/product-editor' : '/editor';
|
||||
context.push(routePath, extra: doc.id).then((_) {
|
||||
onReload();
|
||||
});
|
||||
} else if (doc.status == PendingDocumentStatus.error) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text('Coba Unggah Ulang'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).retryUpload(doc.id);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: AppConfig.errorColor),
|
||||
title: const Text('Hapus Dokumen', style: TextStyle(color: AppConfig.errorColor)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).removeDocument(doc.id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (doc.status == PendingDocumentStatus.syncFailed) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.sync),
|
||||
title: const Text('Coba Sinkron Ulang'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).retrySync(doc.id);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: AppConfig.errorColor),
|
||||
title: const Text('Hapus Dokumen', style: TextStyle(color: AppConfig.errorColor)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).removeDocument(doc.id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
child: Padding(
|
||||
padding: AppConfig.paddingLarge,
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
child: Image.file(
|
||||
File(doc.imagePath),
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.image, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppConfig.spaceLarge),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(statusIcon, size: 16, color: statusColor),
|
||||
const SizedBox(width: AppConfig.spaceXS),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
style: AppConfig.bodyMedium.copyWith(color: statusColor, fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
if (doc.status == PendingDocumentStatus.uploading || doc.status == PendingDocumentStatus.processing)
|
||||
const LinearProgressIndicator(),
|
||||
if (doc.status == PendingDocumentStatus.success)
|
||||
Text('Ketuk untuk mereview dan konfirmasi data', style: AppConfig.bodySmall),
|
||||
if (doc.status == PendingDocumentStatus.error)
|
||||
Text(doc.errorMessage ?? 'Kesalahan tidak diketahui', style: AppConfig.bodySmall.copyWith(color: AppConfig.errorColor), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (doc.status == PendingDocumentStatus.syncFailed)
|
||||
Text(doc.errorMessage ?? 'Ketuk untuk coba sinkron ulang', style: AppConfig.bodySmall.copyWith(color: Colors.deepOrange), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (doc.status == PendingDocumentStatus.success)
|
||||
const Icon(Icons.chevron_right, color: AppConfig.primaryColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'pending_documents_provider.dart';
|
||||
|
||||
class PendingDocumentCard extends ConsumerWidget {
|
||||
final PendingDocument doc;
|
||||
final VoidCallback onReload;
|
||||
|
||||
const PendingDocumentCard({
|
||||
super.key,
|
||||
required this.doc,
|
||||
required this.onReload,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Color statusColor;
|
||||
String statusText;
|
||||
IconData statusIcon;
|
||||
|
||||
switch (doc.status) {
|
||||
case PendingDocumentStatus.uploading:
|
||||
case PendingDocumentStatus.processing:
|
||||
statusColor = Colors.orange;
|
||||
statusText = 'Sedang memproses dokumen...';
|
||||
statusIcon = Icons.cloud_upload;
|
||||
break;
|
||||
case PendingDocumentStatus.success:
|
||||
statusColor = AppConfig.successColor;
|
||||
statusText = 'Ketuk untuk dikonfirmasi';
|
||||
statusIcon = Icons.check_circle;
|
||||
break;
|
||||
case PendingDocumentStatus.error:
|
||||
statusColor = AppConfig.errorColor;
|
||||
statusText = 'Gagal Memproses';
|
||||
statusIcon = Icons.error;
|
||||
break;
|
||||
case PendingDocumentStatus.syncFailed:
|
||||
statusColor = Colors.deepOrange;
|
||||
statusText = 'Belum tersinkron ke server';
|
||||
statusIcon = Icons.sync_problem;
|
||||
break;
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: AppConfig.spaceMedium),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
side: BorderSide(color: statusColor.withOpacity(0.5)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (doc.status == PendingDocumentStatus.success) {
|
||||
final routePath = doc.scanMode == 'Product' ? '/product-editor' : '/editor';
|
||||
context.push(routePath, extra: doc.id).then((_) {
|
||||
onReload();
|
||||
});
|
||||
} else if (doc.status == PendingDocumentStatus.error) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text('Coba Unggah Ulang'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).retryUpload(doc.id);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: AppConfig.errorColor),
|
||||
title: const Text('Hapus Dokumen', style: TextStyle(color: AppConfig.errorColor)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).removeDocument(doc.id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (doc.status == PendingDocumentStatus.syncFailed) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.sync),
|
||||
title: const Text('Coba Sinkron Ulang'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).retrySync(doc.id);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: AppConfig.errorColor),
|
||||
title: const Text('Hapus Dokumen', style: TextStyle(color: AppConfig.errorColor)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(pendingDocumentsProvider.notifier).removeDocument(doc.id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
child: Padding(
|
||||
padding: AppConfig.paddingLarge,
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
child: Image.file(
|
||||
File(doc.imagePath),
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.image, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppConfig.spaceLarge),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(statusIcon, size: 16, color: statusColor),
|
||||
const SizedBox(width: AppConfig.spaceXS),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
style: AppConfig.bodyMedium.copyWith(color: statusColor, fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
if (doc.status == PendingDocumentStatus.uploading || doc.status == PendingDocumentStatus.processing)
|
||||
const LinearProgressIndicator(),
|
||||
if (doc.status == PendingDocumentStatus.success)
|
||||
Text('Ketuk untuk mereview dan konfirmasi data', style: AppConfig.bodySmall),
|
||||
if (doc.status == PendingDocumentStatus.error)
|
||||
Text(doc.errorMessage ?? 'Kesalahan tidak diketahui', style: AppConfig.bodySmall.copyWith(color: AppConfig.errorColor), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (doc.status == PendingDocumentStatus.syncFailed)
|
||||
Text(doc.errorMessage ?? 'Ketuk untuk coba sinkron ulang', style: AppConfig.bodySmall.copyWith(color: Colors.deepOrange), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (doc.status == PendingDocumentStatus.success)
|
||||
const Icon(Icons.chevron_right, color: AppConfig.primaryColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,87 @@
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
enum PendingDocumentStatus { uploading, processing, success, error, syncFailed }
|
||||
|
||||
class PendingDocument {
|
||||
final String id;
|
||||
final String imagePath;
|
||||
final PendingDocumentStatus status;
|
||||
final DocumentModel? document;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? errorMessage;
|
||||
final DateTime createdAt;
|
||||
final String? scanMode;
|
||||
|
||||
PendingDocument({
|
||||
required this.id,
|
||||
required this.imagePath,
|
||||
this.status = PendingDocumentStatus.uploading,
|
||||
this.document,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.errorMessage,
|
||||
DateTime? createdAt,
|
||||
this.scanMode,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
PendingDocument copyWith({
|
||||
PendingDocumentStatus? status,
|
||||
DocumentModel? document,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? errorMessage,
|
||||
String? scanMode,
|
||||
}) {
|
||||
return PendingDocument(
|
||||
id: id,
|
||||
imagePath: imagePath,
|
||||
status: status ?? this.status,
|
||||
document: document ?? this.document,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
errorMessage: errorMessage,
|
||||
createdAt: createdAt,
|
||||
scanMode: scanMode ?? this.scanMode,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'image_path': imagePath,
|
||||
'status': status.name,
|
||||
'document': document?.toJson(),
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'error_message': errorMessage,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'scan_mode': scanMode,
|
||||
};
|
||||
}
|
||||
|
||||
factory PendingDocument.fromJson(Map<String, dynamic> json) {
|
||||
PendingDocumentStatus status;
|
||||
try {
|
||||
status = PendingDocumentStatus.values.byName(json['status'] as String? ?? '');
|
||||
} catch (_) {
|
||||
status = PendingDocumentStatus.error;
|
||||
}
|
||||
|
||||
final rawDocument = json['document'];
|
||||
|
||||
return PendingDocument(
|
||||
id: json['id'] as String,
|
||||
imagePath: json['image_path'] as String,
|
||||
status: status,
|
||||
document: rawDocument is Map
|
||||
? DocumentModel.fromJson(Map<String, dynamic>.from(rawDocument))
|
||||
: null,
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
errorMessage: json['error_message'] as String?,
|
||||
createdAt: json['created_at'] != null ? DateTime.parse(json['created_at'] as String) : null,
|
||||
scanMode: json['scan_mode'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
enum PendingDocumentStatus { uploading, processing, success, error, syncFailed }
|
||||
|
||||
class PendingDocument {
|
||||
final String id;
|
||||
final String imagePath;
|
||||
final PendingDocumentStatus status;
|
||||
final DocumentModel? document;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? errorMessage;
|
||||
final DateTime createdAt;
|
||||
final String? scanMode;
|
||||
|
||||
PendingDocument({
|
||||
required this.id,
|
||||
required this.imagePath,
|
||||
this.status = PendingDocumentStatus.uploading,
|
||||
this.document,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.errorMessage,
|
||||
DateTime? createdAt,
|
||||
this.scanMode,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
PendingDocument copyWith({
|
||||
PendingDocumentStatus? status,
|
||||
DocumentModel? document,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? errorMessage,
|
||||
String? scanMode,
|
||||
}) {
|
||||
return PendingDocument(
|
||||
id: id,
|
||||
imagePath: imagePath,
|
||||
status: status ?? this.status,
|
||||
document: document ?? this.document,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
errorMessage: errorMessage,
|
||||
createdAt: createdAt,
|
||||
scanMode: scanMode ?? this.scanMode,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'image_path': imagePath,
|
||||
'status': status.name,
|
||||
'document': document?.toJson(),
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'error_message': errorMessage,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'scan_mode': scanMode,
|
||||
};
|
||||
}
|
||||
|
||||
factory PendingDocument.fromJson(Map<String, dynamic> json) {
|
||||
PendingDocumentStatus status;
|
||||
try {
|
||||
status = PendingDocumentStatus.values.byName(json['status'] as String? ?? '');
|
||||
} catch (_) {
|
||||
status = PendingDocumentStatus.error;
|
||||
}
|
||||
|
||||
final rawDocument = json['document'];
|
||||
|
||||
return PendingDocument(
|
||||
id: json['id'] as String,
|
||||
imagePath: json['image_path'] as String,
|
||||
status: status,
|
||||
document: rawDocument is Map
|
||||
? DocumentModel.fromJson(Map<String, dynamic>.from(rawDocument))
|
||||
: null,
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
errorMessage: json['error_message'] as String?,
|
||||
createdAt: json['created_at'] != null ? DateTime.parse(json['created_at'] as String) : null,
|
||||
scanMode: json['scan_mode'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,199 +1,199 @@
|
||||
export 'pending_document_model.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../core/location/location_service.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../main.dart';
|
||||
import '../camera/scan_mode_provider.dart';
|
||||
import 'pending_document_model.dart';
|
||||
import 'poll_outcome.dart';
|
||||
|
||||
class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
|
||||
PendingDocumentsNotifier(this._ref) : super([]) {
|
||||
_loadPendingDocuments();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
void _loadPendingDocuments() {
|
||||
final localStorage = _ref.read(localStorageProvider);
|
||||
final list = localStorage.getAllPendingDocuments()
|
||||
.map((e) => PendingDocument.fromJson(e))
|
||||
.toList();
|
||||
state = list;
|
||||
|
||||
for (var doc in list) {
|
||||
if (doc.status == PendingDocumentStatus.processing && doc.document != null) {
|
||||
_resumePolling(doc.id, doc.document!);
|
||||
} else if (doc.status == PendingDocumentStatus.uploading) {
|
||||
_uploadAndProcess(doc.id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _persistPendingDocument(PendingDocument doc) {
|
||||
_ref.read(localStorageProvider).savePendingDocument(doc.toJson());
|
||||
}
|
||||
|
||||
void _removePersistedPendingDocument(String id) {
|
||||
_ref.read(localStorageProvider).removePendingDocument(id);
|
||||
}
|
||||
|
||||
void _updateItem(String id, PendingDocument Function(PendingDocument) updateFn) {
|
||||
state = [for (var item in state) if (item.id == id) updateFn(item) else item];
|
||||
_persistPendingDocument(state.firstWhere((item) => item.id == id));
|
||||
}
|
||||
|
||||
void _updateItemInMemory(String id, PendingDocument Function(PendingDocument) updateFn) {
|
||||
state = [for (var item in state) if (item.id == id) updateFn(item) else item];
|
||||
}
|
||||
|
||||
void addDocument(String imagePath, {double? latitude, double? longitude}) {
|
||||
final id = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final scanMode = _ref.read(scanModeProvider);
|
||||
final newDoc = PendingDocument(
|
||||
id: id,
|
||||
imagePath: imagePath,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
scanMode: scanMode,
|
||||
);
|
||||
state = [newDoc, ...state];
|
||||
_persistPendingDocument(newDoc);
|
||||
_uploadAndProcess(id, imagePath, latitude: latitude, longitude: longitude);
|
||||
}
|
||||
|
||||
Future<void> _uploadAndProcess(String id, String imagePath, {double? latitude, double? longitude}) async {
|
||||
double? lat = latitude;
|
||||
double? lng = longitude;
|
||||
|
||||
if (lat == null || lng == null) {
|
||||
try {
|
||||
final position = await LocationService.determinePosition();
|
||||
if (position != null) {
|
||||
lat = position.latitude;
|
||||
lng = position.longitude;
|
||||
if (mounted) _updateItem(id, (item) => item.copyWith(latitude: lat, longitude: lng));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
try {
|
||||
final scanMode = _ref.read(scanModeProvider);
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
final formData = FormData.fromMap({
|
||||
'image': await MultipartFile.fromFile(imagePath, filename: imagePath.split(RegExp(r'[/\\]')).last),
|
||||
if (lat != null) 'latitude': lat.toString(),
|
||||
if (lng != null) 'longitude': lng.toString(),
|
||||
'scan_mode': scanMode,
|
||||
});
|
||||
|
||||
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
|
||||
final doc = DocumentModel.fromJson(response.data['data']);
|
||||
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.processing, document: doc));
|
||||
await _pollUntilParsed(id, doc);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumePolling(String id, DocumentModel doc) async {
|
||||
try {
|
||||
await _pollUntilParsed(id, doc);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollUntilParsed(String id, DocumentModel doc) async {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
DocumentModel? resultDoc;
|
||||
String? failureMessage;
|
||||
int retries = 0;
|
||||
const maxRetries = 130;
|
||||
|
||||
while (retries < maxRetries && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final getRes = await apiClient.client.get('${AppConfig.fetchDocumentsEndpoint}/${doc.id}');
|
||||
if (getRes.statusCode == 200 && getRes.data['data'] != null) {
|
||||
final fetched = DocumentModel.fromJson(Map<String, dynamic>.from(getRes.data['data']));
|
||||
final outcome = resolvePollOutcome(fetched);
|
||||
if (outcome.kind == PollOutcomeKind.done) {
|
||||
resultDoc = outcome.document;
|
||||
break;
|
||||
} else if (outcome.kind == PollOutcomeKind.failed) {
|
||||
failureMessage = outcome.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (resultDoc != null) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: resultDoc));
|
||||
} else {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: failureMessage ?? 'Gagal mengekstrak data (Timeout).'));
|
||||
}
|
||||
}
|
||||
|
||||
void removeDocument(String id) {
|
||||
state = state.where((item) => item.id != id).toList();
|
||||
_removePersistedPendingDocument(id);
|
||||
}
|
||||
|
||||
void markSyncFailed(String id, DocumentModel doc, String message) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.syncFailed, document: doc, errorMessage: message));
|
||||
}
|
||||
|
||||
Future<void> retrySync(String id) async {
|
||||
final matches = state.where((item) => item.id == id);
|
||||
if (matches.isEmpty) return;
|
||||
final doc = matches.first.document;
|
||||
if (doc == null) return;
|
||||
|
||||
_updateItemInMemory(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
|
||||
|
||||
try {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
await apiClient.client.put('${AppConfig.fetchDocumentsEndpoint}/${doc.id}', data: doc.toPutPayload());
|
||||
if (!mounted) return;
|
||||
removeDocument(id);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(
|
||||
status: PendingDocumentStatus.syncFailed,
|
||||
errorMessage: 'Gagal sinkronisasi ke server: ${ApiException.from(e).displayMessage}',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void retryUpload(String id) {
|
||||
final doc = state.firstWhere((item) => item.id == id);
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
|
||||
_uploadAndProcess(id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
state = const [];
|
||||
_ref.read(localStorageProvider).pendingDocumentsBox.clear();
|
||||
}
|
||||
}
|
||||
|
||||
final pendingDocumentsProvider = StateNotifierProvider<PendingDocumentsNotifier, List<PendingDocument>>((ref) {
|
||||
return PendingDocumentsNotifier(ref);
|
||||
});
|
||||
export 'pending_document_model.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../core/location/location_service.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../main.dart';
|
||||
import '../camera/scan_mode_provider.dart';
|
||||
import 'pending_document_model.dart';
|
||||
import 'poll_outcome.dart';
|
||||
|
||||
class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
|
||||
PendingDocumentsNotifier(this._ref) : super([]) {
|
||||
_loadPendingDocuments();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
void _loadPendingDocuments() {
|
||||
final localStorage = _ref.read(localStorageProvider);
|
||||
final list = localStorage.getAllPendingDocuments()
|
||||
.map((e) => PendingDocument.fromJson(e))
|
||||
.toList();
|
||||
state = list;
|
||||
|
||||
for (var doc in list) {
|
||||
if (doc.status == PendingDocumentStatus.processing && doc.document != null) {
|
||||
_resumePolling(doc.id, doc.document!);
|
||||
} else if (doc.status == PendingDocumentStatus.uploading) {
|
||||
_uploadAndProcess(doc.id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _persistPendingDocument(PendingDocument doc) {
|
||||
_ref.read(localStorageProvider).savePendingDocument(doc.toJson());
|
||||
}
|
||||
|
||||
void _removePersistedPendingDocument(String id) {
|
||||
_ref.read(localStorageProvider).removePendingDocument(id);
|
||||
}
|
||||
|
||||
void _updateItem(String id, PendingDocument Function(PendingDocument) updateFn) {
|
||||
state = [for (var item in state) if (item.id == id) updateFn(item) else item];
|
||||
_persistPendingDocument(state.firstWhere((item) => item.id == id));
|
||||
}
|
||||
|
||||
void _updateItemInMemory(String id, PendingDocument Function(PendingDocument) updateFn) {
|
||||
state = [for (var item in state) if (item.id == id) updateFn(item) else item];
|
||||
}
|
||||
|
||||
void addDocument(String imagePath, {double? latitude, double? longitude}) {
|
||||
final id = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final scanMode = _ref.read(scanModeProvider);
|
||||
final newDoc = PendingDocument(
|
||||
id: id,
|
||||
imagePath: imagePath,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
scanMode: scanMode,
|
||||
);
|
||||
state = [newDoc, ...state];
|
||||
_persistPendingDocument(newDoc);
|
||||
_uploadAndProcess(id, imagePath, latitude: latitude, longitude: longitude);
|
||||
}
|
||||
|
||||
Future<void> _uploadAndProcess(String id, String imagePath, {double? latitude, double? longitude}) async {
|
||||
double? lat = latitude;
|
||||
double? lng = longitude;
|
||||
|
||||
if (lat == null || lng == null) {
|
||||
try {
|
||||
final position = await LocationService.determinePosition();
|
||||
if (position != null) {
|
||||
lat = position.latitude;
|
||||
lng = position.longitude;
|
||||
if (mounted) _updateItem(id, (item) => item.copyWith(latitude: lat, longitude: lng));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
try {
|
||||
final scanMode = _ref.read(scanModeProvider);
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
final formData = FormData.fromMap({
|
||||
'image': await MultipartFile.fromFile(imagePath, filename: imagePath.split(RegExp(r'[/\\]')).last),
|
||||
if (lat != null) 'latitude': lat.toString(),
|
||||
if (lng != null) 'longitude': lng.toString(),
|
||||
'scan_mode': scanMode,
|
||||
});
|
||||
|
||||
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
|
||||
final doc = DocumentModel.fromJson(response.data['data']);
|
||||
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.processing, document: doc));
|
||||
await _pollUntilParsed(id, doc);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumePolling(String id, DocumentModel doc) async {
|
||||
try {
|
||||
await _pollUntilParsed(id, doc);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollUntilParsed(String id, DocumentModel doc) async {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
DocumentModel? resultDoc;
|
||||
String? failureMessage;
|
||||
int retries = 0;
|
||||
const maxRetries = 130;
|
||||
|
||||
while (retries < maxRetries && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final getRes = await apiClient.client.get('${AppConfig.fetchDocumentsEndpoint}/${doc.id}');
|
||||
if (getRes.statusCode == 200 && getRes.data['data'] != null) {
|
||||
final fetched = DocumentModel.fromJson(Map<String, dynamic>.from(getRes.data['data']));
|
||||
final outcome = resolvePollOutcome(fetched);
|
||||
if (outcome.kind == PollOutcomeKind.done) {
|
||||
resultDoc = outcome.document;
|
||||
break;
|
||||
} else if (outcome.kind == PollOutcomeKind.failed) {
|
||||
failureMessage = outcome.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (resultDoc != null) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: resultDoc));
|
||||
} else {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: failureMessage ?? 'Gagal mengekstrak data (Timeout).'));
|
||||
}
|
||||
}
|
||||
|
||||
void removeDocument(String id) {
|
||||
state = state.where((item) => item.id != id).toList();
|
||||
_removePersistedPendingDocument(id);
|
||||
}
|
||||
|
||||
void markSyncFailed(String id, DocumentModel doc, String message) {
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.syncFailed, document: doc, errorMessage: message));
|
||||
}
|
||||
|
||||
Future<void> retrySync(String id) async {
|
||||
final matches = state.where((item) => item.id == id);
|
||||
if (matches.isEmpty) return;
|
||||
final doc = matches.first.document;
|
||||
if (doc == null) return;
|
||||
|
||||
_updateItemInMemory(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
|
||||
|
||||
try {
|
||||
final apiClient = _ref.read(apiClientProvider);
|
||||
await apiClient.client.put('${AppConfig.fetchDocumentsEndpoint}/${doc.id}', data: doc.toPutPayload());
|
||||
if (!mounted) return;
|
||||
removeDocument(id);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_updateItem(id, (item) => item.copyWith(
|
||||
status: PendingDocumentStatus.syncFailed,
|
||||
errorMessage: 'Gagal sinkronisasi ke server: ${ApiException.from(e).displayMessage}',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void retryUpload(String id) {
|
||||
final doc = state.firstWhere((item) => item.id == id);
|
||||
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
|
||||
_uploadAndProcess(id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
state = const [];
|
||||
_ref.read(localStorageProvider).pendingDocumentsBox.clear();
|
||||
}
|
||||
}
|
||||
|
||||
final pendingDocumentsProvider = StateNotifierProvider<PendingDocumentsNotifier, List<PendingDocument>>((ref) {
|
||||
return PendingDocumentsNotifier(ref);
|
||||
});
|
||||
@@ -1,39 +1,39 @@
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
enum PollOutcomeKind { done, failed, pending }
|
||||
|
||||
/// Result of inspecting a single `GET /documents/:id` poll response.
|
||||
/// Deliberately separated from the Dio call site
|
||||
/// (`pending_documents_provider.dart`'s `_pollUntilParsed`) so the
|
||||
/// parse-status branching is unit-testable without mocking network I/O -
|
||||
/// mirrors the pure-function pattern already used for `document_sync_merge.dart`
|
||||
/// (task 6.2) and `product_scan_response_parser.dart` (task 7.1).
|
||||
class PollOutcome {
|
||||
final PollOutcomeKind kind;
|
||||
final DocumentModel? document;
|
||||
final String? message;
|
||||
|
||||
const PollOutcome._(this.kind, {this.document, this.message});
|
||||
|
||||
factory PollOutcome.done(DocumentModel document) =>
|
||||
PollOutcome._(PollOutcomeKind.done, document: document);
|
||||
|
||||
factory PollOutcome.failed(String message) =>
|
||||
PollOutcome._(PollOutcomeKind.failed, message: message);
|
||||
|
||||
static const PollOutcome pending = PollOutcome._(PollOutcomeKind.pending);
|
||||
}
|
||||
|
||||
/// `parseStatus` absent/unrecognized (a legacy/cached response predating
|
||||
/// backend task 9.1) is treated the same as `"pending"` - keep polling rather
|
||||
/// than surface a false failure.
|
||||
PollOutcome resolvePollOutcome(DocumentModel fetched) {
|
||||
switch (fetched.parseStatus) {
|
||||
case 'done':
|
||||
return PollOutcome.done(fetched);
|
||||
case 'failed':
|
||||
return PollOutcome.failed('Gagal mengekstrak data (parsing gagal di server).');
|
||||
default:
|
||||
return PollOutcome.pending;
|
||||
}
|
||||
}
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
enum PollOutcomeKind { done, failed, pending }
|
||||
|
||||
/// Result of inspecting a single `GET /documents/:id` poll response.
|
||||
/// Deliberately separated from the Dio call site
|
||||
/// (`pending_documents_provider.dart`'s `_pollUntilParsed`) so the
|
||||
/// parse-status branching is unit-testable without mocking network I/O -
|
||||
/// mirrors the pure-function pattern already used for `document_sync_merge.dart`
|
||||
/// (task 6.2) and `product_scan_response_parser.dart` (task 7.1).
|
||||
class PollOutcome {
|
||||
final PollOutcomeKind kind;
|
||||
final DocumentModel? document;
|
||||
final String? message;
|
||||
|
||||
const PollOutcome._(this.kind, {this.document, this.message});
|
||||
|
||||
factory PollOutcome.done(DocumentModel document) =>
|
||||
PollOutcome._(PollOutcomeKind.done, document: document);
|
||||
|
||||
factory PollOutcome.failed(String message) =>
|
||||
PollOutcome._(PollOutcomeKind.failed, message: message);
|
||||
|
||||
static const PollOutcome pending = PollOutcome._(PollOutcomeKind.pending);
|
||||
}
|
||||
|
||||
/// `parseStatus` absent/unrecognized (a legacy/cached response predating
|
||||
/// backend task 9.1) is treated the same as `"pending"` - keep polling rather
|
||||
/// than surface a false failure.
|
||||
PollOutcome resolvePollOutcome(DocumentModel fetched) {
|
||||
switch (fetched.parseStatus) {
|
||||
case 'done':
|
||||
return PollOutcome.done(fetched);
|
||||
case 'failed':
|
||||
return PollOutcome.failed('Gagal mengekstrak data (parsing gagal di server).');
|
||||
default:
|
||||
return PollOutcome.pending;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,53 @@
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
enum DocumentSaveActionKind { putExisting, reuploadThenPut, blocked }
|
||||
|
||||
/// Decides how an editor's save flow should reach the server, given whether
|
||||
/// a real server-assigned document is already resolved. Deliberately
|
||||
/// separated from the Dio call sites (`editor_screen.dart`,
|
||||
/// `product_editor_logic.dart`) so the guard against PUTting to a
|
||||
/// fabricated client-side ID - a millisecond timestamp that can never match
|
||||
/// the int4 `documents.id` (gap G5, `docs/api-contract-map.md`) - is
|
||||
/// unit-testable without mocking network I/O, mirroring the pure-decision
|
||||
/// pattern used by `poll_outcome.dart` (task 6.1).
|
||||
class DocumentSaveAction {
|
||||
final DocumentSaveActionKind kind;
|
||||
final String? serverId;
|
||||
final String? blockedMessage;
|
||||
|
||||
const DocumentSaveAction._(this.kind, {this.serverId, this.blockedMessage});
|
||||
|
||||
factory DocumentSaveAction.putExisting(String serverId) =>
|
||||
DocumentSaveAction._(DocumentSaveActionKind.putExisting, serverId: serverId);
|
||||
|
||||
factory DocumentSaveAction.reuploadThenPut() =>
|
||||
const DocumentSaveAction._(DocumentSaveActionKind.reuploadThenPut);
|
||||
|
||||
factory DocumentSaveAction.blocked(String message) =>
|
||||
DocumentSaveAction._(DocumentSaveActionKind.blocked, blockedMessage: message);
|
||||
}
|
||||
|
||||
/// [existingDocument] is the server document already resolved from a
|
||||
/// completed upload+parse (e.g. `PendingDocument.document`) - when present,
|
||||
/// its real id is used for the PUT. When absent (e.g. the editor route lost
|
||||
/// its `pendingId` on process-death restoration), [localImagePath] - the
|
||||
/// pending item's originally-captured photo - lets the save flow recover a
|
||||
/// real server id by re-uploading it (safe: the backend dedups by
|
||||
/// `file_hash`) before PUTting the corrected fields. If neither is
|
||||
/// available there is nothing to save against, so saving is blocked with an
|
||||
/// explicit message instead of PUTting to a made-up ID that can never match
|
||||
/// a real document.
|
||||
DocumentSaveAction resolveDocumentSaveAction({
|
||||
required DocumentModel? existingDocument,
|
||||
required String localImagePath,
|
||||
}) {
|
||||
if (existingDocument != null) {
|
||||
return DocumentSaveAction.putExisting(existingDocument.id);
|
||||
}
|
||||
if (localImagePath.isEmpty) {
|
||||
return DocumentSaveAction.blocked(
|
||||
'Tidak dapat menyimpan: dokumen asli tidak ditemukan di perangkat. Silakan pindai ulang.',
|
||||
);
|
||||
}
|
||||
return DocumentSaveAction.reuploadThenPut();
|
||||
}
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
enum DocumentSaveActionKind { putExisting, reuploadThenPut, blocked }
|
||||
|
||||
/// Decides how an editor's save flow should reach the server, given whether
|
||||
/// a real server-assigned document is already resolved. Deliberately
|
||||
/// separated from the Dio call sites (`editor_screen.dart`,
|
||||
/// `product_editor_logic.dart`) so the guard against PUTting to a
|
||||
/// fabricated client-side ID - a millisecond timestamp that can never match
|
||||
/// the int4 `documents.id` (gap G5, `docs/api-contract-map.md`) - is
|
||||
/// unit-testable without mocking network I/O, mirroring the pure-decision
|
||||
/// pattern used by `poll_outcome.dart` (task 6.1).
|
||||
class DocumentSaveAction {
|
||||
final DocumentSaveActionKind kind;
|
||||
final String? serverId;
|
||||
final String? blockedMessage;
|
||||
|
||||
const DocumentSaveAction._(this.kind, {this.serverId, this.blockedMessage});
|
||||
|
||||
factory DocumentSaveAction.putExisting(String serverId) =>
|
||||
DocumentSaveAction._(DocumentSaveActionKind.putExisting, serverId: serverId);
|
||||
|
||||
factory DocumentSaveAction.reuploadThenPut() =>
|
||||
const DocumentSaveAction._(DocumentSaveActionKind.reuploadThenPut);
|
||||
|
||||
factory DocumentSaveAction.blocked(String message) =>
|
||||
DocumentSaveAction._(DocumentSaveActionKind.blocked, blockedMessage: message);
|
||||
}
|
||||
|
||||
/// [existingDocument] is the server document already resolved from a
|
||||
/// completed upload+parse (e.g. `PendingDocument.document`) - when present,
|
||||
/// its real id is used for the PUT. When absent (e.g. the editor route lost
|
||||
/// its `pendingId` on process-death restoration), [localImagePath] - the
|
||||
/// pending item's originally-captured photo - lets the save flow recover a
|
||||
/// real server id by re-uploading it (safe: the backend dedups by
|
||||
/// `file_hash`) before PUTting the corrected fields. If neither is
|
||||
/// available there is nothing to save against, so saving is blocked with an
|
||||
/// explicit message instead of PUTting to a made-up ID that can never match
|
||||
/// a real document.
|
||||
DocumentSaveAction resolveDocumentSaveAction({
|
||||
required DocumentModel? existingDocument,
|
||||
required String localImagePath,
|
||||
}) {
|
||||
if (existingDocument != null) {
|
||||
return DocumentSaveAction.putExisting(existingDocument.id);
|
||||
}
|
||||
if (localImagePath.isEmpty) {
|
||||
return DocumentSaveAction.blocked(
|
||||
'Tidak dapat menyimpan: dokumen asli tidak ditemukan di perangkat. Silakan pindai ulang.',
|
||||
);
|
||||
}
|
||||
return DocumentSaveAction.reuploadThenPut();
|
||||
}
|
||||
@@ -1,238 +1,238 @@
|
||||
part of 'editor_screen.dart';
|
||||
|
||||
mixin EditorLogic on ConsumerState<EditorScreen> {
|
||||
DocumentModel? _document;
|
||||
|
||||
// Controllers
|
||||
final _tanggalCtrl = TextEditingController();
|
||||
final _noPoCtrl = TextEditingController();
|
||||
final _noSoCtrl = TextEditingController();
|
||||
final _noDoCtrl = TextEditingController();
|
||||
|
||||
final _kepadaYthCtrl = TextEditingController();
|
||||
final _orderUntukCtrl = TextEditingController();
|
||||
final _alamatCtrl = TextEditingController();
|
||||
final _platTrukCtrl = TextEditingController();
|
||||
final _namaDriverCtrl = TextEditingController();
|
||||
final _namaPenerimaCtrl = TextEditingController();
|
||||
|
||||
final _latitudeCtrl = TextEditingController();
|
||||
final _longitudeCtrl = TextEditingController();
|
||||
|
||||
List<Map<String, TextEditingController>> _itemControllers = [];
|
||||
bool _isAgreed = false;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
void _loadDocumentData() {
|
||||
if (widget.pendingId == null) return;
|
||||
|
||||
final pendingDocs = ref.read(pendingDocumentsProvider);
|
||||
final pendingDoc = pendingDocs.firstWhere(
|
||||
(doc) => doc.id == widget.pendingId,
|
||||
orElse: () => PendingDocument(id: '', imagePath: '')
|
||||
);
|
||||
|
||||
if (pendingDoc.document != null) {
|
||||
final doc = pendingDoc.document!;
|
||||
setState(() {
|
||||
_document = doc;
|
||||
_tanggalCtrl.text = doc.tanggal;
|
||||
_noPoCtrl.text = doc.noPo;
|
||||
_noSoCtrl.text = doc.noSo;
|
||||
_noDoCtrl.text = doc.noDo;
|
||||
|
||||
_kepadaYthCtrl.text = doc.kepadaYth;
|
||||
_orderUntukCtrl.text = doc.orderUntuk;
|
||||
_alamatCtrl.text = doc.alamat;
|
||||
_platTrukCtrl.text = doc.platTruk;
|
||||
_namaDriverCtrl.text = doc.namaDriver;
|
||||
|
||||
final lat = pendingDoc.latitude ?? doc.latitude;
|
||||
final lng = pendingDoc.longitude ?? doc.longitude;
|
||||
_latitudeCtrl.text = lat != null ? lat.toString() : '-';
|
||||
_longitudeCtrl.text = lng != null ? lng.toString() : '-';
|
||||
|
||||
for (var item in doc.items) {
|
||||
_addItem(
|
||||
sku: item.nomorSku,
|
||||
nama: item.namaBarang,
|
||||
banyak: item.banyak,
|
||||
jumlah: item.jumlah,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _addItem({String sku = '', String nama = '', String banyak = '', String jumlah = ''}) {
|
||||
final skuCtrl = TextEditingController(text: sku);
|
||||
final namaCtrl = TextEditingController(text: nama);
|
||||
final banyakCtrl = TextEditingController(text: banyak);
|
||||
final jumlahCtrl = TextEditingController(text: jumlah);
|
||||
|
||||
setState(() {
|
||||
_itemControllers.add({
|
||||
'sku': skuCtrl,
|
||||
'nama': namaCtrl,
|
||||
'banyak': banyakCtrl,
|
||||
'jumlah': jumlahCtrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _removeItem(int index) {
|
||||
setState(() {
|
||||
final ctrls = _itemControllers[index];
|
||||
ctrls['sku']?.dispose();
|
||||
ctrls['nama']?.dispose();
|
||||
ctrls['banyak']?.dispose();
|
||||
ctrls['jumlah']?.dispose();
|
||||
_itemControllers.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
void _showConfirmationDialog() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) {
|
||||
return ConfirmationDialog(
|
||||
nameController: _namaPenerimaCtrl,
|
||||
initialAgreed: _isAgreed,
|
||||
onConfirm: (agreed, name) {
|
||||
setState(() {
|
||||
_isAgreed = agreed;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
_submitDocument();
|
||||
},
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitDocument() async {
|
||||
final pendingDocs = ref.read(pendingDocumentsProvider);
|
||||
final pendingDoc = pendingDocs.firstWhere(
|
||||
(doc) => doc.id == widget.pendingId,
|
||||
orElse: () => PendingDocument(id: '', imagePath: '')
|
||||
);
|
||||
final double? finalLat = pendingDoc.latitude ?? _document?.latitude;
|
||||
final double? finalLng = pendingDoc.longitude ?? _document?.longitude;
|
||||
|
||||
final saveAction = resolveDocumentSaveAction(
|
||||
existingDocument: _document,
|
||||
localImagePath: pendingDoc.imagePath,
|
||||
);
|
||||
|
||||
if (saveAction.kind == DocumentSaveActionKind.blocked) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(saveAction.blockedMessage!),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
String serverId;
|
||||
if (saveAction.kind == DocumentSaveActionKind.reuploadThenPut) {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'image': await MultipartFile.fromFile(
|
||||
pendingDoc.imagePath,
|
||||
filename: pendingDoc.imagePath.split(RegExp(r'[/\\]')).last,
|
||||
),
|
||||
if (finalLat != null) 'latitude': finalLat.toString(),
|
||||
if (finalLng != null) 'longitude': finalLng.toString(),
|
||||
'scan_mode': pendingDoc.scanMode ?? 'DO',
|
||||
});
|
||||
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
|
||||
serverId = DocumentModel.fromJson(response.data['data']).id;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Gagal membuat dokumen di server: ${ApiException.from(e).displayMessage}'),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
serverId = saveAction.serverId!;
|
||||
}
|
||||
|
||||
final finalDoc = DocumentModel(
|
||||
id: serverId,
|
||||
tanggal: _tanggalCtrl.text,
|
||||
noPo: _noPoCtrl.text,
|
||||
noSo: _noSoCtrl.text,
|
||||
noDo: _noDoCtrl.text,
|
||||
kepadaYth: _kepadaYthCtrl.text,
|
||||
orderUntuk: _orderUntukCtrl.text,
|
||||
alamat: _alamatCtrl.text,
|
||||
platTruk: _platTrukCtrl.text,
|
||||
namaDriver: _namaDriverCtrl.text,
|
||||
namaPenerima: _namaPenerimaCtrl.text,
|
||||
latitude: finalLat,
|
||||
longitude: finalLng,
|
||||
items: _itemControllers.map((ctrls) => DocumentItem(
|
||||
nomorSku: ctrls['sku']!.text,
|
||||
namaBarang: ctrls['nama']!.text,
|
||||
banyak: ctrls['banyak']!.text,
|
||||
jumlah: ctrls['jumlah']!.text,
|
||||
)).toList(),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final localStorage = ref.read(localStorageProvider);
|
||||
await localStorage.saveDocument(finalDoc);
|
||||
|
||||
bool syncedToServer = false;
|
||||
String? syncError;
|
||||
try {
|
||||
await apiClient.client.put(
|
||||
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
|
||||
data: finalDoc.toPutPayload(),
|
||||
);
|
||||
syncedToServer = true;
|
||||
} catch (e) {
|
||||
syncError = ApiException.from(e).displayMessage;
|
||||
}
|
||||
|
||||
if (widget.pendingId != null) {
|
||||
if (syncedToServer) {
|
||||
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
|
||||
} else {
|
||||
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
|
||||
widget.pendingId!,
|
||||
finalDoc,
|
||||
'Belum tersinkron ke server: $syncError',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
syncedToServer
|
||||
? const SnackBar(content: Text('Dokumen Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
|
||||
: const SnackBar(
|
||||
content: Text('Tersimpan lokal, tapi GAGAL sinkron ke server. Cek koneksi lalu retry dari daftar dokumen.'),
|
||||
backgroundColor: Colors.deepOrange,
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
context.pop(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
part of 'editor_screen.dart';
|
||||
|
||||
mixin EditorLogic on ConsumerState<EditorScreen> {
|
||||
DocumentModel? _document;
|
||||
|
||||
// Controllers
|
||||
final _tanggalCtrl = TextEditingController();
|
||||
final _noPoCtrl = TextEditingController();
|
||||
final _noSoCtrl = TextEditingController();
|
||||
final _noDoCtrl = TextEditingController();
|
||||
|
||||
final _kepadaYthCtrl = TextEditingController();
|
||||
final _orderUntukCtrl = TextEditingController();
|
||||
final _alamatCtrl = TextEditingController();
|
||||
final _platTrukCtrl = TextEditingController();
|
||||
final _namaDriverCtrl = TextEditingController();
|
||||
final _namaPenerimaCtrl = TextEditingController();
|
||||
|
||||
final _latitudeCtrl = TextEditingController();
|
||||
final _longitudeCtrl = TextEditingController();
|
||||
|
||||
List<Map<String, TextEditingController>> _itemControllers = [];
|
||||
bool _isAgreed = false;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
void _loadDocumentData() {
|
||||
if (widget.pendingId == null) return;
|
||||
|
||||
final pendingDocs = ref.read(pendingDocumentsProvider);
|
||||
final pendingDoc = pendingDocs.firstWhere(
|
||||
(doc) => doc.id == widget.pendingId,
|
||||
orElse: () => PendingDocument(id: '', imagePath: '')
|
||||
);
|
||||
|
||||
if (pendingDoc.document != null) {
|
||||
final doc = pendingDoc.document!;
|
||||
setState(() {
|
||||
_document = doc;
|
||||
_tanggalCtrl.text = doc.tanggal;
|
||||
_noPoCtrl.text = doc.noPo;
|
||||
_noSoCtrl.text = doc.noSo;
|
||||
_noDoCtrl.text = doc.noDo;
|
||||
|
||||
_kepadaYthCtrl.text = doc.kepadaYth;
|
||||
_orderUntukCtrl.text = doc.orderUntuk;
|
||||
_alamatCtrl.text = doc.alamat;
|
||||
_platTrukCtrl.text = doc.platTruk;
|
||||
_namaDriverCtrl.text = doc.namaDriver;
|
||||
|
||||
final lat = pendingDoc.latitude ?? doc.latitude;
|
||||
final lng = pendingDoc.longitude ?? doc.longitude;
|
||||
_latitudeCtrl.text = lat != null ? lat.toString() : '-';
|
||||
_longitudeCtrl.text = lng != null ? lng.toString() : '-';
|
||||
|
||||
for (var item in doc.items) {
|
||||
_addItem(
|
||||
sku: item.nomorSku,
|
||||
nama: item.namaBarang,
|
||||
banyak: item.banyak,
|
||||
jumlah: item.jumlah,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _addItem({String sku = '', String nama = '', String banyak = '', String jumlah = ''}) {
|
||||
final skuCtrl = TextEditingController(text: sku);
|
||||
final namaCtrl = TextEditingController(text: nama);
|
||||
final banyakCtrl = TextEditingController(text: banyak);
|
||||
final jumlahCtrl = TextEditingController(text: jumlah);
|
||||
|
||||
setState(() {
|
||||
_itemControllers.add({
|
||||
'sku': skuCtrl,
|
||||
'nama': namaCtrl,
|
||||
'banyak': banyakCtrl,
|
||||
'jumlah': jumlahCtrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _removeItem(int index) {
|
||||
setState(() {
|
||||
final ctrls = _itemControllers[index];
|
||||
ctrls['sku']?.dispose();
|
||||
ctrls['nama']?.dispose();
|
||||
ctrls['banyak']?.dispose();
|
||||
ctrls['jumlah']?.dispose();
|
||||
_itemControllers.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
void _showConfirmationDialog() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) {
|
||||
return ConfirmationDialog(
|
||||
nameController: _namaPenerimaCtrl,
|
||||
initialAgreed: _isAgreed,
|
||||
onConfirm: (agreed, name) {
|
||||
setState(() {
|
||||
_isAgreed = agreed;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
_submitDocument();
|
||||
},
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitDocument() async {
|
||||
final pendingDocs = ref.read(pendingDocumentsProvider);
|
||||
final pendingDoc = pendingDocs.firstWhere(
|
||||
(doc) => doc.id == widget.pendingId,
|
||||
orElse: () => PendingDocument(id: '', imagePath: '')
|
||||
);
|
||||
final double? finalLat = pendingDoc.latitude ?? _document?.latitude;
|
||||
final double? finalLng = pendingDoc.longitude ?? _document?.longitude;
|
||||
|
||||
final saveAction = resolveDocumentSaveAction(
|
||||
existingDocument: _document,
|
||||
localImagePath: pendingDoc.imagePath,
|
||||
);
|
||||
|
||||
if (saveAction.kind == DocumentSaveActionKind.blocked) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(saveAction.blockedMessage!),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
String serverId;
|
||||
if (saveAction.kind == DocumentSaveActionKind.reuploadThenPut) {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'image': await MultipartFile.fromFile(
|
||||
pendingDoc.imagePath,
|
||||
filename: pendingDoc.imagePath.split(RegExp(r'[/\\]')).last,
|
||||
),
|
||||
if (finalLat != null) 'latitude': finalLat.toString(),
|
||||
if (finalLng != null) 'longitude': finalLng.toString(),
|
||||
'scan_mode': pendingDoc.scanMode ?? 'DO',
|
||||
});
|
||||
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
|
||||
serverId = DocumentModel.fromJson(response.data['data']).id;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Gagal membuat dokumen di server: ${ApiException.from(e).displayMessage}'),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
serverId = saveAction.serverId!;
|
||||
}
|
||||
|
||||
final finalDoc = DocumentModel(
|
||||
id: serverId,
|
||||
tanggal: _tanggalCtrl.text,
|
||||
noPo: _noPoCtrl.text,
|
||||
noSo: _noSoCtrl.text,
|
||||
noDo: _noDoCtrl.text,
|
||||
kepadaYth: _kepadaYthCtrl.text,
|
||||
orderUntuk: _orderUntukCtrl.text,
|
||||
alamat: _alamatCtrl.text,
|
||||
platTruk: _platTrukCtrl.text,
|
||||
namaDriver: _namaDriverCtrl.text,
|
||||
namaPenerima: _namaPenerimaCtrl.text,
|
||||
latitude: finalLat,
|
||||
longitude: finalLng,
|
||||
items: _itemControllers.map((ctrls) => DocumentItem(
|
||||
nomorSku: ctrls['sku']!.text,
|
||||
namaBarang: ctrls['nama']!.text,
|
||||
banyak: ctrls['banyak']!.text,
|
||||
jumlah: ctrls['jumlah']!.text,
|
||||
)).toList(),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final localStorage = ref.read(localStorageProvider);
|
||||
await localStorage.saveDocument(finalDoc);
|
||||
|
||||
bool syncedToServer = false;
|
||||
String? syncError;
|
||||
try {
|
||||
await apiClient.client.put(
|
||||
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
|
||||
data: finalDoc.toPutPayload(),
|
||||
);
|
||||
syncedToServer = true;
|
||||
} catch (e) {
|
||||
syncError = ApiException.from(e).displayMessage;
|
||||
}
|
||||
|
||||
if (widget.pendingId != null) {
|
||||
if (syncedToServer) {
|
||||
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
|
||||
} else {
|
||||
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
|
||||
widget.pendingId!,
|
||||
finalDoc,
|
||||
'Belum tersinkron ke server: $syncError',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
syncedToServer
|
||||
? const SnackBar(content: Text('Dokumen Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
|
||||
: const SnackBar(
|
||||
content: Text('Tersimpan lokal, tapi GAGAL sinkron ke server. Cek koneksi lalu retry dari daftar dokumen.'),
|
||||
backgroundColor: Colors.deepOrange,
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
context.pop(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +1,150 @@
|
||||
library editor_screen;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../main.dart'; // To access localStorageProvider
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import 'document_save_action.dart';
|
||||
import 'widgets/header_details_card.dart';
|
||||
import 'widgets/shipment_details_card.dart';
|
||||
import 'widgets/location_details_card.dart';
|
||||
import 'widgets/items_list_card.dart';
|
||||
import 'widgets/confirmation_dialog.dart';
|
||||
|
||||
part 'editor_logic.dart';
|
||||
|
||||
class EditorScreen extends ConsumerStatefulWidget {
|
||||
final String? pendingId;
|
||||
|
||||
const EditorScreen({super.key, this.pendingId});
|
||||
|
||||
@override
|
||||
ConsumerState<EditorScreen> createState() => _EditorScreenState();
|
||||
}
|
||||
|
||||
class _EditorScreenState extends ConsumerState<EditorScreen> with EditorLogic {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDocumentData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tanggalCtrl.dispose();
|
||||
_noPoCtrl.dispose();
|
||||
_noSoCtrl.dispose();
|
||||
_noDoCtrl.dispose();
|
||||
_kepadaYthCtrl.dispose();
|
||||
_orderUntukCtrl.dispose();
|
||||
_alamatCtrl.dispose();
|
||||
_platTrukCtrl.dispose();
|
||||
_namaDriverCtrl.dispose();
|
||||
_namaPenerimaCtrl.dispose();
|
||||
_latitudeCtrl.dispose();
|
||||
_longitudeCtrl.dispose();
|
||||
for (var ctrls in _itemControllers) {
|
||||
ctrls['sku']?.dispose();
|
||||
ctrls['nama']?.dispose();
|
||||
ctrls['banyak']?.dispose();
|
||||
ctrls['jumlah']?.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_document == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Tinjau Data')),
|
||||
body: const Center(child: Text('Dokumen tidak ditemukan atau masih diproses')),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Tinjau & Edit Data'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: AppConfig.paddingLarge,
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.primaryColor.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
border: Border.all(color: AppConfig.primaryColor.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: AppConfig.primaryColor),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tinjau dan edit data hasil ekstraksi di bawah ini untuk memastikan keakuratannya sebelum konfirmasi.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
|
||||
HeaderDetailsCard(
|
||||
tanggalCtrl: _tanggalCtrl,
|
||||
noPoCtrl: _noPoCtrl,
|
||||
noSoCtrl: _noSoCtrl,
|
||||
noDoCtrl: _noDoCtrl,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
|
||||
ShipmentDetailsCard(
|
||||
kepadaYthCtrl: _kepadaYthCtrl,
|
||||
orderUntukCtrl: _orderUntukCtrl,
|
||||
alamatCtrl: _alamatCtrl,
|
||||
platTrukCtrl: _platTrukCtrl,
|
||||
namaDriverCtrl: _namaDriverCtrl,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
|
||||
LocationDetailsCard(
|
||||
latitudeCtrl: _latitudeCtrl,
|
||||
longitudeCtrl: _longitudeCtrl,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
|
||||
ItemsListCard(
|
||||
itemControllers: _itemControllers,
|
||||
onRemoveItem: _removeItem,
|
||||
onAddItem: () => _addItem(),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: _showConfirmationDialog,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
),
|
||||
child: const Text('Simpan & Konfirmasi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
library editor_screen;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../main.dart'; // To access localStorageProvider
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import 'document_save_action.dart';
|
||||
import 'widgets/header_details_card.dart';
|
||||
import 'widgets/shipment_details_card.dart';
|
||||
import 'widgets/location_details_card.dart';
|
||||
import 'widgets/items_list_card.dart';
|
||||
import 'widgets/confirmation_dialog.dart';
|
||||
|
||||
part 'editor_logic.dart';
|
||||
|
||||
class EditorScreen extends ConsumerStatefulWidget {
|
||||
final String? pendingId;
|
||||
|
||||
const EditorScreen({super.key, this.pendingId});
|
||||
|
||||
@override
|
||||
ConsumerState<EditorScreen> createState() => _EditorScreenState();
|
||||
}
|
||||
|
||||
class _EditorScreenState extends ConsumerState<EditorScreen> with EditorLogic {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDocumentData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tanggalCtrl.dispose();
|
||||
_noPoCtrl.dispose();
|
||||
_noSoCtrl.dispose();
|
||||
_noDoCtrl.dispose();
|
||||
_kepadaYthCtrl.dispose();
|
||||
_orderUntukCtrl.dispose();
|
||||
_alamatCtrl.dispose();
|
||||
_platTrukCtrl.dispose();
|
||||
_namaDriverCtrl.dispose();
|
||||
_namaPenerimaCtrl.dispose();
|
||||
_latitudeCtrl.dispose();
|
||||
_longitudeCtrl.dispose();
|
||||
for (var ctrls in _itemControllers) {
|
||||
ctrls['sku']?.dispose();
|
||||
ctrls['nama']?.dispose();
|
||||
ctrls['banyak']?.dispose();
|
||||
ctrls['jumlah']?.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_document == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Tinjau Data')),
|
||||
body: const Center(child: Text('Dokumen tidak ditemukan atau masih diproses')),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Tinjau & Edit Data'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: AppConfig.paddingLarge,
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.primaryColor.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
border: Border.all(color: AppConfig.primaryColor.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: AppConfig.primaryColor),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tinjau dan edit data hasil ekstraksi di bawah ini untuk memastikan keakuratannya sebelum konfirmasi.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
|
||||
HeaderDetailsCard(
|
||||
tanggalCtrl: _tanggalCtrl,
|
||||
noPoCtrl: _noPoCtrl,
|
||||
noSoCtrl: _noSoCtrl,
|
||||
noDoCtrl: _noDoCtrl,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
|
||||
ShipmentDetailsCard(
|
||||
kepadaYthCtrl: _kepadaYthCtrl,
|
||||
orderUntukCtrl: _orderUntukCtrl,
|
||||
alamatCtrl: _alamatCtrl,
|
||||
platTrukCtrl: _platTrukCtrl,
|
||||
namaDriverCtrl: _namaDriverCtrl,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
|
||||
LocationDetailsCard(
|
||||
latitudeCtrl: _latitudeCtrl,
|
||||
longitudeCtrl: _longitudeCtrl,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
|
||||
ItemsListCard(
|
||||
itemControllers: _itemControllers,
|
||||
onRemoveItem: _removeItem,
|
||||
onAddItem: () => _addItem(),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: _showConfirmationDialog,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
),
|
||||
child: const Text('Simpan & Konfirmasi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,235 +1,235 @@
|
||||
import 'package:flutter/widgets.dart' show AssetImage;
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
class PdfService {
|
||||
static Future<void> generateAndPrintReceipt(DocumentModel document) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Load PFM logo asset
|
||||
final logoImage = await flutterImageProvider(const AssetImage('assets/logo.png'));
|
||||
final isProductScan = document.docType == 'Product';
|
||||
|
||||
// Add page
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
build: (pw.Context context) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header & Title
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
isProductScan ? 'BUKTI VERIFIKASI SCAN PRODUK' : 'TANDA TERIMA PENGIRIMAN',
|
||||
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: pw.Image(logoImage),
|
||||
),
|
||||
]
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Document Headers
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.grey),
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: isProductScan
|
||||
? [
|
||||
_buildTextRow('Tanggal Scan', document.tanggal),
|
||||
_buildTextRow('Toko / Cabang', document.kepadaYth),
|
||||
_buildTextRow('Jenis Verifikasi', 'Pemindaian Produk & Kadaluwarsa'),
|
||||
]
|
||||
: [
|
||||
_buildTextRow('Tanggal', document.tanggal),
|
||||
_buildTextRow('No PO', document.noPo),
|
||||
_buildTextRow('No SO', document.noSo),
|
||||
_buildTextRow('No DO', document.noDo),
|
||||
]
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Detail Scan / Pengiriman Section
|
||||
pw.Text(
|
||||
isProductScan ? 'Informasi Verifikasi' : 'Detail Pengiriman',
|
||||
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: PdfColors.grey100,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: isProductScan
|
||||
? [
|
||||
_buildTextRow('Nama Produk', document.items.isNotEmpty ? document.items.first.namaBarang : '-'),
|
||||
_buildTextRow('Kode SKU', document.items.isNotEmpty ? document.items.first.nomorSku : '-'),
|
||||
_buildTextRow('Nomor Batch', document.noDo),
|
||||
_buildTextRow('Tanggal Expired', document.alamat.replaceAll("Product Scan: Expired ", "")),
|
||||
]
|
||||
: [
|
||||
_buildTextRow('Kepada Yth', document.kepadaYth),
|
||||
_buildTextRow('Order Untuk', document.orderUntuk),
|
||||
_buildTextRow('Alamat', document.alamat),
|
||||
_buildTextRow('Plat Truk', document.platTruk),
|
||||
_buildTextRow('Nama Driver', document.namaDriver),
|
||||
]
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Items Table / Scanned Product Details
|
||||
pw.Text(
|
||||
isProductScan ? 'Detail Item Scanned' : 'Barang Dikirim',
|
||||
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
|
||||
isProductScan
|
||||
? pw.Table.fromTextArray(
|
||||
context: context,
|
||||
border: pw.TableBorder.all(color: PdfColors.grey),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
headerHeight: 25,
|
||||
cellHeight: 25,
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(100), // SKU
|
||||
1: const pw.FlexColumnWidth(2), // Nama Barang
|
||||
2: const pw.FixedColumnWidth(100), // Batch
|
||||
3: const pw.FixedColumnWidth(100), // Expired
|
||||
},
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.center,
|
||||
3: pw.Alignment.center,
|
||||
},
|
||||
headers: ['Nomor SKU', 'Nama Barang', 'Nomor Batch', 'Expired Date'],
|
||||
data: [
|
||||
[
|
||||
document.items.isNotEmpty ? document.items.first.nomorSku : '-',
|
||||
document.items.isNotEmpty ? document.items.first.namaBarang : '-',
|
||||
document.noDo,
|
||||
document.alamat.replaceAll("Product Scan: Expired ", ""),
|
||||
]
|
||||
],
|
||||
)
|
||||
: pw.Table.fromTextArray(
|
||||
context: context,
|
||||
border: pw.TableBorder.all(color: PdfColors.grey),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
headerHeight: 25,
|
||||
cellHeight: 25,
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(80), // SKU
|
||||
1: const pw.FlexColumnWidth(2), // Nama Barang
|
||||
2: const pw.FixedColumnWidth(70), // Banyak
|
||||
3: const pw.FixedColumnWidth(70), // Jumlah
|
||||
},
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
},
|
||||
headers: ['Nomor SKU', 'Nama Barang', 'Banyak', 'Jumlah'],
|
||||
data: document.items.map((item) {
|
||||
return [
|
||||
item.nomorSku,
|
||||
item.namaBarang,
|
||||
item.banyak,
|
||||
item.jumlah,
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
pw.Spacer(),
|
||||
|
||||
// Footer & Signatures
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: isProductScan
|
||||
? [
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text('_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Petugas / Staff Toko'),
|
||||
]
|
||||
),
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text('_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Penanggung Jawab Toko'),
|
||||
]
|
||||
)
|
||||
]
|
||||
: [
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text(document.namaDriver.isNotEmpty ? document.namaDriver : '_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Pengirim (Driver)'),
|
||||
]
|
||||
),
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text(document.namaPenerima.isNotEmpty ? document.namaPenerima : '_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Penerima (Penjaga Toko)'),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Prompt user to view/print/save the PDF natively
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (PdfPageFormat format) async => pdf.save(),
|
||||
format: PdfPageFormat.a4,
|
||||
name: isProductScan
|
||||
? 'ProductScan_${document.items.isNotEmpty ? document.items.first.nomorSku : "unknown"}'
|
||||
: 'Document_${document.noDo.replaceAll('/', '_')}',
|
||||
);
|
||||
}
|
||||
|
||||
static pw.Widget _buildTextRow(String label, String value) {
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 2),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.SizedBox(width: 100, child: pw.Text(label, style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
pw.Expanded(
|
||||
child: pw.Text(': $value', softWrap: true),
|
||||
),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/widgets.dart' show AssetImage;
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import '../../models/document_model.dart';
|
||||
|
||||
class PdfService {
|
||||
static Future<void> generateAndPrintReceipt(DocumentModel document) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Load PFM logo asset
|
||||
final logoImage = await flutterImageProvider(const AssetImage('assets/logo.png'));
|
||||
final isProductScan = document.docType == 'Product';
|
||||
|
||||
// Add page
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
build: (pw.Context context) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header & Title
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
isProductScan ? 'BUKTI VERIFIKASI SCAN PRODUK' : 'TANDA TERIMA PENGIRIMAN',
|
||||
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: pw.Image(logoImage),
|
||||
),
|
||||
]
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Document Headers
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.grey),
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: isProductScan
|
||||
? [
|
||||
_buildTextRow('Tanggal Scan', document.tanggal),
|
||||
_buildTextRow('Toko / Cabang', document.kepadaYth),
|
||||
_buildTextRow('Jenis Verifikasi', 'Pemindaian Produk & Kadaluwarsa'),
|
||||
]
|
||||
: [
|
||||
_buildTextRow('Tanggal', document.tanggal),
|
||||
_buildTextRow('No PO', document.noPo),
|
||||
_buildTextRow('No SO', document.noSo),
|
||||
_buildTextRow('No DO', document.noDo),
|
||||
]
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Detail Scan / Pengiriman Section
|
||||
pw.Text(
|
||||
isProductScan ? 'Informasi Verifikasi' : 'Detail Pengiriman',
|
||||
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(10),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: PdfColors.grey100,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: isProductScan
|
||||
? [
|
||||
_buildTextRow('Nama Produk', document.items.isNotEmpty ? document.items.first.namaBarang : '-'),
|
||||
_buildTextRow('Kode SKU', document.items.isNotEmpty ? document.items.first.nomorSku : '-'),
|
||||
_buildTextRow('Nomor Batch', document.noDo),
|
||||
_buildTextRow('Tanggal Expired', document.alamat.replaceAll("Product Scan: Expired ", "")),
|
||||
]
|
||||
: [
|
||||
_buildTextRow('Kepada Yth', document.kepadaYth),
|
||||
_buildTextRow('Order Untuk', document.orderUntuk),
|
||||
_buildTextRow('Alamat', document.alamat),
|
||||
_buildTextRow('Plat Truk', document.platTruk),
|
||||
_buildTextRow('Nama Driver', document.namaDriver),
|
||||
]
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// Items Table / Scanned Product Details
|
||||
pw.Text(
|
||||
isProductScan ? 'Detail Item Scanned' : 'Barang Dikirim',
|
||||
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
|
||||
isProductScan
|
||||
? pw.Table.fromTextArray(
|
||||
context: context,
|
||||
border: pw.TableBorder.all(color: PdfColors.grey),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
headerHeight: 25,
|
||||
cellHeight: 25,
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(100), // SKU
|
||||
1: const pw.FlexColumnWidth(2), // Nama Barang
|
||||
2: const pw.FixedColumnWidth(100), // Batch
|
||||
3: const pw.FixedColumnWidth(100), // Expired
|
||||
},
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.center,
|
||||
3: pw.Alignment.center,
|
||||
},
|
||||
headers: ['Nomor SKU', 'Nama Barang', 'Nomor Batch', 'Expired Date'],
|
||||
data: [
|
||||
[
|
||||
document.items.isNotEmpty ? document.items.first.nomorSku : '-',
|
||||
document.items.isNotEmpty ? document.items.first.namaBarang : '-',
|
||||
document.noDo,
|
||||
document.alamat.replaceAll("Product Scan: Expired ", ""),
|
||||
]
|
||||
],
|
||||
)
|
||||
: pw.Table.fromTextArray(
|
||||
context: context,
|
||||
border: pw.TableBorder.all(color: PdfColors.grey),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||
headerHeight: 25,
|
||||
cellHeight: 25,
|
||||
columnWidths: {
|
||||
0: const pw.FixedColumnWidth(80), // SKU
|
||||
1: const pw.FlexColumnWidth(2), // Nama Barang
|
||||
2: const pw.FixedColumnWidth(70), // Banyak
|
||||
3: const pw.FixedColumnWidth(70), // Jumlah
|
||||
},
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerLeft,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
},
|
||||
headers: ['Nomor SKU', 'Nama Barang', 'Banyak', 'Jumlah'],
|
||||
data: document.items.map((item) {
|
||||
return [
|
||||
item.nomorSku,
|
||||
item.namaBarang,
|
||||
item.banyak,
|
||||
item.jumlah,
|
||||
];
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
pw.Spacer(),
|
||||
|
||||
// Footer & Signatures
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: isProductScan
|
||||
? [
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text('_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Petugas / Staff Toko'),
|
||||
]
|
||||
),
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text('_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Penanggung Jawab Toko'),
|
||||
]
|
||||
)
|
||||
]
|
||||
: [
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text(document.namaDriver.isNotEmpty ? document.namaDriver : '_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Pengirim (Driver)'),
|
||||
]
|
||||
),
|
||||
pw.Column(
|
||||
children: [
|
||||
pw.SizedBox(height: 40),
|
||||
pw.Text(document.namaPenerima.isNotEmpty ? document.namaPenerima : '_______________________', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text('Penerima (Penjaga Toko)'),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Prompt user to view/print/save the PDF natively
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (PdfPageFormat format) async => pdf.save(),
|
||||
format: PdfPageFormat.a4,
|
||||
name: isProductScan
|
||||
? 'ProductScan_${document.items.isNotEmpty ? document.items.first.nomorSku : "unknown"}'
|
||||
: 'Document_${document.noDo.replaceAll('/', '_')}',
|
||||
);
|
||||
}
|
||||
|
||||
static pw.Widget _buildTextRow(String label, String value) {
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 2),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.SizedBox(width: 100, child: pw.Text(label, style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
pw.Expanded(
|
||||
child: pw.Text(': $value', softWrap: true),
|
||||
),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +1,133 @@
|
||||
part of 'product_editor_screen.dart';
|
||||
|
||||
mixin ProductEditorDataLogic on ConsumerState<ProductEditorScreen> {
|
||||
final _expDateCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
String _selectedSku = '';
|
||||
String? _selectedBatchOption;
|
||||
bool _isManualDate = false;
|
||||
String _imagePath = '';
|
||||
DocumentModel? _document;
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
List<Map<String, dynamic>> _matches = [];
|
||||
final Map<String, List<String>> _skuBatches = {};
|
||||
List<Map<String, dynamic>> _skuMasterList = [];
|
||||
|
||||
/// True only when `_matches` came from the real classifier response - used
|
||||
/// to distinguish an actual AI recommendation from the manual-selection
|
||||
/// fallback list, so the UI never shows a fabricated confidence score.
|
||||
bool _hasAutoMatch = false;
|
||||
|
||||
/// True when even the SKU master list couldn't be loaded, leaving nothing
|
||||
/// to build a manual-selection fallback from - the one failure mode that
|
||||
/// blocks the review screen entirely instead of degrading gracefully.
|
||||
bool _classificationFailed = false;
|
||||
|
||||
void _loadPendingDoc() {
|
||||
if (widget.pendingId == null) return;
|
||||
final pendingDoc = ref.read(pendingDocumentsProvider).firstWhere(
|
||||
(doc) => doc.id == widget.pendingId,
|
||||
orElse: () => PendingDocument(id: '', imagePath: '')
|
||||
);
|
||||
setState(() {
|
||||
_imagePath = pendingDoc.imagePath;
|
||||
_document = pendingDoc.document;
|
||||
});
|
||||
}
|
||||
|
||||
/// Populates the SKU dropdown/expiry batches from the document's own data.
|
||||
/// Product Scan documents carry their classify+OCR result from the single
|
||||
/// pass the backend already ran at upload time (`_document.productScan*`,
|
||||
/// backend gap G3 fix) - reading it here is synchronous, no network call,
|
||||
/// mirroring how `EditorScreen._loadDocumentData()` reads a DO document's
|
||||
/// already-parsed data with no request at all. Falls back to a plain SKU
|
||||
/// master list read (a DB query, not a GPU/classifier call) only when the
|
||||
/// document has no stored match at all - e.g. nothing scored above the
|
||||
/// match threshold, or the document predates this fix.
|
||||
Future<void> _fetchClassificationAndSkus() async {
|
||||
_classificationFailed = false;
|
||||
|
||||
final storedMatches = _document?.productScanMatches ?? const [];
|
||||
final extractedExpiryDate = _document?.productScanExtractedExpiryDate ?? '';
|
||||
|
||||
if (storedMatches.isNotEmpty) {
|
||||
setState(() {
|
||||
_matches = List<Map<String, dynamic>>.from(storedMatches);
|
||||
_hasAutoMatch = true;
|
||||
// Only seed a batch from a real extracted date - an empty result
|
||||
// means "no date found," not "here are two made-up dates," so it's
|
||||
// left empty to force manual entry instead (see G7).
|
||||
_skuBatches.clear();
|
||||
for (var match in _matches) {
|
||||
final sku = match['no_sku'];
|
||||
_skuBatches[sku] = extractedExpiryDate.isNotEmpty ? [extractedExpiryDate] : [];
|
||||
}
|
||||
_selectedSku = _matches.first['no_sku'];
|
||||
_updateBatchOptions();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() { _loading = true; });
|
||||
try {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final skusRes = await apiClient.client.get(AppConfig.masterSkusEndpoint);
|
||||
_skuMasterList = parseSkuMasterList(skusRes.data);
|
||||
|
||||
if (_skuMasterList.isNotEmpty) {
|
||||
_matches = _skuMasterList.take(5).map<Map<String, dynamic>>((e) => <String, dynamic>{
|
||||
'no_sku': e['no_sku'],
|
||||
'nama_item': e['nama_item'],
|
||||
'confidence': 0.0,
|
||||
}).toList();
|
||||
_selectedSku = _matches.first['no_sku'];
|
||||
}
|
||||
_hasAutoMatch = false;
|
||||
} catch (e) {
|
||||
debugPrint('Error loading SKU master list fallback: $e');
|
||||
_classificationFailed = true;
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_updateBatchOptions();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _updateBatchOptions() {
|
||||
final batches = _skuBatches[_selectedSku] ?? [];
|
||||
if (batches.isNotEmpty) {
|
||||
_selectedBatchOption = 'Batch 1: ${batches[0]}';
|
||||
_expDateCtrl.text = batches[0];
|
||||
_isManualDate = false;
|
||||
} else {
|
||||
_selectedBatchOption = null;
|
||||
_expDateCtrl.clear();
|
||||
_isManualDate = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
DateTime? pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now().add(const Duration(days: 365)),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2035),
|
||||
builder: (context, child) => Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: const ColorScheme.light(primary: AppConfig.primaryColor, onPrimary: Colors.white, onSurface: AppConfig.textPrimary),
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
if (pickedDate != null) {
|
||||
setState(() { _expDateCtrl.text = DateFormat('dd/MM/yyyy').format(pickedDate); });
|
||||
}
|
||||
}
|
||||
}
|
||||
part of 'product_editor_screen.dart';
|
||||
|
||||
mixin ProductEditorDataLogic on ConsumerState<ProductEditorScreen> {
|
||||
final _expDateCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
String _selectedSku = '';
|
||||
String? _selectedBatchOption;
|
||||
bool _isManualDate = false;
|
||||
String _imagePath = '';
|
||||
DocumentModel? _document;
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
List<Map<String, dynamic>> _matches = [];
|
||||
final Map<String, List<String>> _skuBatches = {};
|
||||
List<Map<String, dynamic>> _skuMasterList = [];
|
||||
|
||||
/// True only when `_matches` came from the real classifier response - used
|
||||
/// to distinguish an actual AI recommendation from the manual-selection
|
||||
/// fallback list, so the UI never shows a fabricated confidence score.
|
||||
bool _hasAutoMatch = false;
|
||||
|
||||
/// True when even the SKU master list couldn't be loaded, leaving nothing
|
||||
/// to build a manual-selection fallback from - the one failure mode that
|
||||
/// blocks the review screen entirely instead of degrading gracefully.
|
||||
bool _classificationFailed = false;
|
||||
|
||||
void _loadPendingDoc() {
|
||||
if (widget.pendingId == null) return;
|
||||
final pendingDoc = ref.read(pendingDocumentsProvider).firstWhere(
|
||||
(doc) => doc.id == widget.pendingId,
|
||||
orElse: () => PendingDocument(id: '', imagePath: '')
|
||||
);
|
||||
setState(() {
|
||||
_imagePath = pendingDoc.imagePath;
|
||||
_document = pendingDoc.document;
|
||||
});
|
||||
}
|
||||
|
||||
/// Populates the SKU dropdown/expiry batches from the document's own data.
|
||||
/// Product Scan documents carry their classify+OCR result from the single
|
||||
/// pass the backend already ran at upload time (`_document.productScan*`,
|
||||
/// backend gap G3 fix) - reading it here is synchronous, no network call,
|
||||
/// mirroring how `EditorScreen._loadDocumentData()` reads a DO document's
|
||||
/// already-parsed data with no request at all. Falls back to a plain SKU
|
||||
/// master list read (a DB query, not a GPU/classifier call) only when the
|
||||
/// document has no stored match at all - e.g. nothing scored above the
|
||||
/// match threshold, or the document predates this fix.
|
||||
Future<void> _fetchClassificationAndSkus() async {
|
||||
_classificationFailed = false;
|
||||
|
||||
final storedMatches = _document?.productScanMatches ?? const [];
|
||||
final extractedExpiryDate = _document?.productScanExtractedExpiryDate ?? '';
|
||||
|
||||
if (storedMatches.isNotEmpty) {
|
||||
setState(() {
|
||||
_matches = List<Map<String, dynamic>>.from(storedMatches);
|
||||
_hasAutoMatch = true;
|
||||
// Only seed a batch from a real extracted date - an empty result
|
||||
// means "no date found," not "here are two made-up dates," so it's
|
||||
// left empty to force manual entry instead (see G7).
|
||||
_skuBatches.clear();
|
||||
for (var match in _matches) {
|
||||
final sku = match['no_sku'];
|
||||
_skuBatches[sku] = extractedExpiryDate.isNotEmpty ? [extractedExpiryDate] : [];
|
||||
}
|
||||
_selectedSku = _matches.first['no_sku'];
|
||||
_updateBatchOptions();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() { _loading = true; });
|
||||
try {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final skusRes = await apiClient.client.get(AppConfig.masterSkusEndpoint);
|
||||
_skuMasterList = parseSkuMasterList(skusRes.data);
|
||||
|
||||
if (_skuMasterList.isNotEmpty) {
|
||||
_matches = _skuMasterList.take(5).map<Map<String, dynamic>>((e) => <String, dynamic>{
|
||||
'no_sku': e['no_sku'],
|
||||
'nama_item': e['nama_item'],
|
||||
'confidence': 0.0,
|
||||
}).toList();
|
||||
_selectedSku = _matches.first['no_sku'];
|
||||
}
|
||||
_hasAutoMatch = false;
|
||||
} catch (e) {
|
||||
debugPrint('Error loading SKU master list fallback: $e');
|
||||
_classificationFailed = true;
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_updateBatchOptions();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _updateBatchOptions() {
|
||||
final batches = _skuBatches[_selectedSku] ?? [];
|
||||
if (batches.isNotEmpty) {
|
||||
_selectedBatchOption = 'Batch 1: ${batches[0]}';
|
||||
_expDateCtrl.text = batches[0];
|
||||
_isManualDate = false;
|
||||
} else {
|
||||
_selectedBatchOption = null;
|
||||
_expDateCtrl.clear();
|
||||
_isManualDate = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
DateTime? pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now().add(const Duration(days: 365)),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2035),
|
||||
builder: (context, child) => Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: const ColorScheme.light(primary: AppConfig.primaryColor, onPrimary: Colors.white, onSurface: AppConfig.textPrimary),
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
if (pickedDate != null) {
|
||||
setState(() { _expDateCtrl.text = DateFormat('dd/MM/yyyy').format(pickedDate); });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +1,143 @@
|
||||
library product_editor;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../main.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import 'document_save_action.dart';
|
||||
import 'product_scan_response_parser.dart';
|
||||
import 'widgets/product_dropdown_card.dart';
|
||||
import 'widgets/product_expiry_card.dart';
|
||||
import 'widgets/product_preview_card.dart';
|
||||
|
||||
part 'product_editor_data_logic.dart';
|
||||
part 'product_editor_submit_logic.dart';
|
||||
|
||||
class ProductEditorScreen extends ConsumerStatefulWidget {
|
||||
final String? pendingId;
|
||||
const ProductEditorScreen({super.key, this.pendingId});
|
||||
@override
|
||||
ConsumerState<ProductEditorScreen> createState() => _ProductEditorScreenState();
|
||||
}
|
||||
|
||||
class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> with ProductEditorDataLogic, ProductEditorSubmitLogic {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPendingDoc();
|
||||
_fetchClassificationAndSkus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Review Hasil Produk'), leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop())),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _classificationFailed
|
||||
? _buildFailureState()
|
||||
: SingleChildScrollView(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ProductPreviewCard(imagePath: _imagePath),
|
||||
if (_imagePath.isNotEmpty) const SizedBox(height: AppConfig.spaceLarge),
|
||||
ProductDropdownCard(
|
||||
selectedSku: _selectedSku,
|
||||
matches: _matches,
|
||||
hasAutoMatch: _hasAutoMatch,
|
||||
onSkuChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() {
|
||||
_selectedSku = val;
|
||||
_updateBatchOptions();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
ProductExpiryCard(
|
||||
selectedSku: _selectedSku,
|
||||
selectedBatchOption: _selectedBatchOption,
|
||||
isManualDate: _isManualDate,
|
||||
skuBatches: _skuBatches,
|
||||
expDateCtrl: _expDateCtrl,
|
||||
notesCtrl: _notesCtrl,
|
||||
onBatchChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() {
|
||||
if (val == 'manual') {
|
||||
_isManualDate = true;
|
||||
_expDateCtrl.clear();
|
||||
} else {
|
||||
_isManualDate = false;
|
||||
_selectedBatchOption = val;
|
||||
final parts = val.split(': ');
|
||||
if (parts.length > 1) { _expDateCtrl.text = parts[1]; }
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onSelectDate: _selectDate,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
ElevatedButton(
|
||||
onPressed: _submit,
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('Simpan & Konfirmasi Produk', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Explicit, retry-able error state for when the SKU master list couldn't
|
||||
/// be loaded at all (see docs/api-contract-map.md G7) - replaces the old
|
||||
/// behavior of silently fabricating fake SKU matches and confidence
|
||||
/// scores so the screen always looked like it had a working AI result.
|
||||
Widget _buildFailureState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48, color: AppConfig.errorColor),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Text('Gagal Memuat Klasifikasi Produk', style: AppConfig.h3, textAlign: TextAlign.center),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
Text(
|
||||
'Periksa koneksi Anda ke server dan coba lagi.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _fetchClassificationAndSkus,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Coba Lagi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
library product_editor;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../models/document_model.dart';
|
||||
import '../../main.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import 'document_save_action.dart';
|
||||
import 'product_scan_response_parser.dart';
|
||||
import 'widgets/product_dropdown_card.dart';
|
||||
import 'widgets/product_expiry_card.dart';
|
||||
import 'widgets/product_preview_card.dart';
|
||||
|
||||
part 'product_editor_data_logic.dart';
|
||||
part 'product_editor_submit_logic.dart';
|
||||
|
||||
class ProductEditorScreen extends ConsumerStatefulWidget {
|
||||
final String? pendingId;
|
||||
const ProductEditorScreen({super.key, this.pendingId});
|
||||
@override
|
||||
ConsumerState<ProductEditorScreen> createState() => _ProductEditorScreenState();
|
||||
}
|
||||
|
||||
class _ProductEditorScreenState extends ConsumerState<ProductEditorScreen> with ProductEditorDataLogic, ProductEditorSubmitLogic {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPendingDoc();
|
||||
_fetchClassificationAndSkus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Review Hasil Produk'), leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => context.pop())),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _classificationFailed
|
||||
? _buildFailureState()
|
||||
: SingleChildScrollView(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ProductPreviewCard(imagePath: _imagePath),
|
||||
if (_imagePath.isNotEmpty) const SizedBox(height: AppConfig.spaceLarge),
|
||||
ProductDropdownCard(
|
||||
selectedSku: _selectedSku,
|
||||
matches: _matches,
|
||||
hasAutoMatch: _hasAutoMatch,
|
||||
onSkuChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() {
|
||||
_selectedSku = val;
|
||||
_updateBatchOptions();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
ProductExpiryCard(
|
||||
selectedSku: _selectedSku,
|
||||
selectedBatchOption: _selectedBatchOption,
|
||||
isManualDate: _isManualDate,
|
||||
skuBatches: _skuBatches,
|
||||
expDateCtrl: _expDateCtrl,
|
||||
notesCtrl: _notesCtrl,
|
||||
onBatchChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() {
|
||||
if (val == 'manual') {
|
||||
_isManualDate = true;
|
||||
_expDateCtrl.clear();
|
||||
} else {
|
||||
_isManualDate = false;
|
||||
_selectedBatchOption = val;
|
||||
final parts = val.split(': ');
|
||||
if (parts.length > 1) { _expDateCtrl.text = parts[1]; }
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onSelectDate: _selectDate,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
ElevatedButton(
|
||||
onPressed: _submit,
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('Simpan & Konfirmasi Produk', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Explicit, retry-able error state for when the SKU master list couldn't
|
||||
/// be loaded at all (see docs/api-contract-map.md G7) - replaces the old
|
||||
/// behavior of silently fabricating fake SKU matches and confidence
|
||||
/// scores so the screen always looked like it had a working AI result.
|
||||
Widget _buildFailureState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48, color: AppConfig.errorColor),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Text('Gagal Memuat Klasifikasi Produk', style: AppConfig.h3, textAlign: TextAlign.center),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
Text(
|
||||
'Periksa koneksi Anda ke server dan coba lagi.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _fetchClassificationAndSkus,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Coba Lagi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +1,130 @@
|
||||
part of 'product_editor_screen.dart';
|
||||
|
||||
mixin ProductEditorSubmitLogic on ProductEditorDataLogic {
|
||||
void _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
final activeMatch = _matches.firstWhere(
|
||||
(m) => m['no_sku'] == _selectedSku,
|
||||
orElse: () => _matches.isNotEmpty ? _matches.first : <String, dynamic>{'no_sku': '', 'nama_item': 'Unknown Product', 'confidence': 0.0},
|
||||
);
|
||||
final String productName = activeMatch['nama_item'] ?? 'Unknown Product';
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final currentStoreName = prefs.getString('nama_toko') ?? 'PM KELAPA DUA KARAWACI';
|
||||
|
||||
final pendingDocs = ref.read(pendingDocumentsProvider);
|
||||
final pendingDoc = pendingDocs.firstWhere(
|
||||
(doc) => doc.id == widget.pendingId,
|
||||
orElse: () => PendingDocument(id: '', imagePath: '')
|
||||
);
|
||||
final double? finalLat = pendingDoc.latitude ?? _document?.latitude;
|
||||
final double? finalLng = pendingDoc.longitude ?? _document?.longitude;
|
||||
|
||||
final saveAction = resolveDocumentSaveAction(
|
||||
existingDocument: _document,
|
||||
localImagePath: pendingDoc.imagePath,
|
||||
);
|
||||
|
||||
if (saveAction.kind == DocumentSaveActionKind.blocked) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(saveAction.blockedMessage!),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
String serverId;
|
||||
if (saveAction.kind == DocumentSaveActionKind.reuploadThenPut) {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'image': await MultipartFile.fromFile(
|
||||
pendingDoc.imagePath,
|
||||
filename: pendingDoc.imagePath.split(RegExp(r'[/\\]')).last,
|
||||
),
|
||||
if (finalLat != null) 'latitude': finalLat.toString(),
|
||||
if (finalLng != null) 'longitude': finalLng.toString(),
|
||||
'scan_mode': pendingDoc.scanMode ?? 'Product',
|
||||
});
|
||||
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
|
||||
serverId = DocumentModel.fromJson(response.data['data']).id;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Gagal membuat dokumen di server: ${ApiException.from(e).displayMessage}'),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
serverId = saveAction.serverId!;
|
||||
}
|
||||
|
||||
final finalDoc = DocumentModel(
|
||||
id: serverId,
|
||||
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now()),
|
||||
// No PO-link concept for Product Scan documents anymore (the
|
||||
// "Hubungkan ke PO Dokumen" field was removed - user request).
|
||||
noPo: '',
|
||||
noSo: '1002003004',
|
||||
noDo: _isManualDate ? 'Batch Manual' : (_selectedBatchOption != null ? _selectedBatchOption!.split(': ')[0] : 'Batch 1'),
|
||||
kepadaYth: currentStoreName,
|
||||
orderUntuk: 'PRODUCT SCAN',
|
||||
alamat: 'Product Scan: Expired ${_expDateCtrl.text}',
|
||||
platTruk: 'B 1234 PFM',
|
||||
namaDriver: 'PRODUCT SCAN',
|
||||
namaPenerima: 'STORE STAFF',
|
||||
items: [DocumentItem(nomorSku: _selectedSku, namaBarang: productName, banyak: '1', jumlah: '1')],
|
||||
latitude: finalLat,
|
||||
longitude: finalLng,
|
||||
createdAt: DateTime.now(),
|
||||
docType: 'Product',
|
||||
);
|
||||
|
||||
final localStorage = ref.read(localStorageProvider);
|
||||
await localStorage.saveDocument(finalDoc);
|
||||
|
||||
bool syncedToServer = false;
|
||||
String? syncError;
|
||||
try {
|
||||
await apiClient.client.put(
|
||||
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
|
||||
data: finalDoc.toPutPayload(),
|
||||
);
|
||||
syncedToServer = true;
|
||||
} catch (e) {
|
||||
syncError = ApiException.from(e).displayMessage;
|
||||
}
|
||||
|
||||
if (widget.pendingId != null) {
|
||||
if (syncedToServer) {
|
||||
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
|
||||
} else {
|
||||
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
|
||||
widget.pendingId!,
|
||||
finalDoc,
|
||||
'Belum tersinkron ke server: $syncError',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
syncedToServer
|
||||
? const SnackBar(content: Text('Hasil Pemindaian Produk Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
|
||||
: const SnackBar(
|
||||
content: Text('Tersimpan lokal, tapi GAGAL sinkron ke server. Cek koneksi lalu retry dari daftar dokumen.'),
|
||||
backgroundColor: Colors.deepOrange,
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
context.pop(true);
|
||||
}
|
||||
}
|
||||
part of 'product_editor_screen.dart';
|
||||
|
||||
mixin ProductEditorSubmitLogic on ProductEditorDataLogic {
|
||||
void _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
final activeMatch = _matches.firstWhere(
|
||||
(m) => m['no_sku'] == _selectedSku,
|
||||
orElse: () => _matches.isNotEmpty ? _matches.first : <String, dynamic>{'no_sku': '', 'nama_item': 'Unknown Product', 'confidence': 0.0},
|
||||
);
|
||||
final String productName = activeMatch['nama_item'] ?? 'Unknown Product';
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final currentStoreName = prefs.getString('nama_toko') ?? 'PM KELAPA DUA KARAWACI';
|
||||
|
||||
final pendingDocs = ref.read(pendingDocumentsProvider);
|
||||
final pendingDoc = pendingDocs.firstWhere(
|
||||
(doc) => doc.id == widget.pendingId,
|
||||
orElse: () => PendingDocument(id: '', imagePath: '')
|
||||
);
|
||||
final double? finalLat = pendingDoc.latitude ?? _document?.latitude;
|
||||
final double? finalLng = pendingDoc.longitude ?? _document?.longitude;
|
||||
|
||||
final saveAction = resolveDocumentSaveAction(
|
||||
existingDocument: _document,
|
||||
localImagePath: pendingDoc.imagePath,
|
||||
);
|
||||
|
||||
if (saveAction.kind == DocumentSaveActionKind.blocked) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(saveAction.blockedMessage!),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
String serverId;
|
||||
if (saveAction.kind == DocumentSaveActionKind.reuploadThenPut) {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'image': await MultipartFile.fromFile(
|
||||
pendingDoc.imagePath,
|
||||
filename: pendingDoc.imagePath.split(RegExp(r'[/\\]')).last,
|
||||
),
|
||||
if (finalLat != null) 'latitude': finalLat.toString(),
|
||||
if (finalLng != null) 'longitude': finalLng.toString(),
|
||||
'scan_mode': pendingDoc.scanMode ?? 'Product',
|
||||
});
|
||||
final response = await apiClient.client.post(AppConfig.uploadDocumentEndpoint, data: formData);
|
||||
serverId = DocumentModel.fromJson(response.data['data']).id;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Gagal membuat dokumen di server: ${ApiException.from(e).displayMessage}'),
|
||||
backgroundColor: AppConfig.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
serverId = saveAction.serverId!;
|
||||
}
|
||||
|
||||
final finalDoc = DocumentModel(
|
||||
id: serverId,
|
||||
tanggal: DateFormat('dd MMMM yyyy').format(DateTime.now()),
|
||||
// No PO-link concept for Product Scan documents anymore (the
|
||||
// "Hubungkan ke PO Dokumen" field was removed - user request).
|
||||
noPo: '',
|
||||
noSo: '1002003004',
|
||||
noDo: _isManualDate ? 'Batch Manual' : (_selectedBatchOption != null ? _selectedBatchOption!.split(': ')[0] : 'Batch 1'),
|
||||
kepadaYth: currentStoreName,
|
||||
orderUntuk: 'PRODUCT SCAN',
|
||||
alamat: 'Product Scan: Expired ${_expDateCtrl.text}',
|
||||
platTruk: 'B 1234 PFM',
|
||||
namaDriver: 'PRODUCT SCAN',
|
||||
namaPenerima: 'STORE STAFF',
|
||||
items: [DocumentItem(nomorSku: _selectedSku, namaBarang: productName, banyak: '1', jumlah: '1')],
|
||||
latitude: finalLat,
|
||||
longitude: finalLng,
|
||||
createdAt: DateTime.now(),
|
||||
docType: 'Product',
|
||||
);
|
||||
|
||||
final localStorage = ref.read(localStorageProvider);
|
||||
await localStorage.saveDocument(finalDoc);
|
||||
|
||||
bool syncedToServer = false;
|
||||
String? syncError;
|
||||
try {
|
||||
await apiClient.client.put(
|
||||
'${AppConfig.fetchDocumentsEndpoint}/${finalDoc.id}',
|
||||
data: finalDoc.toPutPayload(),
|
||||
);
|
||||
syncedToServer = true;
|
||||
} catch (e) {
|
||||
syncError = ApiException.from(e).displayMessage;
|
||||
}
|
||||
|
||||
if (widget.pendingId != null) {
|
||||
if (syncedToServer) {
|
||||
ref.read(pendingDocumentsProvider.notifier).removeDocument(widget.pendingId!);
|
||||
} else {
|
||||
ref.read(pendingDocumentsProvider.notifier).markSyncFailed(
|
||||
widget.pendingId!,
|
||||
finalDoc,
|
||||
'Belum tersinkron ke server: $syncError',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
syncedToServer
|
||||
? const SnackBar(content: Text('Hasil Pemindaian Produk Berhasil Disimpan!'), backgroundColor: AppConfig.successColor)
|
||||
: const SnackBar(
|
||||
content: Text('Tersimpan lokal, tapi GAGAL sinkron ke server. Cek koneksi lalu retry dari daftar dokumen.'),
|
||||
backgroundColor: Colors.deepOrange,
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
context.pop(true);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,41 @@
|
||||
/// Pure parsing helpers for the v1 product-scan endpoints
|
||||
/// (`GET /master/skus`, `POST /scan-product`), kept free of Flutter/network
|
||||
/// imports so the "unwrap the v1 envelope" logic is unit-testable without
|
||||
/// mocking Dio - the same pattern used by `document_sync_merge.dart`.
|
||||
library;
|
||||
|
||||
List<Map<String, dynamic>> parseSkuMasterList(dynamic responseData) {
|
||||
final List raw = responseData?['data'] ?? [];
|
||||
return raw.map((e) => Map<String, dynamic>.from(e as Map)).toList();
|
||||
}
|
||||
|
||||
class ScanProductResult {
|
||||
final List<Map<String, dynamic>> matches;
|
||||
final String extractedExpiryDate;
|
||||
|
||||
const ScanProductResult({
|
||||
required this.matches,
|
||||
required this.extractedExpiryDate,
|
||||
});
|
||||
}
|
||||
|
||||
ScanProductResult parseScanProductResponse(dynamic responseData) {
|
||||
final data = responseData?['data'] ?? {};
|
||||
final List rawMatches = data['possibleMatches'] ?? [];
|
||||
|
||||
final matches = rawMatches.map<Map<String, dynamic>>((m) {
|
||||
return <String, dynamic>{
|
||||
'no_sku': m['no_sku']?.toString() ?? '',
|
||||
'nama_item': m['nama_item']?.toString() ?? '',
|
||||
'confidence': (m['score'] as num?)?.toDouble() ?? 0.0,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final ocr = data['ocr'] ?? {};
|
||||
final extractedExpiryDate = ocr['extracted_expired_date']?.toString() ?? '';
|
||||
|
||||
return ScanProductResult(
|
||||
matches: matches,
|
||||
extractedExpiryDate: extractedExpiryDate,
|
||||
);
|
||||
}
|
||||
/// Pure parsing helpers for the v1 product-scan endpoints
|
||||
/// (`GET /master/skus`, `POST /scan-product`), kept free of Flutter/network
|
||||
/// imports so the "unwrap the v1 envelope" logic is unit-testable without
|
||||
/// mocking Dio - the same pattern used by `document_sync_merge.dart`.
|
||||
library;
|
||||
|
||||
List<Map<String, dynamic>> parseSkuMasterList(dynamic responseData) {
|
||||
final List raw = responseData?['data'] ?? [];
|
||||
return raw.map((e) => Map<String, dynamic>.from(e as Map)).toList();
|
||||
}
|
||||
|
||||
class ScanProductResult {
|
||||
final List<Map<String, dynamic>> matches;
|
||||
final String extractedExpiryDate;
|
||||
|
||||
const ScanProductResult({
|
||||
required this.matches,
|
||||
required this.extractedExpiryDate,
|
||||
});
|
||||
}
|
||||
|
||||
ScanProductResult parseScanProductResponse(dynamic responseData) {
|
||||
final data = responseData?['data'] ?? {};
|
||||
final List rawMatches = data['possibleMatches'] ?? [];
|
||||
|
||||
final matches = rawMatches.map<Map<String, dynamic>>((m) {
|
||||
return <String, dynamic>{
|
||||
'no_sku': m['no_sku']?.toString() ?? '',
|
||||
'nama_item': m['nama_item']?.toString() ?? '',
|
||||
'confidence': (m['score'] as num?)?.toDouble() ?? 0.0,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final ocr = data['ocr'] ?? {};
|
||||
final extractedExpiryDate = ocr['extracted_expired_date']?.toString() ?? '';
|
||||
|
||||
return ScanProductResult(
|
||||
matches: matches,
|
||||
extractedExpiryDate: extractedExpiryDate,
|
||||
);
|
||||
}
|
||||
@@ -1,101 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class ConfirmationDialog extends StatefulWidget {
|
||||
final TextEditingController nameController;
|
||||
final bool initialAgreed;
|
||||
final void Function(bool, String) onConfirm;
|
||||
|
||||
const ConfirmationDialog({
|
||||
super.key,
|
||||
required this.nameController,
|
||||
required this.initialAgreed,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConfirmationDialog> createState() => _ConfirmationDialogState();
|
||||
}
|
||||
|
||||
class _ConfirmationDialogState extends State<ConfirmationDialog> {
|
||||
late bool _isAgreed;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isAgreed = widget.initialAgreed;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isButtonEnabled = _isAgreed && widget.nameController.text.trim().isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
left: AppConfig.spaceXL,
|
||||
right: AppConfig.spaceXL,
|
||||
top: AppConfig.spaceXL,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Konfirmasi Pengiriman', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Text(
|
||||
'Masukkan nama Anda (sebagai penerima/penjaga toko) dan centang kotak persetujuan untuk mengonfirmasi pengiriman.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
TextField(
|
||||
controller: widget.nameController,
|
||||
onChanged: (val) {
|
||||
setState(() {});
|
||||
},
|
||||
style: AppConfig.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nama Penerima (Penjaga Toko)',
|
||||
labelStyle: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _isAgreed,
|
||||
activeColor: AppConfig.primaryColor,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_isAgreed = val ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text('Saya setuju dan mengonfirmasi bahwa data ini sudah benar.', style: AppConfig.bodyMedium),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: isButtonEnabled ? () {
|
||||
widget.onConfirm(_isAgreed, widget.nameController.text.trim());
|
||||
} : null,
|
||||
child: const Text('Konfirmasi'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class ConfirmationDialog extends StatefulWidget {
|
||||
final TextEditingController nameController;
|
||||
final bool initialAgreed;
|
||||
final void Function(bool, String) onConfirm;
|
||||
|
||||
const ConfirmationDialog({
|
||||
super.key,
|
||||
required this.nameController,
|
||||
required this.initialAgreed,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConfirmationDialog> createState() => _ConfirmationDialogState();
|
||||
}
|
||||
|
||||
class _ConfirmationDialogState extends State<ConfirmationDialog> {
|
||||
late bool _isAgreed;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isAgreed = widget.initialAgreed;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isButtonEnabled = _isAgreed && widget.nameController.text.trim().isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
left: AppConfig.spaceXL,
|
||||
right: AppConfig.spaceXL,
|
||||
top: AppConfig.spaceXL,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Konfirmasi Pengiriman', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Text(
|
||||
'Masukkan nama Anda (sebagai penerima/penjaga toko) dan centang kotak persetujuan untuk mengonfirmasi pengiriman.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
TextField(
|
||||
controller: widget.nameController,
|
||||
onChanged: (val) {
|
||||
setState(() {});
|
||||
},
|
||||
style: AppConfig.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nama Penerima (Penjaga Toko)',
|
||||
labelStyle: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _isAgreed,
|
||||
activeColor: AppConfig.primaryColor,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_isAgreed = val ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text('Saya setuju dan mengonfirmasi bahwa data ini sudah benar.', style: AppConfig.bodyMedium),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: isButtonEnabled ? () {
|
||||
widget.onConfirm(_isAgreed, widget.nameController.text.trim());
|
||||
} : null,
|
||||
child: const Text('Konfirmasi'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class EditorTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final TextInputType keyboardType;
|
||||
final bool readOnly;
|
||||
final VoidCallback? onTap;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
const EditorTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.readOnly = false,
|
||||
this.onTap,
|
||||
this.inputFormatters,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceLarge),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
inputFormatters: inputFormatters,
|
||||
style: AppConfig.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: const BorderSide(color: AppConfig.primaryColor),
|
||||
),
|
||||
),
|
||||
validator: validator ?? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$label harus diisi';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class EditorTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final TextInputType keyboardType;
|
||||
final bool readOnly;
|
||||
final VoidCallback? onTap;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
const EditorTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.readOnly = false,
|
||||
this.onTap,
|
||||
this.inputFormatters,
|
||||
this.validator,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceLarge),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
inputFormatters: inputFormatters,
|
||||
style: AppConfig.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
borderSide: const BorderSide(color: AppConfig.primaryColor),
|
||||
),
|
||||
),
|
||||
validator: validator ?? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$label harus diisi';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class HeaderDetailsCard extends StatelessWidget {
|
||||
final TextEditingController tanggalCtrl;
|
||||
final TextEditingController noPoCtrl;
|
||||
final TextEditingController noSoCtrl;
|
||||
final TextEditingController noDoCtrl;
|
||||
|
||||
const HeaderDetailsCard({
|
||||
super.key,
|
||||
required this.tanggalCtrl,
|
||||
required this.noPoCtrl,
|
||||
required this.noSoCtrl,
|
||||
required this.noDoCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Header', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(
|
||||
label: 'Tanggal',
|
||||
controller: tanggalCtrl,
|
||||
readOnly: true,
|
||||
onTap: () async {
|
||||
DateTime? pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2101),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: AppConfig.primaryColor,
|
||||
onPrimary: Colors.white,
|
||||
onSurface: AppConfig.textPrimary,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (pickedDate != null) {
|
||||
String formattedDate = DateFormat('dd MMMM yyyy').format(pickedDate);
|
||||
tanggalCtrl.text = formattedDate;
|
||||
}
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'No PO',
|
||||
controller: noPoCtrl,
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) return 'No PO harus diisi';
|
||||
if (!RegExp(r'^PO/\d{2}/\d{10}$').hasMatch(val)) {
|
||||
return 'Format harus PO/[XX]/[XXXXXXXXXX]';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'No SO',
|
||||
controller: noSoCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) return 'No SO harus diisi';
|
||||
if (val.length != 10) {
|
||||
return 'SO harus persis 10 angka';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
EditorTextField(label: 'No DO', controller: noDoCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class HeaderDetailsCard extends StatelessWidget {
|
||||
final TextEditingController tanggalCtrl;
|
||||
final TextEditingController noPoCtrl;
|
||||
final TextEditingController noSoCtrl;
|
||||
final TextEditingController noDoCtrl;
|
||||
|
||||
const HeaderDetailsCard({
|
||||
super.key,
|
||||
required this.tanggalCtrl,
|
||||
required this.noPoCtrl,
|
||||
required this.noSoCtrl,
|
||||
required this.noDoCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Header', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(
|
||||
label: 'Tanggal',
|
||||
controller: tanggalCtrl,
|
||||
readOnly: true,
|
||||
onTap: () async {
|
||||
DateTime? pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2101),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: AppConfig.primaryColor,
|
||||
onPrimary: Colors.white,
|
||||
onSurface: AppConfig.textPrimary,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (pickedDate != null) {
|
||||
String formattedDate = DateFormat('dd MMMM yyyy').format(pickedDate);
|
||||
tanggalCtrl.text = formattedDate;
|
||||
}
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'No PO',
|
||||
controller: noPoCtrl,
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) return 'No PO harus diisi';
|
||||
if (!RegExp(r'^PO/\d{2}/\d{10}$').hasMatch(val)) {
|
||||
return 'Format harus PO/[XX]/[XXXXXXXXXX]';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'No SO',
|
||||
controller: noSoCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) return 'No SO harus diisi';
|
||||
if (val.length != 10) {
|
||||
return 'SO harus persis 10 angka';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
EditorTextField(label: 'No DO', controller: noDoCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/master_sku.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class ItemsListCard extends StatelessWidget {
|
||||
final List<Map<String, TextEditingController>> itemControllers;
|
||||
final void Function(int) onRemoveItem;
|
||||
final VoidCallback onAddItem;
|
||||
|
||||
const ItemsListCard({
|
||||
super.key,
|
||||
required this.itemControllers,
|
||||
required this.onRemoveItem,
|
||||
required this.onAddItem,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Barang (${itemControllers.length})', style: AppConfig.h2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
...itemControllers.asMap().entries.map((entry) {
|
||||
int index = entry.key;
|
||||
var ctrls = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceLarge),
|
||||
child: Container(
|
||||
padding: AppConfig.paddingMedium,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: ctrls['nama']!,
|
||||
builder: (context, value, child) {
|
||||
return Text(
|
||||
value.text.isNotEmpty ? value.text : 'Item Baru',
|
||||
style: AppConfig.h3,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onRemoveItem(index),
|
||||
tooltip: 'Hapus Barang',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
EditorTextField(
|
||||
label: 'SKU',
|
||||
controller: ctrls['sku']!,
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) {
|
||||
return 'SKU harus diisi';
|
||||
}
|
||||
if (!MasterSku.data.containsKey(val)) {
|
||||
return 'SKU tidak terdaftar';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'Nama Barang',
|
||||
controller: ctrls['nama']!,
|
||||
readOnly: true,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: EditorTextField(
|
||||
label: 'Banyak',
|
||||
controller: ctrls['banyak']!,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
Expanded(
|
||||
child: EditorTextField(
|
||||
label: 'Jumlah',
|
||||
controller: ctrls['jumlah']!,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onAddItem,
|
||||
icon: const Icon(Icons.add, color: AppConfig.primaryColor),
|
||||
label: const Text(
|
||||
'Tambah Barang',
|
||||
style: TextStyle(
|
||||
color: AppConfig.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppConfig.primaryColor),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: AppConfig.spaceMedium),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../data/master_sku.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class ItemsListCard extends StatelessWidget {
|
||||
final List<Map<String, TextEditingController>> itemControllers;
|
||||
final void Function(int) onRemoveItem;
|
||||
final VoidCallback onAddItem;
|
||||
|
||||
const ItemsListCard({
|
||||
super.key,
|
||||
required this.itemControllers,
|
||||
required this.onRemoveItem,
|
||||
required this.onAddItem,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Barang (${itemControllers.length})', style: AppConfig.h2),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
...itemControllers.asMap().entries.map((entry) {
|
||||
int index = entry.key;
|
||||
var ctrls = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppConfig.spaceLarge),
|
||||
child: Container(
|
||||
padding: AppConfig.paddingMedium,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: ctrls['nama']!,
|
||||
builder: (context, value, child) {
|
||||
return Text(
|
||||
value.text.isNotEmpty ? value.text : 'Item Baru',
|
||||
style: AppConfig.h3,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
onPressed: () => onRemoveItem(index),
|
||||
tooltip: 'Hapus Barang',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
EditorTextField(
|
||||
label: 'SKU',
|
||||
controller: ctrls['sku']!,
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) {
|
||||
return 'SKU harus diisi';
|
||||
}
|
||||
if (!MasterSku.data.containsKey(val)) {
|
||||
return 'SKU tidak terdaftar';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'Nama Barang',
|
||||
controller: ctrls['nama']!,
|
||||
readOnly: true,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: EditorTextField(
|
||||
label: 'Banyak',
|
||||
controller: ctrls['banyak']!,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppConfig.spaceMedium),
|
||||
Expanded(
|
||||
child: EditorTextField(
|
||||
label: 'Jumlah',
|
||||
controller: ctrls['jumlah']!,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onAddItem,
|
||||
icon: const Icon(Icons.add, color: AppConfig.primaryColor),
|
||||
label: const Text(
|
||||
'Tambah Barang',
|
||||
style: TextStyle(
|
||||
color: AppConfig.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppConfig.primaryColor),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusSmall),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: AppConfig.spaceMedium),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class LocationDetailsCard extends StatelessWidget {
|
||||
final TextEditingController latitudeCtrl;
|
||||
final TextEditingController longitudeCtrl;
|
||||
|
||||
const LocationDetailsCard({
|
||||
super.key,
|
||||
required this.latitudeCtrl,
|
||||
required this.longitudeCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Lokasi', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(
|
||||
label: 'Lintang',
|
||||
controller: latitudeCtrl,
|
||||
readOnly: true,
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'Bujur',
|
||||
controller: longitudeCtrl,
|
||||
readOnly: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class LocationDetailsCard extends StatelessWidget {
|
||||
final TextEditingController latitudeCtrl;
|
||||
final TextEditingController longitudeCtrl;
|
||||
|
||||
const LocationDetailsCard({
|
||||
super.key,
|
||||
required this.latitudeCtrl,
|
||||
required this.longitudeCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Lokasi', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(
|
||||
label: 'Lintang',
|
||||
controller: latitudeCtrl,
|
||||
readOnly: true,
|
||||
),
|
||||
EditorTextField(
|
||||
label: 'Bujur',
|
||||
controller: longitudeCtrl,
|
||||
readOnly: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,111 +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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class ShipmentDetailsCard extends StatelessWidget {
|
||||
final TextEditingController kepadaYthCtrl;
|
||||
final TextEditingController orderUntukCtrl;
|
||||
final TextEditingController alamatCtrl;
|
||||
final TextEditingController platTrukCtrl;
|
||||
final TextEditingController namaDriverCtrl;
|
||||
|
||||
const ShipmentDetailsCard({
|
||||
super.key,
|
||||
required this.kepadaYthCtrl,
|
||||
required this.orderUntukCtrl,
|
||||
required this.alamatCtrl,
|
||||
required this.platTrukCtrl,
|
||||
required this.namaDriverCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Pengiriman', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(label: 'Kepada Yth', controller: kepadaYthCtrl),
|
||||
EditorTextField(label: 'Order Untuk', controller: orderUntukCtrl),
|
||||
EditorTextField(label: 'Alamat', controller: alamatCtrl),
|
||||
EditorTextField(label: 'Plat Truk', controller: platTrukCtrl),
|
||||
EditorTextField(label: 'Nama Driver', controller: namaDriverCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../config/app_config.dart';
|
||||
import 'editor_text_field.dart';
|
||||
|
||||
class ShipmentDetailsCard extends StatelessWidget {
|
||||
final TextEditingController kepadaYthCtrl;
|
||||
final TextEditingController orderUntukCtrl;
|
||||
final TextEditingController alamatCtrl;
|
||||
final TextEditingController platTrukCtrl;
|
||||
final TextEditingController namaDriverCtrl;
|
||||
|
||||
const ShipmentDetailsCard({
|
||||
super.key,
|
||||
required this.kepadaYthCtrl,
|
||||
required this.orderUntukCtrl,
|
||||
required this.alamatCtrl,
|
||||
required this.platTrukCtrl,
|
||||
required this.namaDriverCtrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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('Detail Pengiriman', style: AppConfig.h2),
|
||||
const SizedBox(height: AppConfig.spaceLarge),
|
||||
EditorTextField(label: 'Kepada Yth', controller: kepadaYthCtrl),
|
||||
EditorTextField(label: 'Order Untuk', controller: orderUntukCtrl),
|
||||
EditorTextField(label: 'Alamat', controller: alamatCtrl),
|
||||
EditorTextField(label: 'Plat Truk', controller: platTrukCtrl),
|
||||
EditorTextField(label: 'Nama Driver', controller: namaDriverCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
|
||||
class SplashScreen extends ConsumerStatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends ConsumerState<SplashScreen> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(_controller);
|
||||
|
||||
_controller.forward().then((_) {
|
||||
_checkAuthAndNavigate();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkAuthAndNavigate() async {
|
||||
// Wait an additional second for effect
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!mounted) return;
|
||||
|
||||
final isLoggedIn = await ref.read(authProvider.notifier).checkLoginState();
|
||||
if (isLoggedIn) {
|
||||
context.go('/camera');
|
||||
} else {
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white, // Changed to white to seamlessly blend with the logo
|
||||
body: Center(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/logo.png',
|
||||
width: 150,
|
||||
height: 150,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
Icons.storefront,
|
||||
size: 100,
|
||||
color: AppConfig.primaryColor // Changed fallback icon color
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
AppConfig.appName,
|
||||
style: AppConfig.h1.copyWith(color: AppConfig.primaryColor), // Red text
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
Text(
|
||||
'PT. Primafood International',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary), // Grey text
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
|
||||
class SplashScreen extends ConsumerStatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends ConsumerState<SplashScreen> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(_controller);
|
||||
|
||||
_controller.forward().then((_) {
|
||||
_checkAuthAndNavigate();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkAuthAndNavigate() async {
|
||||
// Wait an additional second for effect
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (!mounted) return;
|
||||
|
||||
final isLoggedIn = await ref.read(authProvider.notifier).checkLoginState();
|
||||
if (isLoggedIn) {
|
||||
context.go('/camera');
|
||||
} else {
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white, // Changed to white to seamlessly blend with the logo
|
||||
body: Center(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/logo.png',
|
||||
width: 150,
|
||||
height: 150,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
Icons.storefront,
|
||||
size: 100,
|
||||
color: AppConfig.primaryColor // Changed fallback icon color
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
AppConfig.appName,
|
||||
style: AppConfig.h1.copyWith(color: AppConfig.primaryColor), // Red text
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
Text(
|
||||
'PT. Primafood International',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary), // Grey text
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+46
-46
@@ -1,46 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'config/app_config.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/storage/local_storage.dart';
|
||||
|
||||
// Provider for Local Storage
|
||||
final localStorageProvider = Provider<LocalStorage>((ref) {
|
||||
throw UnimplementedError('localStorageProvider not initialized');
|
||||
});
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Resolve API Base URL (Checking Ngrok availability vs Local IP)
|
||||
await AppConfig.initializeApiBaseUrl();
|
||||
|
||||
// Initialize Local Storage (Hive)
|
||||
final localStorage = LocalStorage();
|
||||
await localStorage.init();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
localStorageProvider.overrideWithValue(localStorage),
|
||||
],
|
||||
child: const MainApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MainApp extends ConsumerWidget {
|
||||
const MainApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: AppConfig.appName,
|
||||
theme: AppConfig.lightTheme,
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'config/app_config.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/storage/local_storage.dart';
|
||||
|
||||
// Provider for Local Storage
|
||||
final localStorageProvider = Provider<LocalStorage>((ref) {
|
||||
throw UnimplementedError('localStorageProvider not initialized');
|
||||
});
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Resolve API Base URL (Checking Ngrok availability vs Local IP)
|
||||
await AppConfig.initializeApiBaseUrl();
|
||||
|
||||
// Initialize Local Storage (Hive)
|
||||
final localStorage = LocalStorage();
|
||||
await localStorage.init();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
localStorageProvider.overrideWithValue(localStorage),
|
||||
],
|
||||
child: const MainApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MainApp extends ConsumerWidget {
|
||||
const MainApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: AppConfig.appName,
|
||||
theme: AppConfig.lightTheme,
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
+202
-202
@@ -1,202 +1,202 @@
|
||||
class DocumentModel {
|
||||
final String id;
|
||||
final String tanggal;
|
||||
final String noPo;
|
||||
final String noSo;
|
||||
final String noDo;
|
||||
final String kepadaYth;
|
||||
final String orderUntuk;
|
||||
final String alamat;
|
||||
final String platTruk;
|
||||
final String namaDriver;
|
||||
final String namaPenerima;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final List<DocumentItem> items;
|
||||
final DateTime createdAt;
|
||||
final bool parsed;
|
||||
|
||||
/// 'pending' | 'done' | 'failed', sourced from the backend's `parseStatus`
|
||||
/// (backend task 9.1, `document-mapper.ts`) - only populated by
|
||||
/// `GET /api/v1/documents/:id` (see `pending_documents_provider.dart`'s
|
||||
/// `_pollUntilParsed`, which polls that route instead of scanning the
|
||||
/// whole list). Null for responses that don't include it (e.g. the PUT
|
||||
/// route's own hand-built response, or cached Hive rows).
|
||||
final String? parseStatus;
|
||||
|
||||
/// 'DO' or 'Product', sourced from the backend's persisted `scan_mode`
|
||||
/// column (see backend task 9.1, `document-mapper.ts`'s `docType`) - the
|
||||
/// real replacement for the `orderUntuk == 'PRODUCT SCAN'` sentinel that
|
||||
/// every call site used to check (editing that display field used to
|
||||
/// silently move a document between tabs).
|
||||
final String docType;
|
||||
|
||||
/// True once the user has confirmed this document (`PUT /documents/:id`),
|
||||
/// sourced from the backend's `confirmed` column (backend task 10.1) -
|
||||
/// separates "OCR finished" (`parsed`) from "user confirmed," since a
|
||||
/// document only re-enters `GET /api/v1/documents` (and this field is only
|
||||
/// meaningfully `false`) once confirmed. Defaults to `true` for
|
||||
/// legacy/cached responses that predate the column - see
|
||||
/// docs/api-contract-map.md G11.
|
||||
final bool confirmed;
|
||||
|
||||
/// Top-5 SKU candidates from the single classify+OCR pass the backend runs
|
||||
/// at upload time for Product Scan documents (backend task on gap G3,
|
||||
/// `document-mapper.ts`'s `productScan.possibleMatches`) - lets the product
|
||||
/// editor render immediately instead of re-running the GPU pipeline a
|
||||
/// second time on review. Empty for DO documents, or Product documents
|
||||
/// parsed before this existed (already-confirmed docs also lose it, since
|
||||
/// it's only needed during the initial review).
|
||||
final List<Map<String, dynamic>> productScanMatches;
|
||||
|
||||
/// OCR-extracted expiry date from that same single pass
|
||||
/// (`productScan.extractedExpiryDate`). Empty string when absent/not found.
|
||||
final String productScanExtractedExpiryDate;
|
||||
|
||||
DocumentModel({
|
||||
required this.id,
|
||||
required this.tanggal,
|
||||
required this.noPo,
|
||||
required this.noSo,
|
||||
required this.noDo,
|
||||
required this.kepadaYth,
|
||||
required this.orderUntuk,
|
||||
required this.alamat,
|
||||
required this.platTruk,
|
||||
required this.namaDriver,
|
||||
required this.namaPenerima,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.items,
|
||||
required this.createdAt,
|
||||
this.parsed = false,
|
||||
this.parseStatus,
|
||||
this.docType = 'DO',
|
||||
this.confirmed = true,
|
||||
this.productScanMatches = const [],
|
||||
this.productScanExtractedExpiryDate = '',
|
||||
});
|
||||
|
||||
factory DocumentModel.fromJson(Map<String, dynamic> json) {
|
||||
var header = json['header'] ?? {};
|
||||
var shipment = json['shipment'] ?? {};
|
||||
var itemsList = json['items'] as List? ?? [];
|
||||
|
||||
return DocumentModel(
|
||||
id: json['id'] ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
tanggal: header['tanggal']?.toString() ?? '',
|
||||
noPo: header['no_po']?.toString() ?? '',
|
||||
noSo: header['no_so']?.toString() ?? '',
|
||||
noDo: header['no_do']?.toString() ?? '',
|
||||
kepadaYth: shipment['kepada_yth']?.toString() ?? '',
|
||||
orderUntuk: shipment['order_untuk']?.toString() ?? '',
|
||||
alamat: shipment['alamat']?.toString() ?? '',
|
||||
platTruk: shipment['plat_truk']?.toString() ?? '',
|
||||
namaDriver: json['nama_driver']?.toString() ?? shipment['nama_driver']?.toString() ?? '',
|
||||
namaPenerima: json['nama_penerima']?.toString() ?? shipment['nama_penerima']?.toString() ?? '',
|
||||
latitude: json['latitude'] != null ? double.tryParse(json['latitude'].toString()) : null,
|
||||
longitude: json['longitude'] != null ? double.tryParse(json['longitude'].toString()) : null,
|
||||
items: itemsList.map((e) => DocumentItem.fromJson(Map<String, dynamic>.from(e))).toList(),
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'].toString())
|
||||
: (json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : DateTime.now()),
|
||||
parsed: json['parsed'] as bool? ?? false,
|
||||
parseStatus: json['parseStatus']?.toString(),
|
||||
// Prefer the backend's persisted docType (backend task 9.1). Fall back
|
||||
// to the legacy orderUntuk sentinel for responses/cached rows that
|
||||
// predate that column (e.g. the upload endpoint's fresh-upload stub,
|
||||
// or Hive entries saved before this field existed) - mirrors the same
|
||||
// fallback backend/pfm-web-app/src/utils/document-mapper.ts uses.
|
||||
docType: json['docType']?.toString() ??
|
||||
(shipment['order_untuk'] == 'PRODUCT SCAN' ? 'Product' : 'DO'),
|
||||
confirmed: json['confirmed'] as bool? ?? true,
|
||||
productScanMatches: ((json['productScan'] as Map<String, dynamic>?)?['possibleMatches'] as List?)
|
||||
?.map((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList() ??
|
||||
const [],
|
||||
productScanExtractedExpiryDate:
|
||||
(json['productScan'] as Map<String, dynamic>?)?['extractedExpiryDate']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Round-trips with [fromJson]. Used to persist a document to Hive, both for
|
||||
/// the confirmed-documents box and the in-flight pending-documents queue.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'nama_driver': namaDriver,
|
||||
'nama_penerima': namaPenerima,
|
||||
'parsed': parsed,
|
||||
'docType': docType,
|
||||
'header': {
|
||||
'tanggal': tanggal,
|
||||
'no_po': noPo,
|
||||
'no_so': noSo,
|
||||
'no_do': noDo,
|
||||
},
|
||||
'shipment': {
|
||||
'kepada_yth': kepadaYth,
|
||||
'order_untuk': orderUntuk,
|
||||
'alamat': alamat,
|
||||
'plat_truk': platTruk,
|
||||
},
|
||||
'items': items.map((e) => <String, dynamic>{
|
||||
'nomor_sku': e.nomorSku,
|
||||
'nama_barang': e.namaBarang,
|
||||
'banyak': e.banyak,
|
||||
'jumlah': e.jumlah,
|
||||
}).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Body for `PUT /api/v1/documents/:id`. Shared by the editor's initial save and
|
||||
/// the pending-queue's sync retry so both send an identical payload shape.
|
||||
Map<String, dynamic> toPutPayload() {
|
||||
return {
|
||||
'tanggal': tanggal,
|
||||
'noPo': noPo,
|
||||
'noSo': noSo,
|
||||
'noDo': noDo,
|
||||
'kepadaYth': kepadaYth,
|
||||
'orderUntuk': orderUntuk,
|
||||
'alamat': alamat,
|
||||
'platTruk': platTruk,
|
||||
'namaDriver': namaDriver,
|
||||
'namaPenerima': namaPenerima,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'items': items.map((e) => <String, dynamic>{
|
||||
'nomor_sku': e.nomorSku,
|
||||
'nama_barang': e.namaBarang,
|
||||
'banyak': e.banyak,
|
||||
'jumlah': e.jumlah,
|
||||
}).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentItem {
|
||||
final String nomorSku;
|
||||
final String namaBarang;
|
||||
final String banyak;
|
||||
final String jumlah;
|
||||
|
||||
DocumentItem({
|
||||
required this.nomorSku,
|
||||
required this.namaBarang,
|
||||
required this.banyak,
|
||||
required this.jumlah,
|
||||
});
|
||||
|
||||
factory DocumentItem.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentItem(
|
||||
nomorSku: json['nomor_sku']?.toString() ?? '',
|
||||
namaBarang: json['nama_barang']?.toString() ?? '',
|
||||
banyak: json['banyak']?.toString() ?? '',
|
||||
jumlah: json['jumlah']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
class DocumentModel {
|
||||
final String id;
|
||||
final String tanggal;
|
||||
final String noPo;
|
||||
final String noSo;
|
||||
final String noDo;
|
||||
final String kepadaYth;
|
||||
final String orderUntuk;
|
||||
final String alamat;
|
||||
final String platTruk;
|
||||
final String namaDriver;
|
||||
final String namaPenerima;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final List<DocumentItem> items;
|
||||
final DateTime createdAt;
|
||||
final bool parsed;
|
||||
|
||||
/// 'pending' | 'done' | 'failed', sourced from the backend's `parseStatus`
|
||||
/// (backend task 9.1, `document-mapper.ts`) - only populated by
|
||||
/// `GET /api/v1/documents/:id` (see `pending_documents_provider.dart`'s
|
||||
/// `_pollUntilParsed`, which polls that route instead of scanning the
|
||||
/// whole list). Null for responses that don't include it (e.g. the PUT
|
||||
/// route's own hand-built response, or cached Hive rows).
|
||||
final String? parseStatus;
|
||||
|
||||
/// 'DO' or 'Product', sourced from the backend's persisted `scan_mode`
|
||||
/// column (see backend task 9.1, `document-mapper.ts`'s `docType`) - the
|
||||
/// real replacement for the `orderUntuk == 'PRODUCT SCAN'` sentinel that
|
||||
/// every call site used to check (editing that display field used to
|
||||
/// silently move a document between tabs).
|
||||
final String docType;
|
||||
|
||||
/// True once the user has confirmed this document (`PUT /documents/:id`),
|
||||
/// sourced from the backend's `confirmed` column (backend task 10.1) -
|
||||
/// separates "OCR finished" (`parsed`) from "user confirmed," since a
|
||||
/// document only re-enters `GET /api/v1/documents` (and this field is only
|
||||
/// meaningfully `false`) once confirmed. Defaults to `true` for
|
||||
/// legacy/cached responses that predate the column - see
|
||||
/// docs/api-contract-map.md G11.
|
||||
final bool confirmed;
|
||||
|
||||
/// Top-5 SKU candidates from the single classify+OCR pass the backend runs
|
||||
/// at upload time for Product Scan documents (backend task on gap G3,
|
||||
/// `document-mapper.ts`'s `productScan.possibleMatches`) - lets the product
|
||||
/// editor render immediately instead of re-running the GPU pipeline a
|
||||
/// second time on review. Empty for DO documents, or Product documents
|
||||
/// parsed before this existed (already-confirmed docs also lose it, since
|
||||
/// it's only needed during the initial review).
|
||||
final List<Map<String, dynamic>> productScanMatches;
|
||||
|
||||
/// OCR-extracted expiry date from that same single pass
|
||||
/// (`productScan.extractedExpiryDate`). Empty string when absent/not found.
|
||||
final String productScanExtractedExpiryDate;
|
||||
|
||||
DocumentModel({
|
||||
required this.id,
|
||||
required this.tanggal,
|
||||
required this.noPo,
|
||||
required this.noSo,
|
||||
required this.noDo,
|
||||
required this.kepadaYth,
|
||||
required this.orderUntuk,
|
||||
required this.alamat,
|
||||
required this.platTruk,
|
||||
required this.namaDriver,
|
||||
required this.namaPenerima,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.items,
|
||||
required this.createdAt,
|
||||
this.parsed = false,
|
||||
this.parseStatus,
|
||||
this.docType = 'DO',
|
||||
this.confirmed = true,
|
||||
this.productScanMatches = const [],
|
||||
this.productScanExtractedExpiryDate = '',
|
||||
});
|
||||
|
||||
factory DocumentModel.fromJson(Map<String, dynamic> json) {
|
||||
var header = json['header'] ?? {};
|
||||
var shipment = json['shipment'] ?? {};
|
||||
var itemsList = json['items'] as List? ?? [];
|
||||
|
||||
return DocumentModel(
|
||||
id: json['id'] ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
tanggal: header['tanggal']?.toString() ?? '',
|
||||
noPo: header['no_po']?.toString() ?? '',
|
||||
noSo: header['no_so']?.toString() ?? '',
|
||||
noDo: header['no_do']?.toString() ?? '',
|
||||
kepadaYth: shipment['kepada_yth']?.toString() ?? '',
|
||||
orderUntuk: shipment['order_untuk']?.toString() ?? '',
|
||||
alamat: shipment['alamat']?.toString() ?? '',
|
||||
platTruk: shipment['plat_truk']?.toString() ?? '',
|
||||
namaDriver: json['nama_driver']?.toString() ?? shipment['nama_driver']?.toString() ?? '',
|
||||
namaPenerima: json['nama_penerima']?.toString() ?? shipment['nama_penerima']?.toString() ?? '',
|
||||
latitude: json['latitude'] != null ? double.tryParse(json['latitude'].toString()) : null,
|
||||
longitude: json['longitude'] != null ? double.tryParse(json['longitude'].toString()) : null,
|
||||
items: itemsList.map((e) => DocumentItem.fromJson(Map<String, dynamic>.from(e))).toList(),
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'].toString())
|
||||
: (json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : DateTime.now()),
|
||||
parsed: json['parsed'] as bool? ?? false,
|
||||
parseStatus: json['parseStatus']?.toString(),
|
||||
// Prefer the backend's persisted docType (backend task 9.1). Fall back
|
||||
// to the legacy orderUntuk sentinel for responses/cached rows that
|
||||
// predate that column (e.g. the upload endpoint's fresh-upload stub,
|
||||
// or Hive entries saved before this field existed) - mirrors the same
|
||||
// fallback backend/pfm-web-app/src/utils/document-mapper.ts uses.
|
||||
docType: json['docType']?.toString() ??
|
||||
(shipment['order_untuk'] == 'PRODUCT SCAN' ? 'Product' : 'DO'),
|
||||
confirmed: json['confirmed'] as bool? ?? true,
|
||||
productScanMatches: ((json['productScan'] as Map<String, dynamic>?)?['possibleMatches'] as List?)
|
||||
?.map((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList() ??
|
||||
const [],
|
||||
productScanExtractedExpiryDate:
|
||||
(json['productScan'] as Map<String, dynamic>?)?['extractedExpiryDate']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Round-trips with [fromJson]. Used to persist a document to Hive, both for
|
||||
/// the confirmed-documents box and the in-flight pending-documents queue.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'nama_driver': namaDriver,
|
||||
'nama_penerima': namaPenerima,
|
||||
'parsed': parsed,
|
||||
'docType': docType,
|
||||
'header': {
|
||||
'tanggal': tanggal,
|
||||
'no_po': noPo,
|
||||
'no_so': noSo,
|
||||
'no_do': noDo,
|
||||
},
|
||||
'shipment': {
|
||||
'kepada_yth': kepadaYth,
|
||||
'order_untuk': orderUntuk,
|
||||
'alamat': alamat,
|
||||
'plat_truk': platTruk,
|
||||
},
|
||||
'items': items.map((e) => <String, dynamic>{
|
||||
'nomor_sku': e.nomorSku,
|
||||
'nama_barang': e.namaBarang,
|
||||
'banyak': e.banyak,
|
||||
'jumlah': e.jumlah,
|
||||
}).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Body for `PUT /api/v1/documents/:id`. Shared by the editor's initial save and
|
||||
/// the pending-queue's sync retry so both send an identical payload shape.
|
||||
Map<String, dynamic> toPutPayload() {
|
||||
return {
|
||||
'tanggal': tanggal,
|
||||
'noPo': noPo,
|
||||
'noSo': noSo,
|
||||
'noDo': noDo,
|
||||
'kepadaYth': kepadaYth,
|
||||
'orderUntuk': orderUntuk,
|
||||
'alamat': alamat,
|
||||
'platTruk': platTruk,
|
||||
'namaDriver': namaDriver,
|
||||
'namaPenerima': namaPenerima,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'items': items.map((e) => <String, dynamic>{
|
||||
'nomor_sku': e.nomorSku,
|
||||
'nama_barang': e.namaBarang,
|
||||
'banyak': e.banyak,
|
||||
'jumlah': e.jumlah,
|
||||
}).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentItem {
|
||||
final String nomorSku;
|
||||
final String namaBarang;
|
||||
final String banyak;
|
||||
final String jumlah;
|
||||
|
||||
DocumentItem({
|
||||
required this.nomorSku,
|
||||
required this.namaBarang,
|
||||
required this.banyak,
|
||||
required this.jumlah,
|
||||
});
|
||||
|
||||
factory DocumentItem.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentItem(
|
||||
nomorSku: json['nomor_sku']?.toString() ?? '',
|
||||
namaBarang: json['nama_barang']?.toString() ?? '',
|
||||
banyak: json['banyak']?.toString() ?? '',
|
||||
jumlah: json['jumlah']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user