feat: consolidate backend and docker-compose setup

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-06-30 21:09:08 +07:00
commit ff3753a745
306 files changed
+35450

No files matched your search

+112
View File
@@ -0,0 +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);
}
}
}
+469
View File
@@ -0,0 +1,469 @@
import 'dart:async';
import 'dart:math' show sqrt;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:camera/camera.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:geolocator/geolocator.dart';
import '../../core/location/location_service.dart';
import '../../config/app_config.dart';
import '../auth/auth_provider.dart';
import 'image_preview_screen.dart';
class CameraScreen extends ConsumerStatefulWidget {
const CameraScreen({super.key});
@override
ConsumerState<CameraScreen> createState() => CameraScreenState();
}
class CameraScreenState extends ConsumerState<CameraScreen> {
CameraController? _cameraController;
List<CameraDescription>? _cameras;
bool _isCameraInitialized = false;
// Inertial Measurement Unit (IMU) stillness tracking
StreamSubscription<UserAccelerometerEvent>? _accelSubscription;
StreamSubscription<GyroscopeEvent>? _gyroSubscription;
Timer? _steadyTimer;
double _accelMagnitude = 0.0;
double _gyroMagnitude = 0.0;
bool _isMoving = true; // Start locked until steady for 1s
bool _isCapturing = false;
@override
void initState() {
super.initState();
_initCamera();
_initSensors();
}
void _initSensors() {
// 1. Listen to linear accelerometer (excludes gravity)
_accelSubscription = userAccelerometerEvents.listen((UserAccelerometerEvent event) {
_accelMagnitude = sqrt(event.x * event.x + event.y * event.y + event.z * event.z);
_evaluateStillness();
});
// 2. Listen to gyroscope (angular rotation velocity)
_gyroSubscription = gyroscopeEvents.listen((GyroscopeEvent event) {
_gyroMagnitude = sqrt(event.x * event.x + event.y * event.y + event.z * event.z);
_evaluateStillness();
});
}
void _evaluateStillness() {
// Very strict thresholds:
// - Acceleration threshold: 0.3 m/s^2 (micro linear shifts)
// - Rotation threshold: 0.15 rad/s (micro tilts/rotations)
final bool currentlyMoving = _accelMagnitude > 0.3 || _gyroMagnitude > 0.15;
if (currentlyMoving) {
// Shake or rotation detected: immediately lock and cancel timer
_steadyTimer?.cancel();
_steadyTimer = null;
if (!_isMoving) {
if (mounted) {
setState(() {
_isMoving = true;
});
}
}
} else {
// Steady state: start 1s countdown to unlock
if (_isMoving && _steadyTimer == null) {
_steadyTimer = Timer(const Duration(seconds: 1), () {
if (mounted) {
setState(() {
_isMoving = false;
});
}
});
}
}
}
Future<void> _initCamera() async {
try {
_cameras = await availableCameras();
if (_cameras != null && _cameras!.isNotEmpty) {
_cameraController = CameraController(
_cameras![0],
ResolutionPreset.veryHigh,
enableAudio: false,
);
await _cameraController!.initialize();
if (mounted) {
setState(() {
_isCameraInitialized = true;
});
}
}
} catch (e) {
debugPrint('Error initializing camera: $e');
}
}
@override
void dispose() {
_steadyTimer?.cancel();
_accelSubscription?.cancel();
_gyroSubscription?.cancel();
_cameraController?.dispose();
super.dispose();
}
Future<void> _takePicture() async {
if (!_cameraController!.value.isInitialized) return;
if (_cameraController!.value.isTakingPicture) return;
if (_isMoving || _isCapturing) return; // Block capture if moving or already capturing
setState(() {
_isCapturing = true;
});
try {
final XFile picture = await _cameraController!.takePicture();
Position? position;
try {
position = await LocationService.determinePosition();
} catch (e) {
debugPrint('Error getting position: $e');
}
setState(() {
_isCapturing = false;
});
navigateToPreview(picture.path, latitude: position?.latitude, longitude: position?.longitude);
} catch (e) {
setState(() {
_isCapturing = false;
});
debugPrint('Error taking picture: $e');
}
}
Future<void> _pickFromGallery() async {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
setState(() {
_isCapturing = true;
});
Position? position;
try {
position = await LocationService.determinePosition();
} catch (e) {
debugPrint('Error getting position: $e');
}
setState(() {
_isCapturing = false;
});
navigateToPreview(image.path, latitude: position?.latitude, longitude: position?.longitude);
}
}
void navigateToPreview(String imagePath, {double? latitude, double? longitude}) {
// Navigate to ImagePreviewScreen to confirm quality first
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ImagePreviewScreen(
imagePath: imagePath,
latitude: latitude,
longitude: longitude,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
drawer: _buildDrawer(),
body: Stack(
children: [
// Camera Preview
if (_isCameraInitialized)
Positioned.fill(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
// The preview orientation might require swapping width/height depending on device,
// but generally for portrait mobile, height and width are inverse of controller
width: _cameraController!.value.previewSize!.height,
height: _cameraController!.value.previewSize!.width,
child: CameraPreview(_cameraController!),
),
),
)
else
const Center(child: CircularProgressIndicator(color: Colors.white)),
// Top Bar
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Empty space to balance right icon, or can add flash settings here
IconButton(
icon: const Icon(Icons.flash_off, color: Colors.white),
onPressed: () {
// Flash toggle logic
},
),
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu, color: Colors.white, size: 30),
onPressed: () {
Scaffold.of(context).openDrawer();
},
),
),
],
),
),
),
// Bottom Controls
Align(
alignment: Alignment.bottomCenter,
child: Container(
padding: const EdgeInsets.only(bottom: 40, left: 24, right: 24, top: 20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black.withOpacity(0.8), Colors.transparent],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Gallery Button
GestureDetector(
onTap: _isCapturing ? null : _pickFromGallery,
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white54),
),
child: const Icon(Icons.photo_library, color: Colors.white),
),
),
// Shutter Button
GestureDetector(
onTap: (_isMoving || _isCapturing) ? null : _takePicture,
child: Container(
width: 70,
height: 70,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: _isMoving ? Colors.grey : Colors.white,
width: 4,
),
),
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isMoving ? Colors.grey.withOpacity(0.5) : Colors.white,
),
child: Center(
child: _isCapturing
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: AppConfig.primaryColor,
),
)
: _isMoving
? const Icon(Icons.motion_photos_off, color: Colors.white, size: 24)
: null,
),
),
),
),
),
// Previous Documents Button
GestureDetector(
onTap: _isCapturing ? null : () => context.push('/documents'),
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.white24,
shape: BoxShape.circle,
border: Border.all(color: Colors.white54),
),
child: const Icon(Icons.history_edu, color: Colors.white),
),
),
],
),
),
),
// Vibration / Shaking Warning
if (_isMoving && !_isCapturing)
Positioned(
top: 100,
left: 24,
right: 24,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppConfig.errorColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 3),
)
],
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.warning_amber_rounded, color: Colors.white, size: 22),
SizedBox(width: 10),
Expanded(
child: Text(
'Tahan posisi HP Anda agar tetap tenang!',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13),
textAlign: TextAlign.center,
),
),
],
),
),
),
// Capturing Loading Overlay
if (_isCapturing)
Positioned.fill(
child: Container(
color: Colors.black45,
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
)
],
),
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppConfig.primaryColor),
SizedBox(height: 16),
Text(
'Sedang menangkap gambar & lokasi...',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black87),
),
],
),
),
),
),
),
],
),
);
}
Widget _buildDrawer() {
return Drawer(
backgroundColor: AppConfig.backgroundColor,
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: const BoxDecoration(
color: AppConfig.primaryColor,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Image.asset(
'assets/logo.png',
width: 48,
height: 48,
),
const SizedBox(height: 12),
Text(
'OCR Scanner App',
style: AppConfig.h2.copyWith(color: Colors.white),
),
],
),
),
ListTile(
leading: const Icon(Icons.info),
title: Text('Tentang Kami', style: AppConfig.bodyLarge),
onTap: () {
Navigator.pop(context); // Close drawer
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Tentang Prima Mart'),
content: SingleChildScrollView(
child: Text(
'www.primafreshmart.com merupakan sarana belanja online dari toko Prima Mart yang sudah melayani konsumen selama 10 tahun dan memiliki hampir 1200 gerai di Pulau Jawa, Bali dan Sumatera. Inovasi terbaru dari PT. Primafood International sebagai bagian dari PT. Charoen Pokphand Indonesia, Tbk.\n\nPT. Primafood International\nJl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.',
style: AppConfig.bodyMedium,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
)
],
),
);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text('App Version', style: AppConfig.bodyLarge),
subtitle: Text('v${AppConfig.appVersion}', style: AppConfig.bodySmall),
onTap: () {},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: AppConfig.errorColor),
title: Text('Logout', style: AppConfig.bodyLarge.copyWith(color: AppConfig.errorColor)),
onTap: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
),
],
),
);
}
}
@@ -0,0 +1,443 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image/image.dart' as img;
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;
});
}
}
}
Future<void> _rotateImage() async {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(color: AppConfig.primaryColor),
),
);
try {
final bytes = await File(_currentImagePath).readAsBytes();
final decoded = img.decodeImage(bytes);
if (decoded != null) {
// Rotate 90 degrees clockwise
final rotated = img.copyRotate(decoded, angle: 90);
final encoded = img.encodeJpg(rotated);
final dir = Directory(_currentImagePath).parent.path;
final name = _currentImagePath.split('/').last.split('\\').last;
final newPath = '$dir/rotated_${DateTime.now().millisecondsSinceEpoch}_$name';
await File(newPath).writeAsBytes(encoded);
// Delete old rotated files to prevent memory leaks/leftovers
if (_currentImagePath.contains('rotated_')) {
try {
await File(_currentImagePath).delete();
} catch (_) {}
}
if (mounted) {
setState(() {
_currentImagePath = newPath;
_analyzing = true;
});
Navigator.pop(context); // Close loading dialog
_detectBlur(); // Re-run blur analysis
}
} else {
if (mounted) Navigator.pop(context);
}
} catch (e) {
if (mounted) Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppConfig.backgroundColor,
appBar: AppBar(
title: const Text('Konfirmasi Foto'),
actions: [
IconButton(
icon: const Icon(Icons.rotate_right),
onPressed: _rotateImage,
tooltip: 'Putar 90°',
),
],
),
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,
),
),
),
),
),
),
Positioned(
bottom: 24,
right: 24,
child: FloatingActionButton.small(
heroTag: 'rotate_button',
backgroundColor: Colors.white.withOpacity(0.9),
foregroundColor: AppConfig.primaryColor,
onPressed: _rotateImage,
child: const Icon(Icons.rotate_right),
),
),
],
),
),
// 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 && _blurResult != null && !_blurResult!.isBlur) ? () {
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),
),
],
),
),
],
),
);
}
}
}