feat: update backend OCR parser, web app, mobile app camera/preview UI, tests, and documentation with sample images
This commit is contained in:
1 parent
aa3233e411
commit
bdb3a49742
71 files changed
+3360
-764
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user