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

+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');