Files
pfm-ocr/lib/features/camera/image_preview_screen.dart
T

444 lines
15 KiB
Dart

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),
),
],
),
),
],
),
);
}
}
}