Files
Edouard Marquez e3bc40fdf3 chore: Migration to Dart 3.8 (#6668)
* Migration to Dart 3.8

* New GA

* Fix dartdoc
2025-06-23 18:14:17 +02:00

602 lines
21 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:crop_image/crop_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:openfoodfacts/openfoodfacts.dart';
import 'package:provider/provider.dart';
import 'package:smooth_app/background/background_task_image.dart';
import 'package:smooth_app/background/background_task_upload.dart';
import 'package:smooth_app/database/dao_int.dart';
import 'package:smooth_app/database/local_database.dart';
import 'package:smooth_app/generic_lib/design_constants.dart';
import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart';
import 'package:smooth_app/generic_lib/loading_dialog.dart';
import 'package:smooth_app/helpers/analytics_helper.dart';
import 'package:smooth_app/helpers/database_helper.dart';
import 'package:smooth_app/helpers/image_compute_container.dart';
import 'package:smooth_app/l10n/app_localizations.dart';
import 'package:smooth_app/pages/crop_helper.dart';
import 'package:smooth_app/pages/crop_parameters.dart';
import 'package:smooth_app/pages/prices/eraser_model.dart';
import 'package:smooth_app/pages/prices/eraser_painter.dart';
import 'package:smooth_app/pages/product/common/product_refresher.dart';
import 'package:smooth_app/pages/product/edit_image_button.dart';
import 'package:smooth_app/pages/product/may_exit_page_helper.dart';
import 'package:smooth_app/resources/app_icons.dart' as icons;
import 'package:smooth_app/widgets/smooth_app_bar.dart';
import 'package:smooth_app/widgets/smooth_scaffold.dart';
import 'package:smooth_app/widgets/will_pop_scope.dart';
/// Page dedicated to image cropping. Pops the resulting file path if relevant.
class CropPage extends StatefulWidget {
const CropPage({
required this.inputFile,
required this.initiallyDifferent,
required this.cropHelper,
required this.isLoggedInMandatory,
this.initialCropRect,
this.initialRotation,
this.onRetakePhoto,
});
/// The initial input file we start with.
final File inputFile;
/// Is the full picture initially different from the current selection?
final bool initiallyDifferent;
final Rect? initialCropRect;
final CropRotation? initialRotation;
final bool isLoggedInMandatory;
final CropHelper cropHelper;
final Future<File?> Function()? onRetakePhoto;
@override
State<CropPage> createState() => _CropPageState();
}
class _CropPageState extends State<CropPage> {
late CropController _controller;
late ui.Image _image;
/// The screen size, used as a maximum size for the transient image.
///
/// We need this info:
/// * we experienced performance issues when cropping the full size
/// * it's much faster to create a smaller file
/// * the size of the screen is a good approximation of "how big is enough?"
late Size _screenSize;
/// Progress text, if we are processing data. `null` means we're done.
String? _progress = '';
late Rect _initialCrop;
late CropRotation _initialRotation;
late Uint8List _data;
/// True if we switched to the "erase" mode, and not the "crop grid" mode.
bool _isErasing = false;
final EraserModel _eraserModel = EraserModel();
Future<void> _load(final Uint8List list) async {
_image = await BackgroundTaskImage.loadUiImage(list);
_initialCrop = _getInitialRect();
_initialRotation = widget.initialRotation ?? CropRotation.up;
_controller = CropController(
defaultCrop: _initialCrop,
rotation: _initialRotation,
);
_progress = null;
if (!mounted) {
return;
}
setState(() {});
}
Rect _getInitialRect() {
if (widget.initialCropRect == null) {
return CropHelper.fullImageCropRect;
}
// sometimes the server returns those crop values, meaning full photo.
if (widget.initialCropRect!.left == -1 ||
widget.initialCropRect!.top == -1 ||
widget.initialCropRect!.right == -1 ||
widget.initialCropRect!.bottom == -1) {
return CropHelper.fullImageCropRect;
}
final Rect result;
final CropRotation rotation = widget.initialRotation ?? CropRotation.up;
switch (rotation) {
case CropRotation.up:
case CropRotation.down:
result = Rect.fromLTRB(
widget.initialCropRect!.left / _image.width,
widget.initialCropRect!.top / _image.height,
widget.initialCropRect!.right / _image.width,
widget.initialCropRect!.bottom / _image.height,
);
break;
case CropRotation.right:
case CropRotation.left:
result = Rect.fromLTRB(
widget.initialCropRect!.left / _image.height,
widget.initialCropRect!.top / _image.width,
widget.initialCropRect!.right / _image.height,
widget.initialCropRect!.bottom / _image.width,
);
break;
}
// we clamp in order to avoid controller crash.
return Rect.fromLTRB(
result.left.clamp(0, 1),
result.top.clamp(0, 1),
result.right.clamp(0, 1),
result.bottom.clamp(0, 1),
);
}
@override
void initState() {
super.initState();
_initLoad();
}
Future<void> _initLoad() async {
_data = await widget.inputFile.readAsBytes();
await _load(_data);
}
@override
Widget build(final BuildContext context) {
_screenSize = MediaQuery.sizeOf(context);
final AppLocalizations appLocalizations = AppLocalizations.of(context);
return WillPopScope2(
onWillPop: _onWillPop,
child: SmoothScaffold(
appBar: SmoothAppBar(
centerTitle: false,
titleSpacing: 0.0,
title: Text(
widget.cropHelper.getPageTitle(appLocalizations),
maxLines: 2,
),
actions: <Widget>[
if (widget.onRetakePhoto != null)
Padding(
padding: const EdgeInsetsDirectional.only(end: 8.5),
child: IconButton(
icon: const icons.Camera.restart(),
tooltip: appLocalizations.crop_page_action_retake,
onPressed: () async {
final File? file = await widget.onRetakePhoto?.call();
if (file != null && context.mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute<CropParameters>(
builder: (BuildContext context) => CropPage(
inputFile: file,
initiallyDifferent: widget.initiallyDifferent,
cropHelper: widget.cropHelper,
isLoggedInMandatory: widget.isLoggedInMandatory,
onRetakePhoto: widget.onRetakePhoto,
),
),
);
}
},
),
),
],
),
backgroundColor: Colors.black,
body: _progress != null
? Center(
child: Text(
_progress!,
style: const TextStyle(color: Colors.white),
),
)
: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsetsDirectional.only(
top: SMALL_SPACE,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
if (!_isErasing)
_IconButton(
iconData: Icons.rotate_90_degrees_ccw_outlined,
tooltip: appLocalizations.photo_rotate_left,
onPressed: () => setState(() {
_controller.rotateLeft();
_eraserModel.rotation = _controller.rotation;
}),
),
if (widget.cropHelper.enableEraser)
_IconButton(
iconData: _isErasing ? Icons.crop : Icons.brush,
onPressed: () =>
setState(() => _isErasing = !_isErasing),
),
if (_isErasing)
_IconButton(
iconData: Icons.undo,
tooltip: appLocalizations.photo_undo_action,
onPressed: _eraserModel.isEmpty
? null
: () => setState(() => _eraserModel.undo()),
),
if (!_isErasing)
_IconButton(
iconData: Icons.rotate_90_degrees_cw_outlined,
tooltip: appLocalizations.photo_rotate_right,
onPressed: () => setState(() {
_controller.rotateRight();
_eraserModel.rotation = _controller.rotation;
}),
),
],
),
),
Expanded(
child: Stack(
children: <Widget>[
IgnorePointer(
ignoring: _isErasing,
child: CropImage(
controller: _controller,
image: Image.memory(_data),
minimumImageSize: MINIMUM_TOUCH_SIZE,
gridCornerSize: MINIMUM_TOUCH_SIZE * .75,
touchSize: MINIMUM_TOUCH_SIZE,
paddingSize: MINIMUM_TOUCH_SIZE * .5,
alwaysMove: true,
overlayPainter: !widget.cropHelper.enableEraser
? null
: EraserPainter(eraserModel: _eraserModel),
),
),
if (_isErasing)
LayoutBuilder(
builder:
(
final BuildContext context,
final BoxConstraints constraints,
) => Center(
child: GestureDetector(
onPanStart:
(final DragStartDetails details) =>
setState(
() => _eraserModel.panStart(
details.localPosition,
constraints,
),
),
onPanUpdate:
(final DragUpdateDetails details) =>
setState(
() => _eraserModel.panUpdate(
details.localPosition,
constraints,
),
),
onPanEnd:
(final DragEndDetails details) =>
setState(
() => _eraserModel.panEnd(),
),
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: VERY_SMALL_SPACE,
vertical: SMALL_SPACE,
),
child: SizedBox(
width: double.infinity,
child: EditImageButton.center(
iconData: widget.cropHelper.getProcessIcon(),
label: widget.cropHelper.getProcessLabel(
appLocalizations,
),
onPressed: () async => _saveImageAndPop(),
),
),
),
],
),
),
),
);
}
/// Returns a small file with the cropped image, for the transient image.
///
/// Here we use BMP format as it's faster to encode.
Future<File> _getSmallCroppedImageFile(
final Directory directory,
final int sequenceNumber,
) async {
final AppLocalizations appLocalizations = AppLocalizations.of(context);
final String croppedPath = '${directory.path}/cropped_$sequenceNumber.bmp';
final File result = File(croppedPath);
setState(() => _progress = appLocalizations.crop_page_action_cropping);
final ui.Image cropped = await CropController.getCroppedBitmap(
image: _image,
maxSize: _screenSize.longestSide,
crop: _controller.crop,
rotation: _controller.rotation,
overlayPainter: !widget.cropHelper.enableEraser
? null
: EraserPainter(
eraserModel: EraserModel(
rotation: _controller.rotation,
offsets: _eraserModel.offsets,
),
cropRect: _controller.crop,
),
);
setState(() => _progress = appLocalizations.crop_page_action_local);
try {
await saveBmp(
file: result,
source: cropped,
).timeout(const Duration(seconds: 10));
} catch (e, trace) {
AnalyticsHelper.sendException(e, stackTrace: trace);
rethrow;
}
return result;
}
Future<CropParameters?> _saveImageAndExitTry() async {
final AppLocalizations appLocalizations = AppLocalizations.of(context);
// only for new image upload we have to check the minimum size.
if (widget.cropHelper.isNewImage()) {
// Returns the size of the resulting cropped image.
Size getCroppedSize() {
switch (_controller.rotation) {
case CropRotation.up:
case CropRotation.down:
return Size(
_controller.crop.width * _image.width,
_controller.crop.height * _image.height,
);
case CropRotation.left:
case CropRotation.right:
return Size(
_controller.crop.width * _image.height,
_controller.crop.height * _image.width,
);
}
}
final Size croppedSize = getCroppedSize();
if (!BackgroundTaskImage.isPictureBigEnough(
croppedSize.width,
croppedSize.height,
)) {
final int width = croppedSize.width.floor();
final int height = croppedSize.height.floor();
await showDialog<void>(
context: context,
builder: (BuildContext context) => SmoothAlertDialog(
title: appLocalizations.crop_page_too_small_image_title,
body: Text(
appLocalizations.crop_page_too_small_image_message(
ImageHelper.minimumWidth,
ImageHelper.minimumHeight,
width,
height,
),
),
actionsAxis: Axis.vertical,
positiveAction: SmoothActionButton(
text: appLocalizations.okay,
onPressed: () => Navigator.of(context).pop(),
),
),
);
return null;
}
}
if (!mounted) {
return null;
}
final LocalDatabase localDatabase = context.read<LocalDatabase>();
final DaoInt daoInt = DaoInt(localDatabase);
final int sequenceNumber = await getNextSequenceNumber(
daoInt,
_CROP_PAGE_SEQUENCE_KEY,
);
final Directory directory = await BackgroundTaskUpload.getDirectory();
final File smallCroppedFile = await _getSmallCroppedImageFile(
directory,
sequenceNumber,
);
setState(() => _progress = appLocalizations.crop_page_action_server);
if (!mounted) {
return null;
}
return widget.cropHelper.process(
context: context,
controller: _controller,
image: _image,
smallCroppedFile: smallCroppedFile,
directory: directory,
inputFile: widget.inputFile,
sequenceNumber: sequenceNumber,
offsets: _eraserModel.offsets,
);
}
Future<CropParameters?> _saveImage() async {
if (!await ProductRefresher().checkIfLoggedIn(
context,
isLoggedInMandatory: widget.isLoggedInMandatory,
)) {
return null;
}
setState(
() => _progress = AppLocalizations.of(context).crop_page_action_saving,
);
try {
final CropParameters? cropParameters = await _saveImageAndExitTry();
_progress = null;
if (mounted) {
setState(() {});
}
return cropParameters;
} catch (e) {
await _showErrorDialog();
return null;
} finally {
_progress = null;
}
}
static const String _CROP_PAGE_SEQUENCE_KEY = 'crop_page_sequence';
/// Saves the image if relevant after a user click, and pops the result.
Future<void> _saveImageAndPop() async {
if (_nothingHasChanged()) {
// nothing has changed, let's leave
Navigator.of(context).pop();
return;
}
try {
final CropParameters? cropParameters = await _saveImage();
if (cropParameters != null) {
/// Checking if the context is still mounted is not enough here
SchedulerBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pop<CropParameters>(cropParameters);
});
}
} catch (e) {
await _showExceptionDialog(e);
}
}
bool _nothingHasChanged() =>
_controller.value.rotation == _initialRotation &&
_controller.value.crop == _initialCrop &&
!widget.initiallyDifferent;
Future<(bool, CropParameters?)> _onWillPop() async {
if (_nothingHasChanged()) {
// nothing has changed, let's leave
return (true, null);
}
// the cropped image has changed, but the user went back without saving
final bool? pleaseSave = await MayExitPageHelper()
.openSaveBeforeLeavingDialog(
context,
title: widget.cropHelper.getPageTitle(AppLocalizations.of(context)),
);
if (pleaseSave == null) {
return (false, null);
}
if (pleaseSave == false) {
return (true, null);
}
if (!mounted) {
return (false, null);
}
try {
final CropParameters? cropParameters = await _saveImage();
if (cropParameters != null) {
if (mounted) {
return (true, cropParameters);
}
}
} catch (e) {
await _showExceptionDialog(e);
}
return (false, null);
}
Future<void> _showErrorDialog() async {
if (!mounted) {
return;
}
final AppLocalizations appLocalizations = AppLocalizations.of(context);
return showDialog<void>(
context: context,
builder: (BuildContext context) {
return SmoothSimpleErrorAlertDialog(
title: appLocalizations.crop_page_action_local_failed_title,
message: appLocalizations.crop_page_action_local_failed_message,
);
},
);
}
Future<void> _showExceptionDialog(final Object e) async {
if (mounted) {
// not likely to happen, but you never know...
return LoadingDialog.error(
context: context,
title: 'Could not prepare picture with exception $e',
);
}
}
}
/// Standard icon button for this page.
class _IconButton extends StatelessWidget {
const _IconButton({
required this.iconData,
required this.onPressed,
this.tooltip,
});
final IconData iconData;
final VoidCallback? onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
final Widget icon = ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: Icon(iconData, semanticLabel: tooltip),
);
if (tooltip != null) {
return Tooltip(message: tooltip, child: icon);
} else {
return icon;
}
}
}