feat: update backend OCR parser, web app, mobile app camera/preview UI, tests, and documentation with sample images

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-07-02 13:33:57 +07:00
1 parent aa3233e411
commit bdb3a49742
71 files changed
+3360 -764

No files matched your search

+241 -334
View File
@@ -1,13 +1,11 @@
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 '../../core/utils/exif_helper.dart';
import '../../config/app_config.dart';
import '../auth/auth_provider.dart';
import 'image_preview_screen.dart';
@@ -20,155 +18,140 @@ class CameraScreen extends ConsumerStatefulWidget {
}
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;
Position? _currentPosition;
StreamSubscription<Position>? _locationSubscription;
@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');
}
_preFetchLocation();
}
@override
void dispose() {
_steadyTimer?.cancel();
_accelSubscription?.cancel();
_gyroSubscription?.cancel();
_cameraController?.dispose();
_locationSubscription?.cancel();
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;
});
Future<void> _preFetchLocation() async {
try {
final XFile picture = await _cameraController!.takePicture();
Position? position;
try {
position = await LocationService.determinePosition();
} catch (e) {
debugPrint('Error getting position: $e');
final pos = await LocationService.determinePosition();
if (mounted) {
setState(() {
_currentPosition = pos;
});
}
setState(() {
_isCapturing = false;
});
navigateToPreview(picture.path, latitude: position?.latitude, longitude: position?.longitude);
// Live location updates
_locationSubscription = LocationService.getPositionStream().listen(
(Position freshPos) {
if (mounted) {
setState(() {
_currentPosition = freshPos;
});
}
},
onError: (error) {
debugPrint('Location stream error: $error');
},
);
} catch (e) {
setState(() {
_isCapturing = false;
});
debugPrint('Error taking picture: $e');
debugPrint('Background location fetch failed: $e');
}
}
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: 90,
);
if (image != null) {
setState(() {
_isCapturing = true;
});
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 picking image from camera: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal membuka kamera: $e')),
);
}
}
}
Future<void> _pickFromGallery() async {
if (_isCapturing) return;
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');
try {
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: 2048,
maxHeight: 2048,
imageQuality: 90,
);
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);
}
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}) {
// Navigate to ImagePreviewScreen to confirm quality first
Navigator.push(
context,
MaterialPageRoute(
@@ -184,214 +167,138 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
@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();
},
),
),
],
),
backgroundColor: AppConfig.backgroundColor,
appBar: AppBar(
title: const Text('OCR Document Scanner'),
actions: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
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),
),
],
),
),
),
),
),
],
),
drawer: _buildDrawer(),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Upper Illustration Card
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
)
],
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: SingleChildScrollView(
child: 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: 24),
Text(
'Pindai Dokumen DO',
style: AppConfig.h2,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
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: 24),
// Quick Guidelines
_buildGuidelineRow(Icons.lightbulb_outline, 'Pastikan pencahayaan cukup dan terang.'),
const SizedBox(height: 8),
_buildGuidelineRow(Icons.crop_free, 'Posisikan seluruh halaman dokumen di dalam foto.'),
const SizedBox(height: 8),
_buildGuidelineRow(Icons.camera_enhance_outlined, 'Pegang HP dengan stabil agar tidak buram.'),
],
),
),
),
),
),
const SizedBox(height: 24),
// 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: 12),
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(12),
),
textStyle: AppConfig.bodyLarge.copyWith(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
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: 8),
Expanded(
child: Text(
text,
style: AppConfig.bodySmall.copyWith(color: AppConfig.textSecondary),
),
),
],
);
}
@@ -409,10 +316,10 @@ class CameraScreenState extends ConsumerState<CameraScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Image.asset(
'assets/logo.png',
width: 48,
height: 48,
const Icon(
Icons.store,
size: 48,
color: Colors.white,
),
const SizedBox(height: 12),
Text(
+1 -64
View File
@@ -2,7 +2,6 @@ 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';
@@ -162,51 +161,7 @@ class _ImagePreviewScreenState extends ConsumerState<ImagePreviewScreen> {
}
}
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) {
@@ -214,13 +169,6 @@ class _ImagePreviewScreenState extends ConsumerState<ImagePreviewScreen> {
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(
@@ -259,17 +207,6 @@ class _ImagePreviewScreenState extends ConsumerState<ImagePreviewScreen> {
),
),
),
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),
),
),
],
),
),
@@ -346,7 +283,7 @@ class _ImagePreviewScreenState extends ConsumerState<ImagePreviewScreen> {
// Confirm & Sync Button
Expanded(
child: ElevatedButton(
onPressed: (!_analyzing && _blurResult != null && !_blurResult!.isBlur) ? () {
onPressed: !_analyzing ? () {
ref.read(pendingDocumentsProvider.notifier).addDocument(
_currentImagePath,
latitude: widget.latitude,