chore: normalize line endings (CRLF -> LF)
No content changes: git diff --ignore-all-space over these files is empty. The churn came from editing on Windows against a repo checked out with LF.
This commit is contained in:
1 parent
15566a6951
commit
caf8e98378
315 files changed
+86950
-86950
No files matched your search
@@ -1,112 +1,112 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
class BlurResult {
|
||||
final double score; // Scaled score (0 - 100)
|
||||
final bool isBlur;
|
||||
final double rawScore;
|
||||
|
||||
BlurResult({required this.score, required this.isBlur, required this.rawScore});
|
||||
}
|
||||
|
||||
class BlurDetector {
|
||||
static BlurResult? mockResult;
|
||||
|
||||
/// We scale the raw variance of Laplacian score (0 - 500.0) to a scaled score (0 - 100.0).
|
||||
/// Center cropping and Gaussian blur isolate text sharpness.
|
||||
static const double maxRawScore = 500.0;
|
||||
static const double rawThreshold = 400.0; // Below this raw score is blurry (80% of 500.0)
|
||||
|
||||
static const double scaledThreshold = 80.0; // Corresponding scaled threshold (400 / 500 * 100)
|
||||
|
||||
static Future<BlurResult> checkBlur(String filePath) async {
|
||||
if (mockResult != null) {
|
||||
return mockResult!;
|
||||
}
|
||||
return await compute(_checkBlurIsolate, filePath);
|
||||
}
|
||||
|
||||
static BlurResult _checkBlurIsolate(String filePath) {
|
||||
try {
|
||||
final bytes = File(filePath).readAsBytesSync();
|
||||
final image = img.decodeImage(bytes);
|
||||
if (image == null) {
|
||||
return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
}
|
||||
|
||||
// 1. Resize to 300 width for fast calculation (reduces isolate time to <100ms)
|
||||
final resized = img.copyResize(image, width: 300);
|
||||
|
||||
// 2. Crop the center 60% area to focus purely on text and ignore document borders / backgrounds
|
||||
final cropWidth = (resized.width * 0.60).toInt();
|
||||
final cropHeight = (resized.height * 0.60).toInt();
|
||||
final startX = (resized.width - cropWidth) ~/ 2;
|
||||
final startY = (resized.height - cropHeight) ~/ 2;
|
||||
final cropped = img.copyCrop(resized, x: startX, y: startY, width: cropWidth, height: cropHeight);
|
||||
|
||||
// 3. Apply Gaussian Blur to filter out sensor noise/camera shaking noise
|
||||
final smoothed = img.gaussianBlur(cropped, radius: 1);
|
||||
|
||||
// 4. Convert to Grayscale
|
||||
final gray = img.grayscale(smoothed);
|
||||
|
||||
final width = gray.width;
|
||||
final height = gray.height;
|
||||
|
||||
// 5. Compute Laplacian values
|
||||
final laplacianValues = <double>[];
|
||||
double sum = 0.0;
|
||||
|
||||
for (int y = 1; y < height - 1; y++) {
|
||||
for (int x = 1; x < width - 1; x++) {
|
||||
final p = gray.getPixel(x, y);
|
||||
final pLeft = gray.getPixel(x - 1, y);
|
||||
final pRight = gray.getPixel(x + 1, y);
|
||||
final pTop = gray.getPixel(x, y - 1);
|
||||
final pBottom = gray.getPixel(x, y + 1);
|
||||
|
||||
// Get grayscale values directly (r = g = b)
|
||||
final lum = p.r.toDouble();
|
||||
final lumLeft = pLeft.r.toDouble();
|
||||
final lumRight = pRight.r.toDouble();
|
||||
final lumTop = pTop.r.toDouble();
|
||||
final lumBottom = pBottom.r.toDouble();
|
||||
|
||||
// 3x3 Laplacian: 4 * center - left - right - top - bottom
|
||||
final laplacian = (4 * lum) - lumLeft - lumRight - lumTop - lumBottom;
|
||||
laplacianValues.add(laplacian);
|
||||
sum += laplacian;
|
||||
}
|
||||
}
|
||||
|
||||
final count = laplacianValues.length;
|
||||
if (count == 0) return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
|
||||
final mean = sum / count;
|
||||
|
||||
// 6. Calculate Variance of Laplacian
|
||||
double varianceSum = 0.0;
|
||||
for (final val in laplacianValues) {
|
||||
varianceSum += (val - mean) * (val - mean);
|
||||
}
|
||||
|
||||
final rawScore = varianceSum / count;
|
||||
|
||||
// 7. Scale score to 0-100 range
|
||||
final scaledScore = (rawScore / maxRawScore * 100).clamp(0.0, 100.0);
|
||||
final isBlur = scaledScore < scaledThreshold;
|
||||
|
||||
debugPrint('Blur detection: Raw Score: $rawScore, Scaled Score: $scaledScore, IsBlur: $isBlur');
|
||||
|
||||
return BlurResult(
|
||||
score: scaledScore,
|
||||
isBlur: isBlur,
|
||||
rawScore: rawScore,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error detecting blur in isolate: $e');
|
||||
return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
class BlurResult {
|
||||
final double score; // Scaled score (0 - 100)
|
||||
final bool isBlur;
|
||||
final double rawScore;
|
||||
|
||||
BlurResult({required this.score, required this.isBlur, required this.rawScore});
|
||||
}
|
||||
|
||||
class BlurDetector {
|
||||
static BlurResult? mockResult;
|
||||
|
||||
/// We scale the raw variance of Laplacian score (0 - 500.0) to a scaled score (0 - 100.0).
|
||||
/// Center cropping and Gaussian blur isolate text sharpness.
|
||||
static const double maxRawScore = 500.0;
|
||||
static const double rawThreshold = 400.0; // Below this raw score is blurry (80% of 500.0)
|
||||
|
||||
static const double scaledThreshold = 80.0; // Corresponding scaled threshold (400 / 500 * 100)
|
||||
|
||||
static Future<BlurResult> checkBlur(String filePath) async {
|
||||
if (mockResult != null) {
|
||||
return mockResult!;
|
||||
}
|
||||
return await compute(_checkBlurIsolate, filePath);
|
||||
}
|
||||
|
||||
static BlurResult _checkBlurIsolate(String filePath) {
|
||||
try {
|
||||
final bytes = File(filePath).readAsBytesSync();
|
||||
final image = img.decodeImage(bytes);
|
||||
if (image == null) {
|
||||
return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
}
|
||||
|
||||
// 1. Resize to 300 width for fast calculation (reduces isolate time to <100ms)
|
||||
final resized = img.copyResize(image, width: 300);
|
||||
|
||||
// 2. Crop the center 60% area to focus purely on text and ignore document borders / backgrounds
|
||||
final cropWidth = (resized.width * 0.60).toInt();
|
||||
final cropHeight = (resized.height * 0.60).toInt();
|
||||
final startX = (resized.width - cropWidth) ~/ 2;
|
||||
final startY = (resized.height - cropHeight) ~/ 2;
|
||||
final cropped = img.copyCrop(resized, x: startX, y: startY, width: cropWidth, height: cropHeight);
|
||||
|
||||
// 3. Apply Gaussian Blur to filter out sensor noise/camera shaking noise
|
||||
final smoothed = img.gaussianBlur(cropped, radius: 1);
|
||||
|
||||
// 4. Convert to Grayscale
|
||||
final gray = img.grayscale(smoothed);
|
||||
|
||||
final width = gray.width;
|
||||
final height = gray.height;
|
||||
|
||||
// 5. Compute Laplacian values
|
||||
final laplacianValues = <double>[];
|
||||
double sum = 0.0;
|
||||
|
||||
for (int y = 1; y < height - 1; y++) {
|
||||
for (int x = 1; x < width - 1; x++) {
|
||||
final p = gray.getPixel(x, y);
|
||||
final pLeft = gray.getPixel(x - 1, y);
|
||||
final pRight = gray.getPixel(x + 1, y);
|
||||
final pTop = gray.getPixel(x, y - 1);
|
||||
final pBottom = gray.getPixel(x, y + 1);
|
||||
|
||||
// Get grayscale values directly (r = g = b)
|
||||
final lum = p.r.toDouble();
|
||||
final lumLeft = pLeft.r.toDouble();
|
||||
final lumRight = pRight.r.toDouble();
|
||||
final lumTop = pTop.r.toDouble();
|
||||
final lumBottom = pBottom.r.toDouble();
|
||||
|
||||
// 3x3 Laplacian: 4 * center - left - right - top - bottom
|
||||
final laplacian = (4 * lum) - lumLeft - lumRight - lumTop - lumBottom;
|
||||
laplacianValues.add(laplacian);
|
||||
sum += laplacian;
|
||||
}
|
||||
}
|
||||
|
||||
final count = laplacianValues.length;
|
||||
if (count == 0) return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
|
||||
final mean = sum / count;
|
||||
|
||||
// 6. Calculate Variance of Laplacian
|
||||
double varianceSum = 0.0;
|
||||
for (final val in laplacianValues) {
|
||||
varianceSum += (val - mean) * (val - mean);
|
||||
}
|
||||
|
||||
final rawScore = varianceSum / count;
|
||||
|
||||
// 7. Scale score to 0-100 range
|
||||
final scaledScore = (rawScore / maxRawScore * 100).clamp(0.0, 100.0);
|
||||
final isBlur = scaledScore < scaledThreshold;
|
||||
|
||||
debugPrint('Blur detection: Raw Score: $rawScore, Scaled Score: $scaledScore, IsBlur: $isBlur');
|
||||
|
||||
return BlurResult(
|
||||
score: scaledScore,
|
||||
isBlur: isBlur,
|
||||
rawScore: rawScore,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error detecting blur in isolate: $e');
|
||||
return BlurResult(score: 0, isBlur: true, rawScore: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,233 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import 'camera_drawer_helpers.dart';
|
||||
import 'camera_drawer_mode_toggle.dart';
|
||||
|
||||
/// Main navigation drawer for the camera screen.
|
||||
///
|
||||
/// Split during task 8.1 (AGENTS.md §3 — 256-line threshold on touched files):
|
||||
/// - [CameraDrawerModeToggle] owns the DO/Product mode toggle pill and
|
||||
/// [scanModeProvider] read/write (including the new doModeColor cue).
|
||||
/// - [camera_drawer_helpers.dart] contains stateless UI helpers
|
||||
/// (section header, drawer item, dialogs).
|
||||
class CameraDrawer extends ConsumerStatefulWidget {
|
||||
const CameraDrawer({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CameraDrawer> createState() => _CameraDrawerState();
|
||||
}
|
||||
|
||||
class _CameraDrawerState extends ConsumerState<CameraDrawer> {
|
||||
String _storeName = 'Store Name';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStoreName();
|
||||
}
|
||||
|
||||
Future<void> _loadStoreName() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storeName = prefs.getString('nama_toko') ?? 'Store Name';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Drawer(
|
||||
backgroundColor: Colors.white,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Profile header ─────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.grey),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Image.asset('assets/logo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Prima Fresh Mart',
|
||||
style: AppConfig.h3.copyWith(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_storeName,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Mode toggle (DO/Product) ────────────────────────────────
|
||||
const CameraDrawerModeToggle(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Menu items ─────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: [
|
||||
buildDrawerSectionHeader('DASHBOARD'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.home_outlined,
|
||||
title: 'Home',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.history_outlined,
|
||||
title: 'History',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/documents');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
buildDrawerSectionHeader('USER PROFILE'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.person_outline,
|
||||
title: 'Profile',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Profile');
|
||||
},
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.notifications_none_outlined,
|
||||
title: 'Notifications',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Notifications');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
buildDrawerSectionHeader('SUPPORT & SETTINGS'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.settings_outlined,
|
||||
title: 'Settings',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Settings');
|
||||
},
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.help_outline,
|
||||
title: 'Help',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerHelpDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: buildDrawerItem(
|
||||
icon: Icons.logout_outlined,
|
||||
title: 'Logout',
|
||||
textColor: AppConfig.errorColor,
|
||||
iconColor: AppConfig.errorColor,
|
||||
onTap: _handleLogout,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Warns before logout if the pending-documents queue still has unsynced
|
||||
/// items — a plain logout wipes the local Hive cache and clears the queue
|
||||
/// (see AuthNotifier.logout()), silently discarding any in-flight
|
||||
/// uploads, unreviewed scans, or failed syncs otherwise.
|
||||
Future<void> _handleLogout() async {
|
||||
final pendingCount = ref.read(pendingDocumentsProvider).length;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Ada Dokumen Belum Tersinkron'),
|
||||
content: Text(
|
||||
'Ada $pendingCount dokumen yang belum selesai diunggah atau '
|
||||
'disinkronkan ke server. Jika Anda logout sekarang, dokumen '
|
||||
'tersebut akan hilang.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text('Ya, Logout', style: TextStyle(color: AppConfig.errorColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import 'camera_drawer_helpers.dart';
|
||||
import 'camera_drawer_mode_toggle.dart';
|
||||
|
||||
/// Main navigation drawer for the camera screen.
|
||||
///
|
||||
/// Split during task 8.1 (AGENTS.md §3 — 256-line threshold on touched files):
|
||||
/// - [CameraDrawerModeToggle] owns the DO/Product mode toggle pill and
|
||||
/// [scanModeProvider] read/write (including the new doModeColor cue).
|
||||
/// - [camera_drawer_helpers.dart] contains stateless UI helpers
|
||||
/// (section header, drawer item, dialogs).
|
||||
class CameraDrawer extends ConsumerStatefulWidget {
|
||||
const CameraDrawer({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CameraDrawer> createState() => _CameraDrawerState();
|
||||
}
|
||||
|
||||
class _CameraDrawerState extends ConsumerState<CameraDrawer> {
|
||||
String _storeName = 'Store Name';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStoreName();
|
||||
}
|
||||
|
||||
Future<void> _loadStoreName() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storeName = prefs.getString('nama_toko') ?? 'Store Name';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Drawer(
|
||||
backgroundColor: Colors.white,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Profile header ─────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.grey),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Image.asset('assets/logo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Prima Fresh Mart',
|
||||
style: AppConfig.h3.copyWith(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_storeName,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Mode toggle (DO/Product) ────────────────────────────────
|
||||
const CameraDrawerModeToggle(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Menu items ─────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: [
|
||||
buildDrawerSectionHeader('DASHBOARD'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.home_outlined,
|
||||
title: 'Home',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.history_outlined,
|
||||
title: 'History',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/documents');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
buildDrawerSectionHeader('USER PROFILE'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.person_outline,
|
||||
title: 'Profile',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Profile');
|
||||
},
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.notifications_none_outlined,
|
||||
title: 'Notifications',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Notifications');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
buildDrawerSectionHeader('SUPPORT & SETTINGS'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.settings_outlined,
|
||||
title: 'Settings',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerNotImplemented(context, 'Settings');
|
||||
},
|
||||
),
|
||||
buildDrawerItem(
|
||||
icon: Icons.help_outline,
|
||||
title: 'Help',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showDrawerHelpDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: buildDrawerItem(
|
||||
icon: Icons.logout_outlined,
|
||||
title: 'Logout',
|
||||
textColor: AppConfig.errorColor,
|
||||
iconColor: AppConfig.errorColor,
|
||||
onTap: _handleLogout,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Warns before logout if the pending-documents queue still has unsynced
|
||||
/// items — a plain logout wipes the local Hive cache and clears the queue
|
||||
/// (see AuthNotifier.logout()), silently discarding any in-flight
|
||||
/// uploads, unreviewed scans, or failed syncs otherwise.
|
||||
Future<void> _handleLogout() async {
|
||||
final pendingCount = ref.read(pendingDocumentsProvider).length;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Ada Dokumen Belum Tersinkron'),
|
||||
content: Text(
|
||||
'Ada $pendingCount dokumen yang belum selesai diunggah atau '
|
||||
'disinkronkan ke server. Jika Anda logout sekarang, dokumen '
|
||||
'tersebut akan hilang.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text('Ya, Logout', style: TextStyle(color: AppConfig.errorColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
/// Stateless helper functions extracted from [CameraDrawer] to keep that
|
||||
/// file under the AGENTS.md §3 256-line threshold (touched in task 8.1).
|
||||
/// Contains only UI helpers with no side-effects or state.
|
||||
|
||||
Widget buildDrawerSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8.0, bottom: 8.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade400,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDrawerItem({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required VoidCallback onTap,
|
||||
Color? textColor,
|
||||
Color? iconColor,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: iconColor ?? Colors.black87),
|
||||
title: Text(
|
||||
title,
|
||||
style: AppConfig.bodyLarge.copyWith(
|
||||
color: textColor ?? Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
void showDrawerNotImplemented(BuildContext context, String featureName) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(' feature coming soon!'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showDrawerHelpDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Tentang Prima Mart'),
|
||||
content: const SingleChildScrollView(
|
||||
child: Text(
|
||||
'www.primafreshmart.com merupakan sarana belanja online dari toko Prima Mart '
|
||||
'yang sudah melayani konsumen selama 10 tahun dan memiliki hampir 1200 gerai '
|
||||
'di Pulau Jawa, Bali dan Sumatera. Inovasi terbaru dari PT. Primafood '
|
||||
'International sebagai bagian dari PT. Charoen Pokphand Indonesia, Tbk.\n\n'
|
||||
'PT. Primafood International\n'
|
||||
'Jl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Tutup'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
/// Stateless helper functions extracted from [CameraDrawer] to keep that
|
||||
/// file under the AGENTS.md §3 256-line threshold (touched in task 8.1).
|
||||
/// Contains only UI helpers with no side-effects or state.
|
||||
|
||||
Widget buildDrawerSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8.0, bottom: 8.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade400,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDrawerItem({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required VoidCallback onTap,
|
||||
Color? textColor,
|
||||
Color? iconColor,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: iconColor ?? Colors.black87),
|
||||
title: Text(
|
||||
title,
|
||||
style: AppConfig.bodyLarge.copyWith(
|
||||
color: textColor ?? Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
void showDrawerNotImplemented(BuildContext context, String featureName) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(' feature coming soon!'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showDrawerHelpDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Tentang Prima Mart'),
|
||||
content: const SingleChildScrollView(
|
||||
child: Text(
|
||||
'www.primafreshmart.com merupakan sarana belanja online dari toko Prima Mart '
|
||||
'yang sudah melayani konsumen selama 10 tahun dan memiliki hampir 1200 gerai '
|
||||
'di Pulau Jawa, Bali dan Sumatera. Inovasi terbaru dari PT. Primafood '
|
||||
'International sebagai bagian dari PT. Charoen Pokphand Indonesia, Tbk.\n\n'
|
||||
'PT. Primafood International\n'
|
||||
'Jl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Tutup'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,125 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
|
||||
/// DO / Product Scan mode toggle pill rendered in the [CameraDrawer].
|
||||
///
|
||||
/// Extracted from [CameraDrawer] to keep that file under the 256-line §3
|
||||
/// threshold (AGENTS.md §3, touched during task 8.1). Reads and writes
|
||||
/// [scanModeProvider] directly — the global source of truth shared with
|
||||
/// [DocumentsScreen] (task 8.1, docs/api-contract-map.md G11).
|
||||
///
|
||||
/// Color convention:
|
||||
/// - DO active → [AppConfig.doModeColor] (orange)
|
||||
/// - Product active → [AppConfig.primaryColor] (green)
|
||||
class CameraDrawerModeToggle extends ConsumerWidget {
|
||||
const CameraDrawerModeToggle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mode',
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade500,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'DO',
|
||||
label: 'DO Scan',
|
||||
activeIcon: Icons.description,
|
||||
inactiveIcon: Icons.description_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.doModeColor,
|
||||
),
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'Product',
|
||||
label: 'Product Scan',
|
||||
activeIcon: Icons.inventory_2,
|
||||
inactiveIcon: Icons.inventory_2_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegment({
|
||||
required WidgetRef ref,
|
||||
required String value,
|
||||
required String label,
|
||||
required IconData activeIcon,
|
||||
required IconData inactiveIcon,
|
||||
required String activeMode,
|
||||
required Color activeColor,
|
||||
}) {
|
||||
final isActive = activeMode == value;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => ref.read(scanModeProvider.notifier).state = value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? activeColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: activeColor.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: [],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isActive ? activeIcon : inactiveIcon,
|
||||
size: 16,
|
||||
color: isActive ? Colors.white : Colors.grey.shade500,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? Colors.white : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
|
||||
/// DO / Product Scan mode toggle pill rendered in the [CameraDrawer].
|
||||
///
|
||||
/// Extracted from [CameraDrawer] to keep that file under the 256-line §3
|
||||
/// threshold (AGENTS.md §3, touched during task 8.1). Reads and writes
|
||||
/// [scanModeProvider] directly — the global source of truth shared with
|
||||
/// [DocumentsScreen] (task 8.1, docs/api-contract-map.md G11).
|
||||
///
|
||||
/// Color convention:
|
||||
/// - DO active → [AppConfig.doModeColor] (orange)
|
||||
/// - Product active → [AppConfig.primaryColor] (green)
|
||||
class CameraDrawerModeToggle extends ConsumerWidget {
|
||||
const CameraDrawerModeToggle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mode',
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade500,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'DO',
|
||||
label: 'DO Scan',
|
||||
activeIcon: Icons.description,
|
||||
inactiveIcon: Icons.description_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.doModeColor,
|
||||
),
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'Product',
|
||||
label: 'Product Scan',
|
||||
activeIcon: Icons.inventory_2,
|
||||
inactiveIcon: Icons.inventory_2_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegment({
|
||||
required WidgetRef ref,
|
||||
required String value,
|
||||
required String label,
|
||||
required IconData activeIcon,
|
||||
required IconData inactiveIcon,
|
||||
required String activeMode,
|
||||
required Color activeColor,
|
||||
}) {
|
||||
final isActive = activeMode == value;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => ref.read(scanModeProvider.notifier).state = value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? activeColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: activeColor.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: [],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isActive ? activeIcon : inactiveIcon,
|
||||
size: 16,
|
||||
color: isActive ? Colors.white : Colors.grey.shade500,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? Colors.white : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,324 +1,324 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../../core/location/location_service.dart';
|
||||
import '../../core/utils/exif_helper.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'image_preview_screen.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
import 'camera_drawer.dart';
|
||||
|
||||
class CameraScreen extends ConsumerStatefulWidget {
|
||||
const CameraScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CameraScreen> createState() => CameraScreenState();
|
||||
}
|
||||
|
||||
class CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
bool _isCapturing = false;
|
||||
Position? _currentPosition;
|
||||
StreamSubscription<Position>? _locationSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_preFetchLocation();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_locationSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _preFetchLocation() async {
|
||||
try {
|
||||
final pos = await LocationService.determinePosition();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = pos;
|
||||
});
|
||||
}
|
||||
|
||||
// Live location updates
|
||||
_locationSubscription = LocationService.getPositionStream().listen(
|
||||
(Position freshPos) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = freshPos;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _takePicture() async {
|
||||
if (_isCapturing) return;
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
});
|
||||
|
||||
// Use pre-fetched location if available, otherwise fetch quick position
|
||||
Position? position = _currentPosition;
|
||||
if (position == null) {
|
||||
try {
|
||||
position = await LocationService.determinePosition().timeout(const Duration(seconds: 2));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isCapturing = false;
|
||||
});
|
||||
|
||||
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error taking picture: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFromGallery() async {
|
||||
if (_isCapturing) return;
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
});
|
||||
|
||||
Position? position = _currentPosition;
|
||||
try {
|
||||
// Attempt to extract GPS from EXIF tags
|
||||
final exifGps = await ExifHelper.extractGps(image.path);
|
||||
if (exifGps != null) {
|
||||
position = Position(
|
||||
latitude: exifGps['latitude']!,
|
||||
longitude: exifGps['longitude']!,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 0.0,
|
||||
altitude: 0.0,
|
||||
heading: 0.0,
|
||||
speed: 0.0,
|
||||
speedAccuracy: 0.0,
|
||||
altitudeAccuracy: 0.0,
|
||||
headingAccuracy: 0.0,
|
||||
);
|
||||
} else if (position == null) {
|
||||
position = await LocationService.determinePosition().timeout(const Duration(seconds: 2));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
setState(() {
|
||||
_isCapturing = false;
|
||||
});
|
||||
|
||||
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error picking image from gallery: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void navigateToPreview(String imagePath, {double? latitude, double? longitude}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ImagePreviewScreen(
|
||||
imagePath: imagePath,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppConfig.backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: Text(activeMode == 'DO' ? 'Pemindai Dokumen OCR' : 'Pemindai Produk PFM'),
|
||||
),
|
||||
drawer: const CameraDrawer(),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Upper Illustration Card
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusXL),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: AppConfig.paddingXL,
|
||||
child: SingleChildScrollView(
|
||||
child: activeMode == 'DO'
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.primaryColor.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.document_scanner,
|
||||
size: 48,
|
||||
color: AppConfig.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
'Pemindai DO',
|
||||
style: AppConfig.h3,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
Text(
|
||||
'Ambil foto lembar Delivery Order (DO) Prima Fresh Mart untuk mengekstrak data PO, SO, DO, dan item barang secara otomatis.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
_buildGuidelineRow(Icons.lightbulb_outline, 'Pastikan pencahayaan cukup dan terang.'),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
_buildGuidelineRow(Icons.crop_free, 'Posisikan seluruh halaman dokumen di dalam foto.'),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
_buildGuidelineRow(Icons.camera_enhance_outlined, 'Pegang HP dengan stabil agar tidak buram.'),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.primaryColor.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 48,
|
||||
color: AppConfig.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
'Pemindai Produk',
|
||||
style: AppConfig.h3,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
Text(
|
||||
'Ambil foto kemasan produk Prima Fresh Mart untuk mengidentifikasi SKU, nama item, dan mendeteksi tanggal kadaluwarsa secara otomatis.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
_buildGuidelineRow(Icons.lightbulb_outline, 'Pastikan kode SKU atau teks kadaluwarsa terlihat jelas.'),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
_buildGuidelineRow(Icons.center_focus_strong, 'Posisikan produk di tengah kamera.'),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
_buildGuidelineRow(Icons.flash_on, 'Gunakan pencahayaan yang cukup agar tidak buram.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
|
||||
// Action Buttons
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isCapturing ? null : _takePicture,
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('Ambil Foto Kamera'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isCapturing ? null : _pickFromGallery,
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: const Text('Pilih Dari Galeri'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
side: const BorderSide(color: AppConfig.primaryColor),
|
||||
foregroundColor: AppConfig.primaryColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
),
|
||||
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
TextButton.icon(
|
||||
onPressed: _isCapturing ? null : () => context.push('/documents'),
|
||||
icon: const Icon(Icons.history_edu),
|
||||
label: const Text('Riwayat Dokumen'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppConfig.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGuidelineRow(IconData icon, String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppConfig.secondaryColor),
|
||||
const SizedBox(width: AppConfig.spaceSmall),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../../core/location/location_service.dart';
|
||||
import '../../core/utils/exif_helper.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'image_preview_screen.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
import 'camera_drawer.dart';
|
||||
|
||||
class CameraScreen extends ConsumerStatefulWidget {
|
||||
const CameraScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CameraScreen> createState() => CameraScreenState();
|
||||
}
|
||||
|
||||
class CameraScreenState extends ConsumerState<CameraScreen> {
|
||||
bool _isCapturing = false;
|
||||
Position? _currentPosition;
|
||||
StreamSubscription<Position>? _locationSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_preFetchLocation();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_locationSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _preFetchLocation() async {
|
||||
try {
|
||||
final pos = await LocationService.determinePosition();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = pos;
|
||||
});
|
||||
}
|
||||
|
||||
// Live location updates
|
||||
_locationSubscription = LocationService.getPositionStream().listen(
|
||||
(Position freshPos) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentPosition = freshPos;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _takePicture() async {
|
||||
if (_isCapturing) return;
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
});
|
||||
|
||||
// Use pre-fetched location if available, otherwise fetch quick position
|
||||
Position? position = _currentPosition;
|
||||
if (position == null) {
|
||||
try {
|
||||
position = await LocationService.determinePosition().timeout(const Duration(seconds: 2));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isCapturing = false;
|
||||
});
|
||||
|
||||
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error taking picture: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFromGallery() async {
|
||||
if (_isCapturing) return;
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
final XFile? image = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_isCapturing = true;
|
||||
});
|
||||
|
||||
Position? position = _currentPosition;
|
||||
try {
|
||||
// Attempt to extract GPS from EXIF tags
|
||||
final exifGps = await ExifHelper.extractGps(image.path);
|
||||
if (exifGps != null) {
|
||||
position = Position(
|
||||
latitude: exifGps['latitude']!,
|
||||
longitude: exifGps['longitude']!,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 0.0,
|
||||
altitude: 0.0,
|
||||
heading: 0.0,
|
||||
speed: 0.0,
|
||||
speedAccuracy: 0.0,
|
||||
altitudeAccuracy: 0.0,
|
||||
headingAccuracy: 0.0,
|
||||
);
|
||||
} else if (position == null) {
|
||||
position = await LocationService.determinePosition().timeout(const Duration(seconds: 2));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
setState(() {
|
||||
_isCapturing = false;
|
||||
});
|
||||
|
||||
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error picking image from gallery: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void navigateToPreview(String imagePath, {double? latitude, double? longitude}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ImagePreviewScreen(
|
||||
imagePath: imagePath,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppConfig.backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: Text(activeMode == 'DO' ? 'Pemindai Dokumen OCR' : 'Pemindai Produk PFM'),
|
||||
),
|
||||
drawer: const CameraDrawer(),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: AppConfig.screenPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Upper Illustration Card
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusXL),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: AppConfig.paddingXL,
|
||||
child: SingleChildScrollView(
|
||||
child: activeMode == 'DO'
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.primaryColor.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.document_scanner,
|
||||
size: 48,
|
||||
color: AppConfig.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
'Pemindai DO',
|
||||
style: AppConfig.h3,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
Text(
|
||||
'Ambil foto lembar Delivery Order (DO) Prima Fresh Mart untuk mengekstrak data PO, SO, DO, dan item barang secara otomatis.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
_buildGuidelineRow(Icons.lightbulb_outline, 'Pastikan pencahayaan cukup dan terang.'),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
_buildGuidelineRow(Icons.crop_free, 'Posisikan seluruh halaman dokumen di dalam foto.'),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
_buildGuidelineRow(Icons.camera_enhance_outlined, 'Pegang HP dengan stabil agar tidak buram.'),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.primaryColor.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 48,
|
||||
color: AppConfig.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
Text(
|
||||
'Pemindai Produk',
|
||||
style: AppConfig.h3,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
Text(
|
||||
'Ambil foto kemasan produk Prima Fresh Mart untuk mengidentifikasi SKU, nama item, dan mendeteksi tanggal kadaluwarsa secara otomatis.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
_buildGuidelineRow(Icons.lightbulb_outline, 'Pastikan kode SKU atau teks kadaluwarsa terlihat jelas.'),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
_buildGuidelineRow(Icons.center_focus_strong, 'Posisikan produk di tengah kamera.'),
|
||||
const SizedBox(height: AppConfig.spaceSmall),
|
||||
_buildGuidelineRow(Icons.flash_on, 'Gunakan pencahayaan yang cukup agar tidak buram.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceXL),
|
||||
|
||||
// Action Buttons
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isCapturing ? null : _takePicture,
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('Ambil Foto Kamera'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isCapturing ? null : _pickFromGallery,
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: const Text('Pilih Dari Galeri'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
side: const BorderSide(color: AppConfig.primaryColor),
|
||||
foregroundColor: AppConfig.primaryColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadiusMedium),
|
||||
),
|
||||
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppConfig.spaceMedium),
|
||||
TextButton.icon(
|
||||
onPressed: _isCapturing ? null : () => context.push('/documents'),
|
||||
icon: const Icon(Icons.history_edu),
|
||||
label: const Text('Riwayat Dokumen'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppConfig.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGuidelineRow(IconData icon, String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppConfig.secondaryColor),
|
||||
const SizedBox(width: AppConfig.spaceSmall),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,380 +1,380 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import 'blur_detector.dart';
|
||||
|
||||
class DottedVerticalLine extends StatelessWidget {
|
||||
final double height;
|
||||
final Color color;
|
||||
|
||||
const DottedVerticalLine({super.key, required this.height, this.color = Colors.black45});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate((height / 4).floor(), (index) => Container(
|
||||
width: 1.5,
|
||||
height: 2,
|
||||
color: color,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BlurScoreBar extends StatelessWidget {
|
||||
final double score;
|
||||
final double threshold = 80.0;
|
||||
final double maxScore = 100.0;
|
||||
|
||||
const BlurScoreBar({super.key, required this.score});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double displayScore = score;
|
||||
const double displayThreshold = 80.0;
|
||||
const double maxDisplayScore = 100.0;
|
||||
|
||||
final double normalizedScore = displayScore / maxDisplayScore;
|
||||
final double thresholdRatio = displayThreshold / maxDisplayScore;
|
||||
final bool isSharp = displayScore >= displayThreshold;
|
||||
final Color barColor = isSharp ? AppConfig.successColor : AppConfig.errorColor;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Ketajaman Foto', style: AppConfig.bodyMedium.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
'${displayScore.toStringAsFixed(1)} / ${maxDisplayScore.toInt()}',
|
||||
style: AppConfig.bodyMedium.copyWith(color: barColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double totalWidth = constraints.maxWidth;
|
||||
final double thresholdPosition = totalWidth * thresholdRatio;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 1. Progress Bar Track
|
||||
Container(
|
||||
height: 20,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
// 2. Active Filled Bar
|
||||
FractionallySizedBox(
|
||||
widthFactor: normalizedScore,
|
||||
child: Container(
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: barColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 3. Vertical Dotted Threshold Line
|
||||
Positioned(
|
||||
left: thresholdPosition,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: const DottedVerticalLine(height: 20, color: Colors.black45),
|
||||
),
|
||||
// 4. Threshold Label
|
||||
Positioned(
|
||||
left: thresholdPosition - 40,
|
||||
top: 22,
|
||||
child: Text(
|
||||
'Batas Min (${displayThreshold.toInt()})',
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewScreen extends ConsumerStatefulWidget {
|
||||
final String imagePath;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
const ImagePreviewScreen({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ImagePreviewScreen> createState() => _ImagePreviewScreenState();
|
||||
}
|
||||
|
||||
class _ImagePreviewScreenState extends ConsumerState<ImagePreviewScreen> {
|
||||
bool _analyzing = true;
|
||||
BlurResult? _blurResult;
|
||||
late String _currentImagePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentImagePath = widget.imagePath;
|
||||
_detectBlur();
|
||||
}
|
||||
|
||||
Future<void> _detectBlur() async {
|
||||
try {
|
||||
final result = await BlurDetector.checkBlur(_currentImagePath);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_blurResult = result;
|
||||
_analyzing = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_analyzing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppConfig.backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text('Konfirmasi Foto'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Interactive photo view (with pinch-to-zoom support)
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
)
|
||||
],
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InteractiveViewer(
|
||||
maxScale: 4.0,
|
||||
child: Center(
|
||||
child: Hero(
|
||||
tag: 'doc_image_preview',
|
||||
child: Image.file(
|
||||
File(_currentImagePath),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom confirmation box
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, -5),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Periksa Kualitas Foto',
|
||||
style: AppConfig.h2.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Blur status area
|
||||
if (_analyzing)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppConfig.primaryColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Menganalisis ketajaman foto...',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (_blurResult != null) ...[
|
||||
BlurScoreBar(score: _blurResult!.score),
|
||||
_buildBlurBadge(_blurResult!)
|
||||
] else
|
||||
Text(
|
||||
'Pastikan seluruh area dokumen masuk ke dalam frame, tulisan dapat terbaca dengan jelas, dan hasil foto tidak blur.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Retake Button
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppConfig.textPrimary,
|
||||
side: BorderSide(color: Colors.grey.shade400),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('Ambil Ulang', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Confirm & Sync Button
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: !_analyzing ? () {
|
||||
ref.read(pendingDocumentsProvider.notifier).addDocument(
|
||||
_currentImagePath,
|
||||
latitude: widget.latitude,
|
||||
longitude: widget.longitude,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
context.go('/documents');
|
||||
} : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppConfig.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('Unggah Dokumen', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlurBadge(BlurResult result) {
|
||||
if (result.isBlur) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.errorColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppConfig.errorColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: AppConfig.errorColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Foto Terdeteksi Blur!',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.errorColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Diharuskan untuk mengambil ulang gambar.',
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.successColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppConfig.successColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, color: AppConfig.successColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Kualitas Foto Baik',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.successColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Fokus dan ketajaman gambar optimal.',
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import 'blur_detector.dart';
|
||||
|
||||
class DottedVerticalLine extends StatelessWidget {
|
||||
final double height;
|
||||
final Color color;
|
||||
|
||||
const DottedVerticalLine({super.key, required this.height, this.color = Colors.black45});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate((height / 4).floor(), (index) => Container(
|
||||
width: 1.5,
|
||||
height: 2,
|
||||
color: color,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BlurScoreBar extends StatelessWidget {
|
||||
final double score;
|
||||
final double threshold = 80.0;
|
||||
final double maxScore = 100.0;
|
||||
|
||||
const BlurScoreBar({super.key, required this.score});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double displayScore = score;
|
||||
const double displayThreshold = 80.0;
|
||||
const double maxDisplayScore = 100.0;
|
||||
|
||||
final double normalizedScore = displayScore / maxDisplayScore;
|
||||
final double thresholdRatio = displayThreshold / maxDisplayScore;
|
||||
final bool isSharp = displayScore >= displayThreshold;
|
||||
final Color barColor = isSharp ? AppConfig.successColor : AppConfig.errorColor;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Ketajaman Foto', style: AppConfig.bodyMedium.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
'${displayScore.toStringAsFixed(1)} / ${maxDisplayScore.toInt()}',
|
||||
style: AppConfig.bodyMedium.copyWith(color: barColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double totalWidth = constraints.maxWidth;
|
||||
final double thresholdPosition = totalWidth * thresholdRatio;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 1. Progress Bar Track
|
||||
Container(
|
||||
height: 20,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
// 2. Active Filled Bar
|
||||
FractionallySizedBox(
|
||||
widthFactor: normalizedScore,
|
||||
child: Container(
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: barColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 3. Vertical Dotted Threshold Line
|
||||
Positioned(
|
||||
left: thresholdPosition,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: const DottedVerticalLine(height: 20, color: Colors.black45),
|
||||
),
|
||||
// 4. Threshold Label
|
||||
Positioned(
|
||||
left: thresholdPosition - 40,
|
||||
top: 22,
|
||||
child: Text(
|
||||
'Batas Min (${displayThreshold.toInt()})',
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewScreen extends ConsumerStatefulWidget {
|
||||
final String imagePath;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
const ImagePreviewScreen({
|
||||
super.key,
|
||||
required this.imagePath,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ImagePreviewScreen> createState() => _ImagePreviewScreenState();
|
||||
}
|
||||
|
||||
class _ImagePreviewScreenState extends ConsumerState<ImagePreviewScreen> {
|
||||
bool _analyzing = true;
|
||||
BlurResult? _blurResult;
|
||||
late String _currentImagePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentImagePath = widget.imagePath;
|
||||
_detectBlur();
|
||||
}
|
||||
|
||||
Future<void> _detectBlur() async {
|
||||
try {
|
||||
final result = await BlurDetector.checkBlur(_currentImagePath);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_blurResult = result;
|
||||
_analyzing = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_analyzing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppConfig.backgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text('Konfirmasi Foto'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Interactive photo view (with pinch-to-zoom support)
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
)
|
||||
],
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InteractiveViewer(
|
||||
maxScale: 4.0,
|
||||
child: Center(
|
||||
child: Hero(
|
||||
tag: 'doc_image_preview',
|
||||
child: Image.file(
|
||||
File(_currentImagePath),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom confirmation box
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, -5),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Periksa Kualitas Foto',
|
||||
style: AppConfig.h2.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Blur status area
|
||||
if (_analyzing)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppConfig.primaryColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Menganalisis ketajaman foto...',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (_blurResult != null) ...[
|
||||
BlurScoreBar(score: _blurResult!.score),
|
||||
_buildBlurBadge(_blurResult!)
|
||||
] else
|
||||
Text(
|
||||
'Pastikan seluruh area dokumen masuk ke dalam frame, tulisan dapat terbaca dengan jelas, dan hasil foto tidak blur.',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Retake Button
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppConfig.textPrimary,
|
||||
side: BorderSide(color: Colors.grey.shade400),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('Ambil Ulang', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Confirm & Sync Button
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: !_analyzing ? () {
|
||||
ref.read(pendingDocumentsProvider.notifier).addDocument(
|
||||
_currentImagePath,
|
||||
latitude: widget.latitude,
|
||||
longitude: widget.longitude,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
context.go('/documents');
|
||||
} : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppConfig.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('Unggah Dokumen', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlurBadge(BlurResult result) {
|
||||
if (result.isBlur) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.errorColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppConfig.errorColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: AppConfig.errorColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Foto Terdeteksi Blur!',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.errorColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Diharuskan untuk mengambil ulang gambar.',
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppConfig.successColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppConfig.successColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, color: AppConfig.successColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Kualitas Foto Baik',
|
||||
style: AppConfig.bodyMedium.copyWith(color: AppConfig.successColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Fokus dan ketajaman gambar optimal.',
|
||||
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final scanModeProvider = StateProvider<String>((ref) => 'DO');
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final scanModeProvider = StateProvider<String>((ref) => 'DO');
|
||||
Reference in new issue
Block a user