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:
fhanyuh committed 2026-08-27 10:40:49 +07:00
1 parent 15566a6951
commit caf8e98378
315 files changed
+86950 -86950

No files matched your search

+231 -231
View File
@@ -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),
),
);
}
}
+116 -116
View File
@@ -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;
}
}
}
+75 -75
View File
@@ -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());
+97 -97
View File
@@ -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);
}
}
+47 -47
View File
@@ -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);
},
),
],
);
});
+63 -63
View File
@@ -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();
}
}
+85 -85
View File
@@ -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
View File
@@ -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)',
};
}
+99 -99
View File
@@ -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
View File
@@ -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,
),
],
),
),
),
),
),
);
}
}
+112 -112
View File
@@ -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);
}
}
}
+233 -233
View File
@@ -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');
}
}
+78 -78
View File
@@ -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'),
)
],
),
);
}
+125 -125
View File
@@ -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,
),
),
],
),
),
),
);
}
}
+324 -324
View File
@@ -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),
),
),
],
);
}
}
+380 -380
View File
@@ -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),
),
],
),
),
],
),
);
}
}
}
+3 -3
View File
@@ -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');
+163 -163
View File
@@ -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;
}
}
+33 -33
View File
@@ -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),
],
),
);
}
}
+224 -224
View File
@@ -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,
),
),
],
),
),
),
);
}
}
+175 -175
View File
@@ -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);
});
+39 -39
View File
@@ -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;
}
}
+53 -53
View File
@@ -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();
}
+238 -238
View File
@@ -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);
}
}
}
+150 -150
View File
@@ -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'),
),
],
),
),
),
);
}
}
+235 -235
View File
@@ -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),
),
]
)
);
}
}
+133 -133
View File
@@ -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); });
}
}
}
+143 -143
View File
@@ -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),
],
),
),
);
}
}
+149 -149
View File
@@ -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),
],
),
),
);
}
}
+87 -87
View File
@@ -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
View File
@@ -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
View File
@@ -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() ?? '',
);
}
}