182 lines
5.1 KiB
Dart
182 lines
5.1 KiB
Dart
/// Drag a crop box over the receipt photo before OCR runs.
|
|
library;
|
|
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:crop_your_image/crop_your_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
class CropReceiptScreen extends StatefulWidget {
|
|
const CropReceiptScreen({super.key, required this.imagePath});
|
|
|
|
final String imagePath;
|
|
|
|
@override
|
|
State<CropReceiptScreen> createState() => _CropReceiptScreenState();
|
|
}
|
|
|
|
class _CropReceiptScreenState extends State<CropReceiptScreen> {
|
|
final CropController _controller = CropController();
|
|
|
|
Uint8List? _image;
|
|
String? _error;
|
|
bool _ready = false;
|
|
bool _cropping = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
try {
|
|
final bytes = await File(widget.imagePath).readAsBytes();
|
|
if (!mounted) return;
|
|
setState(() => _image = bytes);
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() => _error = 'Could not open this photo.');
|
|
}
|
|
}
|
|
|
|
void _onCropped(CropResult result) {
|
|
switch (result) {
|
|
case CropSuccess(:final croppedImage):
|
|
_saveAndPop(croppedImage);
|
|
case CropFailure():
|
|
if (!mounted) return;
|
|
setState(() => _cropping = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Could not crop this photo.')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _saveAndPop(Uint8List bytes) async {
|
|
try {
|
|
final file = File(
|
|
'${Directory.systemTemp.path}/receipity_crop_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
|
);
|
|
await file.writeAsBytes(bytes, flush: true);
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop(file.path);
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() => _cropping = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Could not save the cropped photo.')),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _confirm() {
|
|
if (!_ready || _cropping) return;
|
|
setState(() => _cropping = true);
|
|
_controller.crop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return PopScope(
|
|
canPop: !_cropping,
|
|
child: Scaffold(
|
|
appBar: AppBar(title: const Text('Crop receipt')),
|
|
body: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(child: _editor(theme)),
|
|
SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
'Drag the corners over the text you want to read. Pinch to zoom.',
|
|
style: theme.textTheme.bodyMedium,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 12),
|
|
FilledButton.icon(
|
|
onPressed: _ready && !_cropping ? _confirm : null,
|
|
icon: const Icon(Icons.check),
|
|
label: Text(_cropping ? 'Cropping…' : 'Use this area'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _editor(ThemeData theme) {
|
|
if (_error != null) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
_error!,
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.bodyLarge,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final image = _image;
|
|
if (image == null) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Crop(
|
|
image: image,
|
|
controller: _controller,
|
|
onCropped: _onCropped,
|
|
interactive: true,
|
|
filterQuality: FilterQuality.medium,
|
|
baseColor: theme.colorScheme.surface,
|
|
maskColor: Colors.black.withValues(alpha: 0.55),
|
|
radius: 8,
|
|
progressIndicator: const Center(child: CircularProgressIndicator()),
|
|
initialRectBuilder: InitialRectBuilder.withBuilder((
|
|
viewportRect,
|
|
imageRect,
|
|
) {
|
|
final dx = imageRect.width * 0.06;
|
|
final dy = imageRect.height * 0.06;
|
|
return Rect.fromLTRB(
|
|
imageRect.left + dx,
|
|
imageRect.top + dy,
|
|
imageRect.right - dx,
|
|
imageRect.bottom - dy,
|
|
);
|
|
}),
|
|
cornerDotBuilder: (size, _) =>
|
|
DotControl(color: theme.colorScheme.primary),
|
|
onStatusChanged: (status) {
|
|
final ready = status == CropStatus.ready;
|
|
if (ready == _ready) return;
|
|
setState(() => _ready = ready);
|
|
},
|
|
),
|
|
if (_cropping)
|
|
const ColoredBox(
|
|
color: Color(0x66000000),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|