Merge branch 'main' into feat/mobile-ocr

This commit is contained in:
Yaros
2026-04-22 18:08:10 +02:00
1565 changed files with 43096 additions and 79581 deletions
@@ -5,11 +5,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/activities/comment_bubble.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/widgets/activities/comment_bubble.dart';
@RoutePage()
class DriftActivitiesPage extends HookConsumerWidget {
@@ -21,8 +21,8 @@ class DriftActivitiesPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier);
final activities = ref.watch(albumActivityProvider(album.id, assetId));
final activityNotifier = ref.read(albumActivityProvider((album.id, assetId)).notifier);
final activities = ref.watch(albumActivityProvider((album.id, assetId)));
final listViewScrollController = useScrollController();
void scrollToBottom() {
@@ -34,7 +34,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
final isOwner = album.ownerId == userId;
void showErrorMessage() {
context.pop();
ContextHelper(context).pop();
ImmichToast.show(
context: context,
msg: "shared_album_section_people_action_error".t(context: context),
@@ -60,7 +60,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
showErrorMessage();
}
context.pop();
ContextHelper(context).pop();
}
Future<void> addUsers() async {
@@ -112,10 +112,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Updated At', value: asset.updatedAt.toString()),
_PropertyItem(label: 'Width', value: asset.width?.toString()),
_PropertyItem(label: 'Height', value: asset.height?.toString()),
_PropertyItem(
label: 'Duration',
value: asset.durationInSeconds != null ? '${asset.durationInSeconds} seconds' : null,
),
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
@@ -33,7 +33,7 @@ class DriftMapPage extends StatelessWidget {
top: 70,
child: IconButton.filled(
color: Colors.white,
onPressed: () => context.pop(),
onPressed: () => ContextHelper(context).pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(8),
@@ -89,7 +89,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
key: ValueKey('avatar-${person.id}'),
key: ValueKey(person.id),
maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
@@ -58,11 +58,11 @@ class _DriftPersonPageState extends ConsumerState<DriftPersonPage> {
return PersonOptionSheet(
onEditName: () async {
await handleEditName(context);
context.pop();
ContextHelper(context).pop();
},
onEditBirthday: () async {
await handleEditBirthday(context);
context.pop();
ContextHelper(context).pop();
},
birthdayExists: _person.birthDate != null,
);
@@ -0,0 +1,399 @@
import 'dart:async';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/aspect_ratios.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:openapi/api.dart' show RotateParameters, MirrorParameters, MirrorAxis;
@RoutePage()
class DriftEditImagePage extends ConsumerStatefulWidget {
final Image image;
final Future<void> Function(List<AssetEdit> edits) applyEdits;
const DriftEditImagePage({super.key, required this.image, required this.applyEdits});
@override
ConsumerState<DriftEditImagePage> createState() => _DriftEditImagePageState();
}
class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with TickerProviderStateMixin {
Future<void> _saveEditedImage() async {
ref.read(editorStateProvider.notifier).setIsEditing(true);
final editorState = ref.read(editorStateProvider);
final cropParameters = convertRectToCropParameters(
editorState.crop,
editorState.originalWidth,
editorState.originalHeight,
);
final edits = <AssetEdit>[];
if (cropParameters.width != editorState.originalWidth || cropParameters.height != editorState.originalHeight) {
edits.add(CropEdit(cropParameters));
}
if (editorState.flipHorizontal) {
edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)));
}
if (editorState.flipVertical) {
edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)));
}
final normalizedRotation = (editorState.rotationAngle % 360 + 360) % 360;
if (normalizedRotation != 0) {
edits.add(RotateEdit(RotateParameters(angle: normalizedRotation)));
}
try {
await widget.applyEdits(edits);
ImmichToast.show(context: context, msg: 'success'.tr(), toastType: ToastType.success);
Navigator.of(context).pop();
} catch (e) {
ImmichToast.show(context: context, msg: 'error_title'.tr(), toastType: ToastType.error);
} finally {
ref.read(editorStateProvider.notifier).setIsEditing(false);
}
}
Future<bool?> _showDiscardChangesDialog() {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('editor_discard_edits_title'.tr()),
content: Text('editor_discard_edits_prompt'.tr()),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
style: ButtonStyle(
foregroundColor: WidgetStateProperty.all(context.themeData.colorScheme.onSurfaceVariant),
),
child: Text('cancel'.tr()),
),
TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text('confirm'.tr())),
],
),
);
}
@override
Widget build(BuildContext context) {
final hasUnsavedEdits = ref.watch(editorStateProvider.select((state) => state.hasUnsavedEdits));
return PopScope(
canPop: !hasUnsavedEdits,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldDiscard = await _showDiscardChangesDialog() ?? false;
if (shouldDiscard && mounted) {
Navigator.of(context).pop();
}
},
child: Theme(
data: getThemeData(colorScheme: ref.watch(immichThemeProvider).dark, locale: context.locale),
child: Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: Text("edit".tr()),
leading: ImmichCloseButton(onPressed: () => Navigator.of(context).maybePop()),
actions: [_SaveEditsButton(onSave: _saveEditedImage)],
),
backgroundColor: Colors.black,
body: SafeArea(
bottom: false,
child: Column(
children: [
Expanded(child: _EditorPreview(image: widget.image)),
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
alignment: Alignment.bottomCenter,
clipBehavior: Clip.none,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: ref.watch(immichThemeProvider).dark.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
_TransformControls(),
Padding(
padding: EdgeInsets.only(bottom: 36, left: 24, right: 24),
child: Row(children: [Spacer(), _ResetEditsButton()]),
),
],
),
),
),
],
),
),
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final AspectRatioPreset ratio;
final bool isSelected;
final VoidCallback onPressed;
const _AspectRatioButton({required this.ratio, required this.isSelected, required this.onPressed});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.max,
children: [
IconButton(
iconSize: 36,
icon: Transform.rotate(
angle: ratio.iconRotated ? pi / 2 : 0,
child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color),
),
onPressed: onPressed,
),
Text(ratio.label, style: context.textTheme.displayMedium),
],
);
}
}
class _AspectRatioSelector extends ConsumerWidget {
const _AspectRatioSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier);
// the whole crop view is rotated, so we need to swap the aspect ratio when the rotation is 90 or 270 degrees
double? selectedAspectRatio = editorState.aspectRatio;
if (editorState.rotationAngle % 180 != 0 && selectedAspectRatio != null) {
selectedAspectRatio = 1 / selectedAspectRatio;
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: AspectRatioPreset.values.map((entry) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _AspectRatioButton(
ratio: entry,
isSelected: selectedAspectRatio == entry.ratio,
onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
),
);
}).toList(),
),
);
}
}
class _TransformControls extends ConsumerWidget {
const _TransformControls();
@override
Widget build(BuildContext context, WidgetRef ref) {
final editorNotifier = ref.read(editorStateProvider.notifier);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ImmichIconButton(
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: editorNotifier.rotateCCW,
),
const SizedBox(width: 8),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: editorNotifier.rotateCW,
),
],
),
Row(
children: [
ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: editorNotifier.flipHorizontally,
),
const SizedBox(width: 8),
Transform.rotate(
angle: pi / 2,
child: ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: editorNotifier.flipVertically,
),
),
],
),
],
),
),
const _AspectRatioSelector(),
const SizedBox(height: 32),
],
);
}
}
class _SaveEditsButton extends ConsumerWidget {
final VoidCallback onSave;
const _SaveEditsButton({required this.onSave});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isApplyingEdits = ref.watch(editorStateProvider.select((state) => state.isApplyingEdits));
final hasUnsavedEdits = ref.watch(editorStateProvider.select((state) => state.hasUnsavedEdits));
return isApplyingEdits
? const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2.5)),
)
: ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
disabled: !hasUnsavedEdits,
onPressed: onSave,
);
}
}
class _ResetEditsButton extends ConsumerWidget {
const _ResetEditsButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier);
return ImmichTextButton(
labelText: 'reset'.tr(),
onPressed: editorNotifier.resetEdits,
variant: ImmichVariant.ghost,
expanded: false,
disabled: !editorState.hasEdits || editorState.isApplyingEdits,
);
}
}
class _EditorPreview extends ConsumerStatefulWidget {
final Image image;
const _EditorPreview({required this.image});
@override
ConsumerState<_EditorPreview> createState() => _EditorPreviewState();
}
class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProviderStateMixin {
late final CropController cropController;
@override
void initState() {
super.initState();
cropController = CropController();
cropController.crop = ref.read(editorStateProvider.select((state) => state.crop));
cropController.addListener(onCrop);
}
void onCrop() {
if (!mounted || cropController.crop == ref.read(editorStateProvider).crop) {
return;
}
ref.read(editorStateProvider.notifier).setCrop(cropController.crop);
}
@override
void dispose() {
cropController.removeListener(onCrop);
cropController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier);
ref.listen(editorStateProvider, (_, current) {
cropController.aspectRatio = current.aspectRatio;
if (cropController.crop != current.crop) {
cropController.crop = current.crop;
}
});
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// Calculate the bounding box size needed for the rotated container
final baseWidth = constraints.maxWidth * 0.9;
final baseHeight = constraints.maxHeight * 0.95;
return Center(
child: AnimatedRotation(
turns: editorState.rotationAngle / 360,
duration: editorState.animationDuration,
curve: Curves.easeInOut,
onEnd: editorNotifier.normalizeRotation,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..scaleByDouble(
editorState.flipHorizontal ? -1.0 : 1.0,
editorState.flipVertical ? -1.0 : 1.0,
1.0,
1.0,
),
child: Container(
padding: const EdgeInsets.all(10),
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white),
),
),
),
);
},
);
}
}
@@ -0,0 +1,210 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
final editorStateProvider = NotifierProvider<EditorProvider, EditorState>(EditorProvider.new);
class EditorProvider extends Notifier<EditorState> {
@override
EditorState build() {
return const EditorState();
}
void clear() {
state = const EditorState();
}
void init(List<AssetEdit> edits, ExifInfo exifInfo) {
clear();
final existingCrop = edits.whereType<CropEdit>().firstOrNull;
final originalWidth = exifInfo.isFlipped ? exifInfo.height : exifInfo.width;
final originalHeight = exifInfo.isFlipped ? exifInfo.width : exifInfo.height;
Rect crop = existingCrop != null && originalWidth != null && originalHeight != null
? convertCropParametersToRect(existingCrop.parameters, originalWidth, originalHeight)
: const Rect.fromLTRB(0, 0, 1, 1);
final transform = normalizeTransformEdits(edits);
state = state.copyWith(
originalWidth: originalWidth,
originalHeight: originalHeight,
crop: crop,
flipHorizontal: transform.mirrorHorizontal,
flipVertical: transform.mirrorVertical,
);
_animateRotation(transform.rotation.toInt(), duration: Duration.zero);
}
void _animateRotation(int angle, {Duration duration = const Duration(milliseconds: 300)}) {
state = state.copyWith(rotationAngle: angle, animationDuration: duration);
}
void normalizeRotation() {
final normalizedAngle = ((state.rotationAngle % 360) + 360) % 360;
if (normalizedAngle != state.rotationAngle) {
state = state.copyWith(rotationAngle: normalizedAngle, animationDuration: Duration.zero);
}
}
void setIsEditing(bool isApplyingEdits) {
state = state.copyWith(isApplyingEdits: isApplyingEdits);
}
void setCrop(Rect crop) {
state = state.copyWith(crop: crop, hasUnsavedEdits: true);
}
void setAspectRatio(double? aspectRatio) {
if (aspectRatio != null && state.rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations
aspectRatio = 1 / aspectRatio;
}
state = state.copyWith(aspectRatio: aspectRatio);
}
void resetEdits() {
_animateRotation(0);
state = state.copyWith(
flipHorizontal: false,
flipVertical: false,
crop: const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio: null,
hasUnsavedEdits: true,
);
}
void rotateCCW() {
_animateRotation(state.rotationAngle - 90);
state = state.copyWith(hasUnsavedEdits: true);
}
void rotateCW() {
_animateRotation(state.rotationAngle + 90);
state = state.copyWith(hasUnsavedEdits: true);
}
void flipHorizontally() {
if (state.rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, flipping horizontally is equivalent to flipping vertically
state = state.copyWith(flipVertical: !state.flipVertical, hasUnsavedEdits: true);
} else {
state = state.copyWith(flipHorizontal: !state.flipHorizontal, hasUnsavedEdits: true);
}
}
void flipVertically() {
if (state.rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, flipping vertically is equivalent to flipping horizontally
state = state.copyWith(flipHorizontal: !state.flipHorizontal, hasUnsavedEdits: true);
} else {
state = state.copyWith(flipVertical: !state.flipVertical, hasUnsavedEdits: true);
}
}
}
class EditorState {
final bool isApplyingEdits;
final int rotationAngle;
final bool flipHorizontal;
final bool flipVertical;
final Rect crop;
final double? aspectRatio;
final int originalWidth;
final int originalHeight;
final Duration animationDuration;
final bool hasUnsavedEdits;
const EditorState({
bool? isApplyingEdits,
int? rotationAngle,
bool? flipHorizontal,
bool? flipVertical,
Rect? crop,
this.aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
bool? hasUnsavedEdits,
}) : isApplyingEdits = isApplyingEdits ?? false,
rotationAngle = rotationAngle ?? 0,
flipHorizontal = flipHorizontal ?? false,
flipVertical = flipVertical ?? false,
animationDuration = animationDuration ?? Duration.zero,
originalWidth = originalWidth ?? 0,
originalHeight = originalHeight ?? 0,
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
hasUnsavedEdits = hasUnsavedEdits ?? false;
EditorState copyWith({
bool? isApplyingEdits,
int? rotationAngle,
bool? flipHorizontal,
bool? flipVertical,
double? aspectRatio = double.infinity,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
Rect? crop,
bool? hasUnsavedEdits,
}) {
return EditorState(
isApplyingEdits: isApplyingEdits ?? this.isApplyingEdits,
rotationAngle: rotationAngle ?? this.rotationAngle,
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
flipVertical: flipVertical ?? this.flipVertical,
aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
animationDuration: animationDuration ?? this.animationDuration,
originalWidth: originalWidth ?? this.originalWidth,
originalHeight: originalHeight ?? this.originalHeight,
crop: crop ?? this.crop,
hasUnsavedEdits: hasUnsavedEdits ?? this.hasUnsavedEdits,
);
}
bool get hasEdits {
return rotationAngle != 0 || flipHorizontal || flipVertical || crop != const Rect.fromLTRB(0, 0, 1, 1);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is EditorState &&
other.isApplyingEdits == isApplyingEdits &&
other.rotationAngle == rotationAngle &&
other.flipHorizontal == flipHorizontal &&
other.flipVertical == flipVertical &&
other.crop == crop &&
other.aspectRatio == aspectRatio &&
other.originalWidth == originalWidth &&
other.originalHeight == originalHeight &&
other.animationDuration == animationDuration &&
other.hasUnsavedEdits == hasUnsavedEdits;
}
@override
int get hashCode {
return isApplyingEdits.hashCode ^
rotationAngle.hashCode ^
flipHorizontal.hashCode ^
flipVertical.hashCode ^
crop.hashCode ^
aspectRatio.hashCode ^
originalWidth.hashCode ^
originalHeight.hashCode ^
animationDuration.hashCode ^
hasUnsavedEdits.hashCode;
}
}
@@ -1,179 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'package:immich_ui/immich_ui.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to crop an image and then navigate to the [EditImagePage] with the
/// cropped image.
@RoutePage()
class DriftCropImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftCropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final cropController = useCropController();
final aspectRatio = useState<double?>(null);
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: const ImmichCloseButton(),
actions: [
ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(controller: cropController, image: image, gridColor: Colors.white),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImmichIconButton(
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: () => cropController.rotateLeft(),
),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: () => cropController.rotateRight(),
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
],
),
),
),
),
],
);
},
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
final ValueNotifier<double?> aspectRatio;
final double? ratio;
final String label;
const _AspectRatioButton({
required this.cropController,
required this.aspectRatio,
required this.ratio,
required this.label,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
onPressed: () {
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}
@@ -1,153 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/image_converter.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
/// A stateless widget that provides functionality for editing an image.
///
/// This widget allows users to edit an image provided either as an [Asset] or
/// directly as an [Image]. It ensures that exactly one of these is provided.
///
/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone
/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server.
@immutable
@RoutePage()
class DriftEditImagePage extends ConsumerWidget {
final BaseAsset asset;
final Image image;
final bool isEdited;
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
void _exitEditing(BuildContext context) {
// this assumes that the only way to get to this page is from the AssetViewerRoute
context.navigator.popUntil((route) => route.data?.name == AssetViewerRoute.name);
}
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await imageToUint8List(image);
LocalAsset? localAsset;
try {
localAsset = await ref
.read(fileMediaRepositoryProvider)
.saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg");
} on PlatformException catch (e) {
// OS might not return the saved image back, so we handle that gracefully
// This can happen if app does not have full library access
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
}
unawaited(ref.read(backgroundSyncProvider).syncLocal(full: true));
_exitEditing(context);
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
if (localAsset == null) {
return;
}
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text("edit".tr()),
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
onPressed: () => _exitEditing(context),
),
actions: <Widget>[
TextButton(
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(7)),
child: Image(image: image.image, fit: BoxFit.contain),
),
),
),
),
bottomNavigationBar: Container(
height: 70,
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(30)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftCropImageRoute(asset: asset, image: image));
},
),
Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftFilterImageRoute(asset: asset, image: image));
},
),
Text("filter".tr(), style: context.textTheme.displayMedium),
],
),
],
),
),
);
}
}
@@ -1,159 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/constants/filters.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
/// A widget for filtering an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to add filters to an image and then navigate to the [EditImagePage] with the
/// final composition.'
@RoutePage()
class DriftFilterImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftFilterImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
final completer = Completer<ui.Image>();
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..colorFilter = filter;
canvas.drawImage(inputImage, Offset.zero, paint);
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
completer.complete(image);
});
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
completer.complete(info.image);
}),
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
return Image.memory(pngBytes, fit: BoxFit.contain);
}
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("filter".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final filteredImage = await applyFilterAndConvert(colorFilter.value);
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true)));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Column(
children: [
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
),
);
},
),
),
],
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.image,
required this.label,
required this.filter,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: context.themeData.textTheme.bodyMedium),
],
);
}
}
@@ -6,11 +6,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
@@ -340,11 +340,11 @@ class DriftSearchPage extends HookConsumerWidget {
child: QuickDatePicker(
currentInput: dateInputFilter.value,
onRequestPicker: () {
context.pop();
ContextHelper(context).pop();
showDatePicker();
},
onSelect: (date) {
context.pop();
ContextHelper(context).pop();
datePicked(date);
},
),
@@ -1,10 +1,17 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget {
@@ -14,13 +21,33 @@ class EditImageActionButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
onPress() {
if (currentAsset == null) {
Future<void> editImage(List<AssetEdit> edits) async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
final image = Image(image: getFullImageProvider(currentAsset));
context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, isEdited: false));
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
}
Future<void> onPress() async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
final imageProvider = getFullImageProvider(currentAsset, edited: false);
final image = Image(image: imageProvider);
final (edits, exifInfo) = await (
ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!),
ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!),
).wait;
if (exifInfo == null) {
return;
}
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage));
}
return BaseActionButton(
@@ -23,7 +23,7 @@ class LikeActivityActionButton extends ConsumerWidget {
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
final activities = ref.watch(albumActivityProvider((album?.id ?? "", asset?.id)));
onTap(Activity? liked) async {
if (user == null) {
@@ -31,12 +31,12 @@ class LikeActivityActionButton extends ConsumerWidget {
}
if (liked != null) {
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).removeActivity(liked.id);
await ref.read(albumActivityProvider((album?.id ?? "", asset?.id)).notifier).removeActivity(liked.id);
} else {
await ref.read(albumActivityProvider(album?.id ?? "", asset?.id).notifier).addLike();
await ref.read(albumActivityProvider((album?.id ?? "", asset?.id)).notifier).addLike();
}
ref.invalidate(albumActivityProvider(album?.id ?? "", asset?.id));
ref.invalidate(albumActivityProvider((album?.id ?? "", asset?.id)));
}
return activities.when(
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
@@ -17,9 +17,9 @@ import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.wi
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -833,7 +833,7 @@ class CreateAlbumButton extends ConsumerWidget {
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(asset.remoteId!));
context.pop();
ContextHelper(context).pop();
}
return SliverPadding(
@@ -6,10 +6,10 @@ import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
@@ -73,7 +73,7 @@ class PeopleDetails extends ConsumerWidget {
context.back();
return;
}
context.pop();
ContextHelper(context).pop();
context.pushRoute(DriftPersonRoute(person: person));
},
onNameTap: () => showNameEditModal(person),
@@ -3,16 +3,18 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
class ViewerBottomBar extends ConsumerWidget {
@@ -30,6 +32,7 @@ class ViewerBottomBar extends ConsumerWidget {
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider);
final originalTheme = context.themeData;
@@ -38,7 +41,9 @@ class ViewerBottomBar extends ConsumerWidget {
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
// edit sync was added in 2.6.0
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[
@@ -148,7 +148,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
if (ref.read(assetViewerProvider).showingDetails) return;
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
if (autoPlayVideo) await _notifier.play();
if (autoPlayVideo || widget.asset.isMotionPhoto) await _notifier.play();
}
void _onPlaybackEnded() {
@@ -9,9 +9,9 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
@@ -38,7 +38,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
ref.watch(albumActivityProvider(album.id, asset.id));
ref.watch(albumActivityProvider((album.id, asset.id)));
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -147,7 +147,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
}
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
// Create new provider and cache it
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
@@ -170,13 +170,14 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
thumbhash: thumbhash,
assetType: asset.type,
isAnimated: asset.isAnimatedImage,
edited: edited,
);
}
return provider;
}
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
@@ -184,7 +185,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash) : null;
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>
@@ -13,11 +13,12 @@ import 'package:openapi/api.dart';
class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
with CancellableImageProviderMixin<RemoteImageProvider> {
final String url;
final bool edited;
RemoteImageProvider({required this.url});
RemoteImageProvider({required this.url, this.edited = true});
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash})
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash);
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, this.edited = true})
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited);
@override
Future<RemoteImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -45,13 +46,13 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteImageProvider) {
return url == other.url;
return url == other.url && edited == other.edited;
}
return false;
}
@override
int get hashCode => url.hashCode;
int get hashCode => url.hashCode ^ edited.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
@@ -60,12 +61,14 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final String thumbhash;
final AssetType assetType;
final bool isAnimated;
final bool edited;
RemoteFullImageProvider({
required this.assetId,
required this.thumbhash,
required this.assetType,
required this.isAnimated,
this.edited = true,
});
@override
@@ -91,7 +94,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
initialImage: getInitialImage(
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, edited: key.edited),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@@ -109,7 +114,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
}
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
uri: getThumbnailUrlForRemoteId(
key.assetId,
type: AssetMediaSize.preview,
thumbhash: key.thumbhash,
edited: key.edited,
),
);
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
yield* loadRequest(previewRequest, decode, isFinal: !loadOriginal);
@@ -122,7 +132,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return;
}
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
final originalRequest = request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
);
yield* loadRequest(originalRequest, decode, isFinal: true);
}
@@ -134,7 +146,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
}
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
uri: getThumbnailUrlForRemoteId(
key.assetId,
type: AssetMediaSize.preview,
thumbhash: key.thumbhash,
edited: key.edited,
),
);
yield* loadRequest(previewRequest, decode, isFinal: false);
@@ -143,7 +160,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
}
// always try original for animated, since previews don't support animation
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
final originalRequest = request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
);
final codec = await loadCodecRequest(originalRequest, isFinal: true);
if (codec == null) {
if (isCancelled) {
@@ -158,12 +177,15 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated;
return assetId == other.assetId &&
thumbhash == other.thumbhash &&
isAnimated == other.isAnimated &&
edited == other.edited;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode ^ edited.hashCode;
}
@@ -129,7 +129,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
}
animation.addStatusListener(animationStatusListener);
return to.widget;
return direction == HeroFlightDirection.push ? from.widget : to.widget;
},
),
),
@@ -132,7 +132,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
final currentRoute = ref.read(currentRouteNameProvider);
if (currentRoute == AssetViewerRoute.name || currentRoute == GalleryViewerRoute.name) {
if (currentRoute == AssetViewerRoute.name) {
return;
}
@@ -40,8 +40,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
child: MaterialButton(
minWidth: 0,
onPressed: () async {
await context.maybePop();
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
await context.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate));
},
shape: const CircleBorder(),