feat(mobile): inline asset details (#25952)

The existing implementation for showing asset details uses a bottom
sheet, and is not in sync with the preview or scroll intent. Other apps
use inline details, which is much cleaner and feels better to use.
This commit is contained in:
Thomas
2026-02-17 15:24:34 +00:00
committed by GitHub
parent 06d487782e
commit 5c6433b4ca
42 changed files with 1518 additions and 1277 deletions

View File

@@ -16,9 +16,8 @@ class ScrollToDateEvent extends Event {
} }
// Asset Viewer Events // Asset Viewer Events
class ViewerOpenBottomSheetEvent extends Event { class ViewerShowDetailsEvent extends Event {
final bool activitiesMode; const ViewerShowDetailsEvent();
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
} }
class ViewerReloadAssetEvent extends Event { class ViewerReloadAssetEvent extends Event {

View File

@@ -183,8 +183,8 @@ class TimelineService {
return _buffer.slice(start, start + count); return _buffer.slice(start, start + count);
} }
// Pre-cache assets around the given index for asset viewer // Preload assets around the given index for asset viewer
Future<void> preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); Future<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length)); BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length));

View File

@@ -32,3 +32,125 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
damping: 80, damping: 80,
); );
} }
class SnapScrollPhysics extends ScrollPhysics {
static const _minFlingVelocity = 700.0;
static const minSnapDistance = 30.0;
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
const SnapScrollPhysics({super.parent});
@override
SnapScrollPhysics applyTo(ScrollPhysics? ancestor) {
return SnapScrollPhysics(parent: buildParent(ancestor));
}
@override
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
assert(
position is SnapScrollPosition,
'SnapScrollPhysics can only be used with Scrollables that use a '
'controller whose createScrollPosition returns a SnapScrollPosition',
);
final snapOffset = (position as SnapScrollPosition).snapOffset;
if (snapOffset <= 0) {
return super.createBallisticSimulation(position, velocity);
}
if (position.pixels >= snapOffset) {
final simulation = super.createBallisticSimulation(position, velocity);
if (simulation == null || simulation.x(double.infinity) >= snapOffset) {
return simulation;
}
}
return ScrollSpringSimulation(
_spring,
position.pixels,
target(position, velocity, snapOffset),
velocity,
tolerance: toleranceFor(position),
);
}
static double target(ScrollMetrics position, double velocity, double snapOffset) {
if (velocity > _minFlingVelocity) return snapOffset;
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
}
}
class SnapScrollPosition extends ScrollPositionWithSingleContext {
double snapOffset;
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
}
class ProxyScrollController extends ScrollController {
final ScrollController scrollController;
ProxyScrollController({required this.scrollController});
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
@override
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
return ProxyScrollPosition(
scrollController: scrollController,
physics: physics,
context: context,
oldPosition: oldPosition,
);
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
}
class ProxyScrollPosition extends SnapScrollPosition {
final ScrollController scrollController;
ProxyScrollPosition({
required this.scrollController,
required super.physics,
required super.context,
super.oldPosition,
});
@override
double setPixels(double newPixels) {
final overscroll = super.setPixels(newPixels);
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
scrollController.position.forcePixels(pixels);
}
return overscroll;
}
@override
void forcePixels(double value) {
super.forcePixels(value);
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
scrollController.position.forcePixels(pixels);
}
}
@override
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
? scrollController.position.maxScrollExtent
: super.maxScrollExtent;
@override
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
? scrollController.position.minScrollExtent
: super.minScrollExtent;
@override
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
? scrollController.position.viewportDimension
: super.viewportDimension;
}

View File

@@ -14,13 +14,15 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
@RoutePage() @RoutePage()
class DriftActivitiesPage extends HookConsumerWidget { class DriftActivitiesPage extends HookConsumerWidget {
final RemoteAlbum album; final RemoteAlbum album;
final String? assetId;
final String? assetName;
const DriftActivitiesPage({super.key, required this.album}); const DriftActivitiesPage({super.key, required this.album, this.assetId, this.assetName});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final activityNotifier = ref.read(albumActivityProvider(album.id).notifier); final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier);
final activities = ref.watch(albumActivityProvider(album.id)); final activities = ref.watch(albumActivityProvider(album.id, assetId));
final listViewScrollController = useScrollController(); final listViewScrollController = useScrollController();
void scrollToBottom() { void scrollToBottom() {
@@ -36,7 +38,13 @@ class DriftActivitiesPage extends HookConsumerWidget {
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(album.name), title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(album.name),
if (assetName != null) Text(assetName!, style: context.textTheme.bodySmall),
],
),
actions: [const LikeActivityActionButton(iconOnly: true)], actions: [const LikeActivityActionButton(iconOnly: true)],
actionsPadding: const EdgeInsets.only(right: 8), actionsPadding: const EdgeInsets.only(right: 8),
), ),
@@ -47,7 +55,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
activityWidgets.add( activityWidgets.add(
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: CommentBubble(activity: activity), child: CommentBubble(activity: activity, isAssetActivity: assetId != null),
), ),
); );
} }

View File

@@ -12,7 +12,7 @@ import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.wid
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';

View File

@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';

View File

@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
class EditImageActionButton extends ConsumerWidget { class EditImageActionButton extends ConsumerWidget {

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';

View File

@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.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/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';

View File

@@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.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/album/drift_activity_text_field.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
class ActivitiesBottomSheet extends HookConsumerWidget {
final DraggableScrollableController controller;
final double initialChildSize;
final bool scrollToBottomInitially;
const ActivitiesBottomSheet({
required this.controller,
this.initialChildSize = 0.35,
this.scrollToBottomInitially = true,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentRemoteAlbumProvider)!;
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
}
Widget buildActivitiesSliver() {
return activities.widgetWhen(
onLoading: () => const SliverToBoxAdapter(child: SizedBox.shrink()),
onData: (data) {
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == data.length) {
return const SizedBox.shrink();
}
final activity = data[data.length - 1 - index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: CommentBubble(activity: activity, isAssetActivity: true),
);
}, childCount: data.length + 1),
);
},
);
}
return BaseBottomSheet(
actions: [],
slivers: [buildActivitiesSliver()],
footer: Padding(
// TODO: avoid fixed padding, use context.padding.bottom
padding: const EdgeInsets.only(bottom: 32),
child: Column(
children: [
const Divider(indent: 16, endIndent: 16),
DriftActivityTextField(
isEnabled: album.isActivityEnabled,
isBottomSheet: true,
// likeId: likedId,
onSubmit: onAddComment,
),
],
),
),
controller: controller,
initialChildSize: initialChildSize,
minChildSize: 0.1,
maxChildSize: 0.88,
expand: false,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
class AssetDetails extends ConsumerWidget {
final double minHeight;
const AssetDetails({required this.minHeight, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
return Container(
constraints: BoxConstraints(minHeight: minHeight),
decoration: BoxDecoration(
color: context.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DragHandle(),
const DateTimeDetails(),
const PeopleDetails(),
const LocationDetails(),
const TechnicalDetails(),
const RatingDetails(),
const AppearsInDetails(),
SizedBox(height: context.padding.bottom + 48),
],
),
);
}
}

View File

@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AppearsInDetails extends ConsumerWidget {
const AppearsInDetails({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
String? remoteAssetId;
if (asset is RemoteAsset) {
remoteAssetId = asset.id;
} else if (asset is LocalAsset) {
remoteAssetId = asset.remoteAssetId;
}
if (remoteAssetId == null) return const SizedBox.shrink();
final userId = ref.watch(currentUserProvider)?.id;
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId));
return assetAlbums.when(
data: (albums) {
if (albums.isEmpty) return const SizedBox.shrink();
albums.sortBy((a) => a.name);
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Column(
spacing: 12,
children: [
SheetTile(
title: 'appears_in'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
Padding(
padding: const EdgeInsets.only(left: 12),
child: Column(
spacing: 12,
children: albums.map((album) {
final isOwner = album.ownerId == userId;
return AlbumTile(
album: album,
isOwner: isOwner,
onAlbumSelected: (album) async {
ref.invalidate(assetViewerProvider);
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
},
);
}).toList(),
),
),
],
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
}

View File

@@ -0,0 +1,142 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.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/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = '';
class DateTimeDetails extends ConsumerWidget {
const DateTimeDetails({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
return Column(
children: [
SheetTile(
title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.labelLarge,
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner
? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context)
: null,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
],
);
}
static String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
DateTime dateTime = asset.createdAt.toLocal();
Duration timeZoneOffset = dateTime.timeZoneOffset;
if (exifInfo?.dateTimeOriginal != null) {
(dateTime, timeZoneOffset) = applyTimezoneOffset(
dateTime: exifInfo!.dateTimeOriginal!,
timeZone: exifInfo.timeZone,
);
}
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
return '$date$_kSeparator$time $timezone';
}
}
class _SheetAssetDescription extends ConsumerStatefulWidget {
final ExifInfo exif;
final bool isEditable;
const _SheetAssetDescription({required this.exif, this.isEditable = true});
@override
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
}
class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> {
late TextEditingController _controller;
final _descriptionFocus = FocusNode();
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.exif.description ?? '');
}
Future<void> saveDescription(String? previousDescription) async {
final newDescription = _controller.text.trim();
if (newDescription == previousDescription) {
_descriptionFocus.unfocus();
return;
}
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
if (!editAction.success) {
_controller.text = previousDescription ?? '';
ImmichToast.show(
context: context,
msg: 'exif_bottom_sheet_description_error'.t(context: context),
toastType: ToastType.error,
);
}
_descriptionFocus.unfocus();
}
@override
Widget build(BuildContext context) {
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final currentDescription = currentExifInfo?.description ?? '';
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
context: context,
);
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
_controller.text = currentDescription;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: IgnorePointer(
ignoring: !widget.isEditable,
child: TextField(
controller: _controller,
keyboardType: TextInputType.multiline,
maxLines: null,
focusNode: _descriptionFocus,
decoration: InputDecoration(
hintText: hintText,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
),
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class DragHandle extends StatelessWidget {
const DragHandle({super.key});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(2)),
color: context.colorScheme.onSurfaceVariant,
),
),
),
);
}

View File

@@ -8,18 +8,18 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
class SheetLocationDetails extends ConsumerStatefulWidget { class LocationDetails extends ConsumerStatefulWidget {
const SheetLocationDetails({super.key}); const LocationDetails({super.key});
@override @override
ConsumerState createState() => _SheetLocationDetailsState(); ConsumerState createState() => _LocationDetailsState();
} }
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> { class _LocationDetailsState extends ConsumerState<LocationDetails> {
MapLibreMapController? _mapController; MapLibreMapController? _mapController;
String? _getLocationName(ExifInfo? exifInfo) { String? _getLocationName(ExifInfo? exifInfo) {
@@ -42,7 +42,6 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) { void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
final currentExif = current.valueOrNull; final currentExif = current.valueOrNull;
if (currentExif != null && currentExif.hasCoordinates) { if (currentExif != null && currentExif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!))); _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
} }

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.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/presentation/widgets/images/remote_image_provider.dart';
@@ -15,14 +15,14 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart'; import 'package:immich_mobile/utils/people.utils.dart';
class SheetPeopleDetails extends ConsumerStatefulWidget { class PeopleDetails extends ConsumerStatefulWidget {
const SheetPeopleDetails({super.key}); const PeopleDetails({super.key});
@override @override
ConsumerState createState() => _SheetPeopleDetailsState(); ConsumerState createState() => _PeopleDetailsState();
} }
class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> { class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier); final asset = ref.watch(currentAssetNotifier);
@@ -65,7 +65,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
children: [ children: [
for (final person in people) for (final person in people)
_PeopleAvatar( _Avatar(
person: person, person: person,
assetFileCreatedAt: asset.createdAt, assetFileCreatedAt: asset.createdAt,
onTap: () { onTap: () {
@@ -97,14 +97,14 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
} }
} }
class _PeopleAvatar extends StatelessWidget { class _Avatar extends StatelessWidget {
final DriftPerson person; final DriftPerson person;
final DateTime assetFileCreatedAt; final DateTime assetFileCreatedAt;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onNameTap; final VoidCallback? onNameTap;
final double imageSize = 96; final double imageSize = 96;
const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap}); const _Avatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
class RatingDetails extends ConsumerWidget {
const RatingDetails({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
if (!isRatingEnabled) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
return Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'rating'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,
filledColor: context.themeData.colorScheme.primary,
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
itemSize: 40,
onRatingUpdate: (rating) async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
},
onClearRating: () async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
},
),
],
),
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.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/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
const _kSeparator = '';
class TechnicalDetails extends ConsumerWidget {
const TechnicalDetails({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) return const SizedBox.shrink();
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
return Column(
children: [
SheetTile(
title: 'details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
_buildFileInfoTile(context, ref, asset, exifInfo),
if (cameraTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
if (lensTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: lensTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
],
);
}
Widget _buildFileInfoTile(BuildContext context, WidgetRef ref, BaseAsset asset, ExifInfo? exifInfo) {
final icon = Icon(
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
size: 24,
color: context.textTheme.labelLarge?.color,
);
final subtitle = _getFileInfo(asset, exifInfo);
final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary);
if (asset is LocalAsset) {
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
return FutureBuilder<String?>(
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {
return SheetTile(
title: snapshot.data ?? asset.name,
titleStyle: context.textTheme.labelLarge,
leading: icon,
subtitle: subtitle,
subtitleStyle: subtitleStyle,
);
},
);
}
return SheetTile(
title: asset.name,
titleStyle: context.textTheme.labelLarge,
leading: icon,
subtitle: subtitle,
subtitleStyle: subtitleStyle,
);
}
static String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
final height = asset.height;
final width = asset.width;
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null;
final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
return switch ((fileSize, resolution)) {
(null, null) => '',
(String fileSize, null) => fileSize,
(null, String resolution) => resolution,
(String fileSize, String resolution) => '$fileSize$_kSeparator$resolution',
};
}
static String? _getCameraInfoTitle(ExifInfo? exifInfo) {
if (exifInfo == null) return null;
return switch ((exifInfo.make, exifInfo.model)) {
(null, null) => null,
(String make, null) => make,
(null, String model) => model,
(String make, String model) => '$make $model',
};
}
static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
if (exifInfo == null) return null;
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
}
static String? _getLensInfoSubtitle(ExifInfo? exifInfo) {
if (exifInfo == null) return null;
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
}
}

View File

@@ -0,0 +1,454 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/gestures.dart' show Drag, kTouchSlop;
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
enum _DragIntent { none, scroll, dismiss }
class AssetPage extends ConsumerStatefulWidget {
final int index;
final int heroOffset;
const AssetPage({super.key, required this.index, required this.heroOffset});
@override
ConsumerState createState() => _AssetPageState();
}
class _AssetPageState extends ConsumerState<AssetPage> {
PhotoViewControllerBase? _viewController;
StreamSubscription? _scaleBoundarySub;
StreamSubscription? _eventSubscription;
AssetViewerStateNotifier get _viewer => ref.read(assetViewerProvider.notifier);
late PhotoViewControllerValue _initialPhotoViewState;
bool _blockGestures = false;
bool _showingDetails = false;
bool _isZoomed = false;
final _scrollController = ScrollController();
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
double _snapOffset = 0.0;
double _lastScrollOffset = 0.0;
DragStartDetails? _dragStart;
_DragIntent _dragIntent = _DragIntent.none;
Drag? _drag;
bool _dragInProgress = false;
bool _shouldPopOnDrag = false;
@override
void initState() {
super.initState();
_proxyScrollController.addListener(_onScroll);
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_proxyScrollController.hasClients) return;
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
if (_showingDetails && _snapOffset > 0) {
_proxyScrollController.jumpTo(_snapOffset);
}
});
}
@override
void dispose() {
_proxyScrollController.dispose();
_scaleBoundarySub?.cancel();
_eventSubscription?.cancel();
super.dispose();
}
void _onEvent(Event event) {
switch (event) {
case ViewerShowDetailsEvent():
_showDetails();
default:
}
}
void _showDetails() {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
_lastScrollOffset = _proxyScrollController.offset;
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
}
bool _willClose(double scrollVelocity) {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false;
final position = _proxyScrollController.position;
return _proxyScrollController.position.pixels < _snapOffset &&
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
}
void _onScroll() {
final offset = _proxyScrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance && offset > _lastScrollOffset) {
_viewer.setShowingDetails(true);
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
_viewer.setShowingDetails(false);
}
_lastScrollOffset = offset;
}
void _beginDrag(DragStartDetails details) {
_dragStart = details;
_shouldPopOnDrag = false;
_lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0;
if (_viewController != null) {
_initialPhotoViewState = _viewController!.value;
}
if (_showingDetails) {
_dragIntent = _DragIntent.scroll;
_startProxyDrag();
}
}
void _startProxyDrag() {
if (_proxyScrollController.hasClients && _dragStart != null) {
_drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null);
}
}
void _updateDrag(DragUpdateDetails details) {
if (_blockGestures) return;
_dragInProgress = true;
if (_dragIntent == _DragIntent.none) {
_dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) {
< -kTouchSlop => _DragIntent.scroll,
> kTouchSlop => _DragIntent.dismiss,
_ => _DragIntent.none,
};
}
switch (_dragIntent) {
case _DragIntent.none:
case _DragIntent.scroll:
if (_drag == null) _startProxyDrag();
_drag?.update(details);
case _DragIntent.dismiss:
_handleDragDown(context, details.localPosition - _dragStart!.localPosition);
}
}
void _endDrag(DragEndDetails details) {
_dragInProgress = false;
if (_blockGestures) {
_blockGestures = false;
return;
}
final intent = _dragIntent;
_dragIntent = _DragIntent.none;
_dragStart = null;
switch (intent) {
case _DragIntent.none:
case _DragIntent.scroll:
final scrollVelocity = -(details.primaryVelocity ?? 0.0);
if (_willClose(scrollVelocity)) {
_viewer.setShowingDetails(false);
}
_drag?.end(details);
_drag = null;
case _DragIntent.dismiss:
if (_shouldPopOnDrag) {
context.maybePop();
return;
}
_viewController?.animateMultiple(
position: _initialPhotoViewState.position,
scale: _viewController?.initialScale ?? _initialPhotoViewState.scale,
rotation: _initialPhotoViewState.rotation,
);
_viewer.setOpacity(1.0);
}
}
void _onDragStart(
BuildContext context,
DragStartDetails details,
PhotoViewControllerBase controller,
PhotoViewScaleStateController scaleStateController,
) {
_viewController = controller;
if (!_showingDetails && _isZoomed) {
_blockGestures = true;
return;
}
_beginDrag(details);
}
void _onDragUpdate(BuildContext context, DragUpdateDetails details, PhotoViewControllerValue _) =>
_updateDrag(details);
void _onDragEnd(BuildContext context, DragEndDetails details, PhotoViewControllerValue _) => _endDrag(details);
void _onDragCancel() => _endDrag(DragEndDetails(primaryVelocity: 0.0));
void _handleDragDown(BuildContext context, Offset delta) {
const dragRatio = 0.2;
const popThreshold = 75.0;
_shouldPopOnDrag = delta.dy > popThreshold;
final distance = delta.dy.abs();
final maxScaleDistance = context.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale;
final updatedScale = initialScale != null ? initialScale * (1.0 - scaleReduction) : null;
final opacity = 1.0 - (scaleReduction / dragRatio);
_viewController?.updateMultiple(position: _initialPhotoViewState.position + delta, scale: updatedScale);
_viewer.setOpacity(opacity);
}
void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) {
if (!_showingDetails && !_dragInProgress) _viewer.toggleControls();
}
void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) =>
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
_isZoomed = switch (scaleState) {
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
_ => false,
};
_viewer.setZoomed(_isZoomed);
if (scaleState != PhotoViewScaleState.initial) {
if (!_dragInProgress) _viewer.setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
if (!_showingDetails) _viewer.setControls(true);
}
void _listenForScaleBoundaries(PhotoViewControllerBase? controller) {
_scaleBoundarySub?.cancel();
_scaleBoundarySub = null;
if (controller == null || controller.scaleBoundaries != null) return;
_scaleBoundarySub = controller.outputStateStream.listen((_) {
if (controller.scaleBoundaries != null) {
_scaleBoundarySub?.cancel();
_scaleBoundarySub = null;
if (mounted) setState(() {});
}
});
}
double _getImageHeight(double maxWidth, double maxHeight, BaseAsset? asset) {
final sb = _viewController?.scaleBoundaries;
if (sb != null) return sb.childSize.height * sb.initialScale;
if (asset == null || asset.width == null || asset.height == null) return maxHeight;
final r = asset.width! / asset.height!;
return math.min(maxWidth / r, maxHeight);
}
void _onPageBuild(PhotoViewControllerBase controller) {
_viewController = controller;
_listenForScaleBoundaries(controller);
}
Widget _buildPhotoView(
BaseAsset displayAsset,
BaseAsset asset, {
required bool isCurrentPage,
required bool showingDetails,
required bool isPlayingMotionVideo,
required BoxDecoration backgroundDecoration,
}) {
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
if (displayAsset.isImage && !isPlayingMotionVideo) {
final size = context.sizeData;
return PhotoView(
key: ValueKey(displayAsset.heroTag),
index: widget.index,
imageProvider: getFullImageProvider(displayAsset, size: size),
heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
backgroundDecoration: backgroundDecoration,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
tightMode: true,
enablePanAlways: true,
disableScaleGestures: showingDetails,
scaleStateChangedCallback: _onScaleStateChanged,
onPageBuild: _onPageBuild,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => SizedBox(
width: size.width,
height: size.height,
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
),
);
}
return PhotoView.customChild(
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onDragCancel: _onDragCancel,
onTapUp: _onTapUp,
heroAttributes: heroAttributes,
filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center,
disableScaleGestures: true,
scaleStateChangedCallback: _onScaleStateChanged,
onPageBuild: _onPageBuild,
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: SizedBox(
width: context.width,
height: context.height,
child: NativeVideoViewer(
key: ValueKey(displayAsset.heroTag),
asset: displayAsset,
image: Image(
key: ValueKey(displayAsset),
image: getFullImageProvider(displayAsset, size: context.sizeData),
fit: BoxFit.contain,
height: context.height,
width: context.width,
alignment: Alignment.center,
),
),
),
);
}
@override
Widget build(BuildContext context) {
final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
if (asset == null) {
return const Center(child: ImmichLoadingIndicator());
}
BaseAsset displayAsset = asset;
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(stackIndex);
}
final viewportWidth = MediaQuery.widthOf(context);
final viewportHeight = MediaQuery.heightOf(context);
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
final margin = (viewportHeight - imageHeight) / 2;
final overflowBoxHeight = margin + imageHeight - (kMinInteractiveDimension / 2);
_snapOffset = (margin + imageHeight) - (viewportHeight / 4);
if (_proxyScrollController.hasClients) {
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
}
return ProviderScope(
overrides: [
currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)),
currentAssetExifProvider.overrideWith((ref) {
final a = ref.watch(currentAssetNotifier);
if (a == null) return Future.value(null);
return ref.watch(assetServiceProvider).getExif(a);
}),
],
child: Stack(
children: [
Offstage(
child: SingleChildScrollView(
controller: _proxyScrollController,
physics: const SnapScrollPhysics(),
child: const SizedBox.shrink(),
),
),
SingleChildScrollView(
controller: _scrollController,
physics: const NeverScrollableScrollPhysics(),
child: Stack(
children: [
SizedBox(
width: viewportWidth,
height: viewportHeight,
child: _buildPhotoView(
displayAsset,
asset,
isCurrentPage: currentHeroTag == asset.heroTag,
showingDetails: _showingDetails,
isPlayingMotionVideo: isPlayingMotionVideo,
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
),
),
IgnorePointer(
ignoring: !_showingDetails,
child: Column(
children: [
SizedBox(height: overflowBoxHeight),
GestureDetector(
onVerticalDragStart: _beginDrag,
onVerticalDragUpdate: _updateDrag,
onVerticalDragEnd: _endDrag,
onVerticalDragCancel: _onDragCancel,
child: AnimatedOpacity(
opacity: _showingDetails ? 1.0 : 0.0,
duration: Durations.short2,
child: AssetDetails(minHeight: _snapOffset + viewportHeight - overflowBoxHeight),
),
),
],
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
class AssetPreloader {
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
final TimelineService timelineService;
final bool Function() mounted;
Timer? _timer;
ImageStream? _prevStream;
ImageStream? _nextStream;
AssetPreloader({required this.timelineService, required this.mounted});
void preload(int index, Size size) {
unawaited(timelineService.preloadAssets(index));
_timer?.cancel();
_timer = Timer(Durations.medium4, () async {
if (!mounted()) return;
final (prev, next) = await (
timelineService.getAssetAsync(index - 1),
timelineService.getAssetAsync(index + 1),
).wait;
if (!mounted()) return;
_prevStream?.removeListener(_dummyListener);
_nextStream?.removeListener(_dummyListener);
_prevStream = prev != null ? _resolveImage(prev, size) : null;
_nextStream = next != null ? _resolveImage(next, size) : null;
});
}
ImageStream _resolveImage(BaseAsset asset, Size size) {
return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener);
}
void dispose() {
_timer?.cancel();
_prevStream?.removeListener(_dummyListener);
_nextStream?.removeListener(_dummyListener);
}
}

View File

@@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
class AssetStackRow extends ConsumerWidget { class AssetStackRow extends ConsumerWidget {
const AssetStackRow({super.key}); const AssetStackRow({super.key});
@@ -21,17 +21,11 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0; if (showingDetails) {
return const SizedBox.shrink();
return IgnorePointer( }
ignoring: opacity < 255, return _StackList(stack: stackChildren);
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: _StackList(stack: stackChildren),
),
);
} }
} }

View File

@@ -14,27 +14,19 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
@RoutePage() @RoutePage()
class AssetViewerPage extends StatelessWidget { class AssetViewerPage extends StatelessWidget {
@@ -79,10 +71,6 @@ class AssetViewer extends ConsumerStatefulWidget {
_setAsset(ref, asset); _setAsset(ref, asset);
} }
void changeAsset(WidgetRef ref, BaseAsset asset) {
_setAsset(ref, asset);
}
static void _setAsset(WidgetRef ref, BaseAsset asset) { static void _setAsset(WidgetRef ref, BaseAsset asset) {
// Always holds the current asset from the timeline // Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset); ref.read(assetViewerProvider.notifier).setAsset(asset);
@@ -94,45 +82,20 @@ class AssetViewer extends ConsumerStatefulWidget {
ref.read(videoPlayerControlsProvider.notifier).pause(); ref.read(videoPlayerControlsProvider.notifier).pause();
} }
// Hide controls by default for videos // Hide controls by default for videos
if (asset.isVideo) { if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
ref.read(assetViewerProvider.notifier).setControls(false);
}
} }
} }
const double _kBottomSheetMinimumExtent = 0.4;
const double _kBottomSheetSnapExtent = 0.67;
class _AssetViewerState extends ConsumerState<AssetViewer> { class _AssetViewerState extends ConsumerState<AssetViewer> {
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
late PageController pageController; late PageController pageController;
late DraggableScrollableController bottomSheetController;
PersistentBottomSheetController? sheetCloseController; StreamSubscription? _reloadSubscription;
// PhotoViewGallery takes care of disposing it's controllers
PhotoViewControllerBase? viewController;
StreamSubscription? reloadSubscription;
late final int heroOffset; late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState; bool _assetReloadRequested = false;
bool? hasDraggedDown; int _totalAssets = 0;
bool isSnapping = false;
bool blockGestures = false;
bool dragInProgress = false;
bool shouldPopOnDrag = false;
bool assetReloadRequested = false;
double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero;
int totalAssets = 0;
int stackIndex = 0;
BuildContext? scaffoldContext;
Map<String, GlobalKey> videoPlayerKeys = {};
// Delayed operations that should be cancelled on disposal
final List<Timer> _delayedOperations = [];
ImageStream? _prevPreCacheStream;
ImageStream? _nextPreCacheStream;
late final AssetPreloader _preloader;
KeepAliveLink? _stackChildrenKeepAlive; KeepAliveLink? _stackChildrenKeepAlive;
@override @override
@@ -140,94 +103,38 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.initState(); super.initState();
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
pageController = PageController(initialPage: widget.initialIndex); pageController = PageController(initialPage: widget.initialIndex);
totalAssets = ref.read(timelineServiceProvider).totalAssets; final timelineService = ref.read(timelineServiceProvider);
bottomSheetController = DraggableScrollableController(); _totalAssets = timelineService.totalAssets;
_preloader = AssetPreloader(timelineService: timelineService, mounted: () => mounted);
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
reloadSubscription = EventStream.shared.listen(_onEvent); _reloadSubscription = EventStream.shared.listen(_onEvent);
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final asset = ref.read(currentAssetNotifier); final asset = ref.read(currentAssetNotifier);
if (asset != null) { if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
if (ref.read(assetViewerProvider).showingControls) {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
} else {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
}
} }
@override @override
void dispose() { void dispose() {
pageController.dispose(); pageController.dispose();
bottomSheetController.dispose(); _preloader.dispose();
_cancelTimers(); _reloadSubscription?.cancel();
reloadSubscription?.cancel();
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive?.close();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose(); super.dispose();
} }
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet)); void _onAssetInit(Duration timeStamp) {
_preloader.preload(widget.initialIndex, context.sizeData);
Color get backgroundColor {
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
return Colors.black.withAlpha(opacity);
}
void _cancelTimers() {
for (final timer in _delayedOperations) {
timer.cancel();
}
_delayedOperations.clear();
}
double _getVerticalOffsetForBottomSheet(double extent) =>
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
ImageStream _precacheImage(BaseAsset asset) {
final provider = getFullImageProvider(asset, size: context.sizeData);
return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener);
}
void _precacheAssets(int index) {
final timelineService = ref.read(timelineServiceProvider);
unawaited(timelineService.preCacheAssets(index));
_cancelTimers();
// This will trigger the pre-caching of adjacent assets ensuring
// that they are ready when the user navigates to them.
final timer = Timer(Durations.medium4, () async {
// Check if widget is still mounted before proceeding
if (!mounted) return;
final (prevAsset, nextAsset) = await (
timelineService.getAssetAsync(index - 1),
timelineService.getAssetAsync(index + 1),
).wait;
if (!mounted) return;
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
_prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null;
_nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null;
});
_delayedOperations.add(timer);
}
void _onAssetInit(Duration _) {
_precacheAssets(widget.initialIndex);
_handleCasting(); _handleCasting();
} }
void _onAssetChanged(int index) async { void _onAssetChanged(int index) async {
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index); final asset = await timelineService.getAssetAsync(index);
if (asset == null) { if (asset == null) return;
return;
}
widget.changeAsset(ref, asset); AssetViewer._setAsset(ref, asset);
_precacheAssets(index); _preloader.preload(index, context.sizeData);
_handleCasting(); _handleCasting();
_stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive?.close();
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
@@ -238,17 +145,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final asset = ref.read(currentAssetNotifier); final asset = ref.read(currentAssetNotifier);
if (asset == null) return; if (asset == null) return;
// hide any casting snackbars if they exist
context.scaffoldMessenger.hideCurrentSnackBar();
// send image to casting if the server has it
if (asset is RemoteAsset) { if (asset is RemoteAsset) {
context.scaffoldMessenger.hideCurrentSnackBar();
ref.read(castProvider.notifier).loadMedia(asset, false); ref.read(castProvider.notifier).loadMedia(asset, false);
} else { return;
// casting cannot show local assets }
context.scaffoldMessenger.clearSnackBars();
if (ref.read(castProvider).isCasting) { context.scaffoldMessenger.clearSnackBars();
ref.read(castProvider.notifier).stop(); ref.read(castProvider.notifier).stop();
context.scaffoldMessenger.showSnackBar( context.scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
@@ -260,201 +163,22 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
), ),
); );
} }
}
}
void _onPageBuild(PhotoViewControllerBase controller) {
viewController ??= controller;
if (showingBottomSheet && bottomSheetController.isAttached) {
final verticalOffset =
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing
controller.scale = (controller.scale ?? 1.0) + 0.01;
}
}
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index);
viewController = controller;
}
void _onDragStart(
_,
DragStartDetails details,
PhotoViewControllerBase controller,
PhotoViewScaleStateController scaleStateController,
) {
viewController = controller;
dragDownPosition = details.localPosition;
initialPhotoViewState = controller.value;
final isZoomed =
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
scaleStateController.scaleState == PhotoViewScaleState.covering;
if (!showingBottomSheet && isZoomed) {
blockGestures = true;
}
}
void _onDragEnd(BuildContext ctx, _, __) {
dragInProgress = false;
if (shouldPopOnDrag) {
// Dismiss immediately without state updates to avoid rebuilds
ctx.maybePop();
return;
}
// Do not reset the state if the bottom sheet is showing
if (showingBottomSheet) {
_snapBottomSheet();
return;
}
// If the gestures are blocked, do not reset the state
if (blockGestures) {
blockGestures = false;
return;
}
shouldPopOnDrag = false;
hasDraggedDown = null;
viewController?.animateMultiple(
position: initialPhotoViewState.position,
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
rotation: initialPhotoViewState.rotation,
);
ref.read(assetViewerProvider.notifier).setOpacity(255);
}
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
if (blockGestures) {
return;
}
dragInProgress = true;
final delta = details.localPosition - dragDownPosition;
hasDraggedDown ??= delta.dy > 0;
if (!hasDraggedDown! || showingBottomSheet) {
_handleDragUp(ctx, delta);
return;
}
_handleDragDown(ctx, delta);
}
void _handleDragUp(BuildContext ctx, Offset delta) {
const double openThreshold = 50;
final position = initialPhotoViewState.position + Offset(0, delta.dy);
final distanceToOrigin = position.distance;
viewController?.updateMultiple(position: position);
// Moves the bottom sheet when the asset is being dragged up
if (showingBottomSheet && bottomSheetController.isAttached) {
final centre = (ctx.height * _kBottomSheetMinimumExtent);
bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height);
}
if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) {
_openBottomSheet(ctx);
}
}
void _handleDragDown(BuildContext ctx, Offset delta) {
const double dragRatio = 0.2;
const double popThreshold = 75;
final distance = delta.distance;
shouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
final maxScaleDistance = ctx.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
double? updatedScale;
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
if (initialScale != null) {
updatedScale = initialScale * (1.0 - scaleReduction);
}
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
viewController?.updateMultiple(position: initialPhotoViewState.position + delta, scale: updatedScale);
ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity);
}
void _onTapDown(_, __, ___) {
if (!showingBottomSheet) {
ref.read(assetViewerProvider.notifier).toggleControls();
}
}
bool _onNotification(Notification delta) {
if (delta is DraggableScrollableNotification) {
_handleDraggableNotification(delta);
}
// Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after
// the isSnapping guard is to prevent the notification from recursively handling the
// notification, eventually resulting in a heap overflow
if (!isSnapping && delta is ScrollEndNotification) {
_snapBottomSheet();
}
return false;
}
void _handleDraggableNotification(DraggableScrollableNotification delta) {
final currentExtent = delta.extent;
final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down
if (isDraggingDown && delta.extent < 0.67) {
if (dragInProgress) {
blockGestures = true;
}
// Jump to a lower position before starting close animation to prevent glitch
if (bottomSheetController.isAttached) {
bottomSheetController.jumpTo(0.67);
}
sheetCloseController?.close();
}
// If the asset is being dragged down, we do not want to update the asset position again
if (dragInProgress) {
return;
}
final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent);
// Moves the asset when the bottom sheet is being dragged
if (verticalOffset > 0) {
viewController?.position = Offset(0, -verticalOffset);
}
}
void _onEvent(Event event) { void _onEvent(Event event) {
if (event is TimelineReloadEvent) { switch (event) {
case TimelineReloadEvent():
_onTimelineReloadEvent(); _onTimelineReloadEvent();
return; case ViewerReloadAssetEvent():
} _assetReloadRequested = true;
default:
if (event is ViewerReloadAssetEvent) {
assetReloadRequested = true;
return;
}
if (event is ViewerOpenBottomSheetEvent) {
final extent = _kBottomSheetMinimumExtent + 0.3;
_openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode);
final offset = _getVerticalOffsetForBottomSheet(extent);
viewController?.position = Offset(0, -offset);
return;
} }
} }
void _onTimelineReloadEvent() { void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
totalAssets = timelineService.totalAssets; _totalAssets = timelineService.totalAssets;
if (totalAssets == 0) { if (_totalAssets == 0) {
context.maybePop(); context.maybePop();
return; return;
} }
@@ -469,229 +193,58 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
} }
if (index >= totalAssets) { if (index >= _totalAssets) {
index = totalAssets - 1; index = _totalAssets - 1;
pageController.jumpToPage(index); pageController.jumpToPage(index);
} }
if (assetReloadRequested) { if (_assetReloadRequested) {
assetReloadRequested = false; _assetReloadRequested = false;
_onAssetReloadEvent(index); _onAssetReloadEvent(index);
} }
} }
void _onAssetReloadEvent(int index) async { void _onAssetReloadEvent(int index) async {
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) { final newAsset = await timelineService.getAssetAsync(index);
return; if (newAsset == null) return;
}
final currentAsset = ref.read(currentAssetNotifier); final currentAsset = ref.read(currentAssetNotifier);
// Do not reload / close the bottom sheet if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) {
return;
}
setState(() { // Do not reload if the asset has not changed
_onAssetChanged(pageController.page!.round()); if (newAsset.heroTag == currentAsset?.heroTag) return;
sheetCloseController?.close();
});
}
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) { _onAssetChanged(index);
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2),
constraints: const BoxConstraints(maxWidth: double.infinity),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))),
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
builder: (_) {
return NotificationListener<Notification>(
onNotification: _onNotification,
child: activitiesMode
? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent)
: AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent),
);
},
);
sheetCloseController?.closed.then((_) => _handleSheetClose());
}
void _handleSheetClose() {
viewController?.animateMultiple(position: Offset.zero);
viewController?.updateMultiple(scale: viewController?.initialScale);
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
sheetCloseController = null;
shouldPopOnDrag = false;
hasDraggedDown = null;
}
void _snapBottomSheet() {
if (!bottomSheetController.isAttached ||
bottomSheetController.size > _kBottomSheetSnapExtent ||
bottomSheetController.size < 0.4) {
return;
}
isSnapping = true;
bottomSheetController.animateTo(_kBottomSheetSnapExtent, duration: Durations.short3, curve: Curves.easeOut);
}
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
return const Center(child: ImmichLoadingIndicator());
}
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
if (scaleState != PhotoViewScaleState.initial) {
if (!dragInProgress) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
if (!showingBottomSheet) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
}
void _onLongPress(_, __, ___) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
}
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx;
final timelineService = ref.read(timelineServiceProvider);
final asset = timelineService.getAssetSafe(index);
// If asset is not available in buffer, return a placeholder
if (asset == null) {
return PhotoViewGalleryPageOptions.customChild(
heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'),
child: Container(
width: ctx.width,
height: ctx.height,
color: backgroundColor,
child: const Center(child: CircularProgressIndicator()),
),
);
}
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
}
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
if (displayAsset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, displayAsset);
}
return _videoBuilder(ctx, displayAsset);
}
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
final size = ctx.sizeData;
return PhotoViewGalleryPageOptions(
key: ValueKey(asset.heroTag),
imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
tightMode: true,
disableScaleGestures: showingBottomSheet,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onTapDown: _onTapDown,
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
errorBuilder: (_, __, ___) => Container(
width: size.width,
height: size.height,
color: backgroundColor,
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
),
);
}
GlobalKey _getVideoPlayerKey(String id) {
videoPlayerKeys.putIfAbsent(id, () => GlobalKey());
return videoPlayerKeys[id]!;
}
PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onTapDown: _onTapDown,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center,
disableScaleGestures: true,
child: SizedBox(
width: ctx.width,
height: ctx.height,
child: NativeVideoViewer(
key: _getVideoPlayerKey(asset.heroTag),
asset: asset,
image: Image(
key: ValueKey(asset),
image: getFullImageProvider(asset, size: ctx.sizeData),
fit: BoxFit.contain,
height: ctx.height,
width: ctx.width,
alignment: Alignment.center,
),
),
),
);
}
void _onPop<T>(bool didPop, T? result) {
ref.read(currentAssetNotifier.notifier).dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Rebuild the widget when the asset viewer state changes
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
ref.watch(isPlayingMotionVideoProvider);
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isZoomed = ref.watch(assetViewerProvider.select((s) => s.isZoomed));
final backgroundColor = showingDetails
? context.colorScheme.surface
: Colors.black.withValues(alpha: ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)));
// Listen for casting changes and send initial asset to the cast provider // Listen for casting changes and send initial asset to the cast provider
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async { ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) {
if (!isCasting) return; if (!isCasting) return;
final asset = ref.read(currentAssetNotifier);
if (asset == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_handleCasting(); _handleCasting();
}); });
}); });
// Listen for control visibility changes and change system UI mode accordingly ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async { final (controls, details) = state;
if (showingControls) { final mode = !controls || (CurrentPlatform.isIOS && details)
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); ? SystemUiMode.immersiveSticky
} else { : SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); unawaited(SystemChrome.setEnabledSystemUIMode(mode));
}
}); });
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
// Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable
return PopScope( return PopScope(
onPopInvokedWithResult: _onPop, onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
child: Scaffold( child: Scaffold(
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
appBar: const ViewerTopAppBar(), appBar: const ViewerTopAppBar(),
@@ -705,33 +258,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
child: const DownloadStatusFloatingButton(), child: const DownloadStatusFloatingButton(),
), ),
), ),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack( body: Stack(
children: [ children: [
PhotoViewGallery.builder( PhotoViewGestureDetectorScope(
gaplessPlayback: true, axis: Axis.horizontal,
loadingBuilder: _placeholderBuilder, child: PageView.builder(
pageController: pageController, controller: pageController,
scrollPhysics: CurrentPlatform.isIOS physics: isZoomed
? const FastScrollPhysics() // Use bouncing physics for iOS ? const NeverScrollableScrollPhysics()
: const FastClampingScrollPhysics(), // Use heavy physics for Android : CurrentPlatform.isIOS
itemCount: totalAssets, ? const FastScrollPhysics()
onPageChanged: _onPageChanged, : const FastClampingScrollPhysics(),
onPageBuild: _onPageBuild, itemCount: _totalAssets,
scaleStateChangedCallback: _onScaleStateChanged, onPageChanged: (index) => _onAssetChanged(index),
builder: _assetBuilder, itemBuilder: (context, index) => AssetPage(index: index, heroOffset: heroOffset),
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
), ),
if (!showingBottomSheet) ),
const Positioned( if (!CurrentPlatform.isIOS)
bottom: 0, IgnorePointer(
left: 0, child: AnimatedContainer(
right: 0, duration: Durations.short2,
child: Column( color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
mainAxisSize: MainAxisSize.min, height: context.padding.top,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [AssetStackRow(), ViewerBottomBar()],
), ),
), ),
], ],

View File

@@ -3,31 +3,35 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
class AssetViewerState { class AssetViewerState {
final int backgroundOpacity; final double backgroundOpacity;
final bool showingBottomSheet; final bool showingDetails;
final bool showingControls; final bool showingControls;
final bool isZoomed;
final BaseAsset? currentAsset; final BaseAsset? currentAsset;
final int stackIndex; final int stackIndex;
const AssetViewerState({ const AssetViewerState({
this.backgroundOpacity = 255, this.backgroundOpacity = 1.0,
this.showingBottomSheet = false, this.showingDetails = false,
this.showingControls = true, this.showingControls = true,
this.isZoomed = false,
this.currentAsset, this.currentAsset,
this.stackIndex = 0, this.stackIndex = 0,
}); });
AssetViewerState copyWith({ AssetViewerState copyWith({
int? backgroundOpacity, double? backgroundOpacity,
bool? showingBottomSheet, bool? showingDetails,
bool? showingControls, bool? showingControls,
bool? isZoomed,
BaseAsset? currentAsset, BaseAsset? currentAsset,
int? stackIndex, int? stackIndex,
}) { }) {
return AssetViewerState( return AssetViewerState(
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, showingDetails: showingDetails ?? this.showingDetails,
showingControls: showingControls ?? this.showingControls, showingControls: showingControls ?? this.showingControls,
isZoomed: isZoomed ?? this.isZoomed,
currentAsset: currentAsset ?? this.currentAsset, currentAsset: currentAsset ?? this.currentAsset,
stackIndex: stackIndex ?? this.stackIndex, stackIndex: stackIndex ?? this.stackIndex,
); );
@@ -35,7 +39,7 @@ class AssetViewerState {
@override @override
String toString() { String toString() {
return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)'; return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)';
} }
@override @override
@@ -44,8 +48,9 @@ class AssetViewerState {
if (other.runtimeType != runtimeType) return false; if (other.runtimeType != runtimeType) return false;
return other is AssetViewerState && return other is AssetViewerState &&
other.backgroundOpacity == backgroundOpacity && other.backgroundOpacity == backgroundOpacity &&
other.showingBottomSheet == showingBottomSheet && other.showingDetails == showingDetails &&
other.showingControls == showingControls && other.showingControls == showingControls &&
other.isZoomed == isZoomed &&
other.currentAsset == currentAsset && other.currentAsset == currentAsset &&
other.stackIndex == stackIndex; other.stackIndex == stackIndex;
} }
@@ -53,8 +58,9 @@ class AssetViewerState {
@override @override
int get hashCode => int get hashCode =>
backgroundOpacity.hashCode ^ backgroundOpacity.hashCode ^
showingBottomSheet.hashCode ^ showingDetails.hashCode ^
showingControls.hashCode ^ showingControls.hashCode ^
isZoomed.hashCode ^
currentAsset.hashCode ^ currentAsset.hashCode ^
stackIndex.hashCode; stackIndex.hashCode;
} }
@@ -76,18 +82,18 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
state = state.copyWith(currentAsset: asset, stackIndex: 0); state = state.copyWith(currentAsset: asset, stackIndex: 0);
} }
void setOpacity(int opacity) { void setOpacity(double opacity) {
if (opacity == state.backgroundOpacity) { if (opacity == state.backgroundOpacity) {
return; return;
} }
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls); state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls);
} }
void setBottomSheet(bool showing) { void setShowingDetails(bool showing) {
if (showing == state.showingBottomSheet) { if (showing == state.showingDetails) {
return; return;
} }
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls); state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
if (showing) { if (showing) {
ref.read(videoPlayerControlsProvider.notifier).pause(); ref.read(videoPlayerControlsProvider.notifier).pause();
} }
@@ -104,6 +110,13 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
state = state.copyWith(showingControls: !state.showingControls); state = state.copyWith(showingControls: !state.showingControls);
} }
void setZoomed(bool isZoomed) {
if (isZoomed == state.isZoomed) {
return;
}
state = state.copyWith(isZoomed: isZoomed);
}
void setStackIndex(int index) { void setStackIndex(int index) {
if (index == state.stackIndex) { if (index == state.stackIndex) {
return; return;

View File

@@ -10,7 +10,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.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/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -29,15 +29,9 @@ class ViewerBottomBar extends ConsumerWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
if (!showControls) {
opacity = 0;
}
final originalTheme = context.themeData; final originalTheme = context.themeData;
final actions = <Widget>[ final actions = <Widget>[
@@ -56,14 +50,9 @@ class ViewerBottomBar extends ConsumerWidget {
], ],
]; ];
return IgnorePointer( return AnimatedSwitcher(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: AnimatedSwitcher(
duration: Durations.short4, duration: Durations.short4,
child: isSheetOpen child: showingDetails
? const SizedBox.shrink() ? const SizedBox.shrink()
: Theme( : Theme(
data: context.themeData.copyWith( data: context.themeData.copyWith(
@@ -85,8 +74,6 @@ class ViewerBottomBar extends ConsumerWidget {
), ),
), ),
), ),
),
),
); );
} }
} }

View File

@@ -1,409 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.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/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = '';
class AssetDetailBottomSheet extends ConsumerWidget {
final DraggableScrollableController? controller;
final double initialChildSize;
const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
return BaseBottomSheet(
actions: [],
slivers: const [_AssetDetailBottomSheet()],
controller: controller,
initialChildSize: initialChildSize,
minChildSize: 0.1,
maxChildSize: 0.88,
expand: false,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
);
}
}
class _AssetDetailBottomSheet extends ConsumerWidget {
const _AssetDetailBottomSheet();
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
DateTime dateTime = asset.createdAt.toLocal();
Duration timeZoneOffset = dateTime.timeZoneOffset;
// Use EXIF timezone information if available (matching web app behavior)
if (exifInfo?.dateTimeOriginal != null) {
(dateTime, timeZoneOffset) = applyTimezoneOffset(
dateTime: exifInfo!.dateTimeOriginal!,
timeZone: exifInfo.timeZone,
);
}
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
return '$date$_kSeparator$time $timezone';
}
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
final height = asset.height;
final width = asset.width;
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null;
final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
return switch ((fileSize, resolution)) {
(null, null) => '',
(String fileSize, null) => fileSize,
(null, String resolution) => resolution,
(String fileSize, String resolution) => '$fileSize$_kSeparator$resolution',
};
}
String? _getCameraInfoTitle(ExifInfo? exifInfo) {
if (exifInfo == null) {
return null;
}
return switch ((exifInfo.make, exifInfo.model)) {
(null, null) => null,
(String make, null) => make,
(null, String model) => model,
(String make, String model) => '$make $model',
};
}
String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
if (exifInfo == null) {
return null;
}
final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
}
String? _getLensInfoSubtitle(ExifInfo? exifInfo) {
if (exifInfo == null) {
return null;
}
final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
}
Future<void> _editDateTime(BuildContext context, WidgetRef ref) async {
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
}
Widget _buildAppearsInList(WidgetRef ref, BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
if (!asset.hasRemote) {
return const SizedBox.shrink();
}
String? remoteAssetId;
if (asset is RemoteAsset) {
remoteAssetId = asset.id;
} else if (asset is LocalAsset) {
remoteAssetId = asset.remoteAssetId;
}
if (remoteAssetId == null) {
return const SizedBox.shrink();
}
final userId = ref.watch(currentUserProvider)?.id;
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId));
return assetAlbums.when(
data: (albums) {
if (albums.isEmpty) {
return const SizedBox.shrink();
}
albums.sortBy((a) => a.name);
return Column(
spacing: 12,
children: [
if (albums.isNotEmpty)
SheetTile(
title: 'appears_in'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
Padding(
padding: const EdgeInsets.only(left: 24),
child: Column(
spacing: 12,
children: albums.map((album) {
final isOwner = album.ownerId == userId;
return AlbumTile(
album: album,
isOwner: isOwner,
onAlbumSelected: (album) async {
ref.invalidate(assetViewerProvider);
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
},
);
}).toList(),
),
),
],
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
// Build file info tile based on asset type
Widget buildFileInfoTile() {
if (asset is LocalAsset) {
final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
return FutureBuilder<String?>(
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {
final displayName = snapshot.data ?? asset.name;
return SheetTile(
title: displayName,
titleStyle: context.textTheme.labelLarge,
leading: Icon(
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
size: 24,
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
},
);
} else {
// For remote assets, use the name directly
return SheetTile(
title: asset.name,
titleStyle: context.textTheme.labelLarge,
leading: Icon(
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
size: 24,
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
}
}
return SliverList.list(
children: [
// Asset Date and Time
SheetTile(
title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.labelLarge,
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
const SheetPeopleDetails(),
const SheetLocationDetails(),
// Details header
SheetTile(
title: 'details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
// File info
buildFileInfoTile(),
// Camera info
if (cameraTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Lens info
if (lensTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: lensTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Rating bar
if (isRatingEnabled) ...[
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'rating'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,
filledColor: context.themeData.colorScheme.primary,
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
itemSize: 40,
onRatingUpdate: (rating) async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
},
onClearRating: () async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
},
),
],
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off
const SizedBox(height: 60),
],
);
}
}
class _SheetAssetDescription extends ConsumerStatefulWidget {
final ExifInfo exif;
final bool isEditable;
const _SheetAssetDescription({required this.exif, this.isEditable = true});
@override
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
}
class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> {
late TextEditingController _controller;
final _descriptionFocus = FocusNode();
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.exif.description ?? '');
}
Future<void> saveDescription(String? previousDescription) async {
final newDescription = _controller.text.trim();
if (newDescription == previousDescription) {
_descriptionFocus.unfocus();
return;
}
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
if (!editAction.success) {
_controller.text = previousDescription ?? '';
ImmichToast.show(
context: context,
msg: 'exif_bottom_sheet_description_error'.t(context: context),
toastType: ToastType.error,
);
}
_descriptionFocus.unfocus();
}
@override
Widget build(BuildContext context) {
// Watch the current asset EXIF provider to get updates
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
// Update controller text when EXIF data changes
final currentDescription = currentExifInfo?.description ?? '';
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
context: context,
);
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
_controller.text = currentDescription;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: IgnorePointer(
ignoring: !widget.isEditable,
child: TextField(
controller: _controller,
keyboardType: TextInputType.multiline,
focusNode: _descriptionFocus,
maxLines: null, // makes it grow as text is added
decoration: InputDecoration(
hintText: hintText,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
),
),
);
}
}

View File

@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
@@ -205,7 +205,7 @@ class NativeVideoViewer extends HookConsumerWidget {
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) { if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
return; return;
} }

View File

@@ -5,7 +5,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
@@ -19,8 +19,8 @@ class VideoViewerControls extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo)); final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
if (showBottomSheet) { if (showingDetails) {
showControls = false; showControls = false;
} }
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
class ViewerBottomAppBar extends ConsumerWidget {
const ViewerBottomAppBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (!showControls) {
opacity = 0.0;
}
return IgnorePointer(
ignoring: opacity < 1.0,
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [AssetStackRow(), ViewerBottomBar()],
),
),
);
}
}

View File

@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';

View File

@@ -3,16 +3,15 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
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/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/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.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/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
@@ -35,8 +34,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isInLockedView = ref.watch(inLockedViewProvider); final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) { if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
@@ -44,7 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
} }
if (!showControls) { if (!showControls) {
opacity = 0; opacity = 0.0;
} }
final originalTheme = context.themeData; final originalTheme = context.themeData;
@@ -55,7 +54,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
IconButton( IconButton(
icon: const Icon(Icons.chat_outlined), icon: const Icon(Icons.chat_outlined),
onPressed: () { onPressed: () {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); context.router.push(
DriftActivitiesRoute(
album: album,
assetId: asset is RemoteAsset ? asset.id : null,
assetName: asset.name,
),
);
}, },
), ),
@@ -70,17 +75,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)]; final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
return IgnorePointer( return IgnorePointer(
ignoring: opacity < 255, ignoring: opacity < 1.0,
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: opacity / 255, opacity: opacity,
duration: Durations.short2, duration: Durations.short2,
child: AppBar( child: AppBar(
backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125), backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
leading: const _AppBarBackButton(), leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white), iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(), shape: const Border(),
actions: isShowingSheet || isReadonlyModeEnabled actions: showingDetails || isReadonlyModeEnabled
? null ? null
: isInLockedView : isInLockedView
? lockedViewActions ? lockedViewActions
@@ -99,9 +104,9 @@ class _AppBarBackButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black; final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white; final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
return Padding( return Padding(
padding: const EdgeInsets.only(left: 12.0), padding: const EdgeInsets.only(left: 12.0),
@@ -112,7 +117,7 @@ class _AppBarBackButton extends ConsumerWidget {
iconSize: 22, iconSize: 22,
iconColor: foregroundColor, iconColor: foregroundColor,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
elevation: isShowingSheet ? 4 : 0, elevation: showingDetails ? 4 : 0,
), ),
onPressed: context.maybePop, onPressed: context.maybePop,
child: const Icon(Icons.arrow_back_rounded), child: const Icon(Icons.arrow_back_rounded),

View File

@@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';

View File

@@ -31,6 +31,18 @@ class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
} }
} }
class ScopedAssetNotifier extends CurrentAssetNotifier {
final BaseAsset _asset;
ScopedAssetNotifier(this._asset);
@override
BaseAsset? build() {
setAsset(_asset);
return _asset;
}
}
final currentAssetExifProvider = FutureProvider.autoDispose((ref) { final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
final currentAsset = ref.watch(currentAssetNotifier); final currentAsset = ref.watch(currentAssetNotifier);
if (currentAsset == null) { if (currentAsset == null) {

View File

@@ -753,10 +753,17 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
DriftActivitiesRoute({ DriftActivitiesRoute({
Key? key, Key? key,
required RemoteAlbum album, required RemoteAlbum album,
String? assetId,
String? assetName,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
DriftActivitiesRoute.name, DriftActivitiesRoute.name,
args: DriftActivitiesRouteArgs(key: key, album: album), args: DriftActivitiesRouteArgs(
key: key,
album: album,
assetId: assetId,
assetName: assetName,
),
initialChildren: children, initialChildren: children,
); );
@@ -766,21 +773,35 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<DriftActivitiesRouteArgs>(); final args = data.argsAs<DriftActivitiesRouteArgs>();
return DriftActivitiesPage(key: args.key, album: args.album); return DriftActivitiesPage(
key: args.key,
album: args.album,
assetId: args.assetId,
assetName: args.assetName,
);
}, },
); );
} }
class DriftActivitiesRouteArgs { class DriftActivitiesRouteArgs {
const DriftActivitiesRouteArgs({this.key, required this.album}); const DriftActivitiesRouteArgs({
this.key,
required this.album,
this.assetId,
this.assetName,
});
final Key? key; final Key? key;
final RemoteAlbum album; final RemoteAlbum album;
final String? assetId;
final String? assetName;
@override @override
String toString() { String toString() {
return 'DriftActivitiesRouteArgs{key: $key, album: $album}'; return 'DriftActivitiesRouteArgs{key: $key, album: $album, assetId: $assetId, assetName: $assetName}';
} }
} }

View File

@@ -225,7 +225,7 @@ enum ActionButtonType {
iconData: Icons.info_outline, iconData: Icons.info_outline,
iconColor: context.originalTheme?.iconTheme.color, iconColor: context.originalTheme?.iconTheme.color,
menuItem: true, menuItem: true,
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()),
), ),
ActionButtonType.viewInTimeline => BaseActionButton( ActionButtonType.viewInTimeline => BaseActionButton(
label: 'view_in_timeline'.tr(), label: 'view_in_timeline'.tr(),

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class AssetMarkerIcon extends StatelessWidget {
const AssetMarkerIcon({required this.id, required this.thumbhash, super.key});
final String id;
final String thumbhash;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
return LayoutBuilder(
builder: (context, constraints) {
final pinHeight = constraints.maxHeight * 0.14;
final pinWidth = constraints.maxWidth * 0.14;
return SizedOverflowBox(
size: Size(pinWidth, pinHeight),
child: Stack(
// alignment: AlignmentGeometry.center,
children: [
Positioned(
bottom: 0,
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: context.colorScheme.onSurface,
secondaryColor: context.colorScheme.surface,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
child: SizedBox(height: pinHeight, width: pinWidth),
),
),
Positioned(
top: constraints.maxHeight * 0.07,
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: context.colorScheme.onSurface,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: RemoteImageProvider(url: imageUrl),
),
),
),
],
),
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
const _PinPainter({
required this.primaryColor,
required this.secondaryColor,
required this.primaryRadius,
required this.secondaryRadius,
});
@override
void paint(Canvas canvas, Size size) {
Paint primaryBrush = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
Paint secondaryBrush = Paint()
..color = secondaryColor
..style = PaintingStyle.fill;
Paint lineBrush = Paint()
..color = primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush);
canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush);
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
// The line is to make the above triangluar path more prominent since it has a slight curve
canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush);
}
Path getTrianglePath(double x, double y) {
final firstEndPoint = Offset(x / 2, y);
final controlPoint = Offset(x / 2, y * 0.3);
final secondEndPoint = Offset(x, 0);
return Path()
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy)
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy)
..lineTo(0, 0);
}
@override
bool shouldRepaint(_PinPainter old) {
return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor;
}
}

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; import 'package:immich_mobile/widgets/map/asset_market_icon.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
/// A non-interactive thumbnail of a map in the given coordinates with optional markers /// A non-interactive thumbnail of a map in the given coordinates with optional markers
@@ -45,21 +45,12 @@ class MapThumbnail extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
final controller = useRef<MapLibreMapController?>(null); final controller = useRef<MapLibreMapController?>(null);
final styleLoaded = useState(false); final styleLoaded = useState(false);
final position = useValueNotifier<Point<num>?>(null);
Future<void> onMapCreated(MapLibreMapController mapController) async { Future<void> onMapCreated(MapLibreMapController mapController) async {
controller.value = mapController; controller.value = mapController;
styleLoaded.value = false; styleLoaded.value = false;
if (assetMarkerRemoteId != null) {
// The iOS impl returns wrong toScreenLocation without the delay
Future.delayed(
const Duration(milliseconds: 100),
() async => position.value = await mapController.toScreenLocation(centre),
);
}
onCreated?.call(mapController); onCreated?.call(mapController);
} }
@@ -90,11 +81,11 @@ class MapThumbnail extends HookConsumerWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)), borderRadius: const BorderRadius.all(Radius.circular(15)),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: AlignmentGeometry.topCenter,
children: [ children: [
style.widgetWhen( style.widgetWhen(
onData: (style) => MapLibreMap( onData: (style) => MapLibreMap(
initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom), initialCameraPosition: CameraPosition(target: centre, zoom: zoom),
styleString: style, styleString: style,
onMapCreated: onMapCreated, onMapCreated: onMapCreated,
onStyleLoadedCallback: onStyleLoaded, onStyleLoadedCallback: onStyleLoaded,
@@ -109,16 +100,15 @@ class MapThumbnail extends HookConsumerWidget {
attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null, attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null,
), ),
), ),
ValueListenableBuilder( if (assetMarkerRemoteId != null && assetThumbhash != null)
valueListenable: position, Container(
builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null width: width,
? PositionedAssetMarkerIcon( height: height / 2,
size: height / 2, alignment: Alignment.bottomCenter,
point: value, child: SizedBox.square(
assetRemoteId: assetMarkerRemoteId!, dimension: height / 2.5,
assetThumbhash: assetThumbhash!, child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!),
) ),
: const SizedBox.shrink(),
), ),
], ],
), ),

View File

@@ -3,8 +3,7 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/widgets/map/asset_market_icon.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class PositionedAssetMarkerIcon extends StatelessWidget { class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point; final Point<num> point;
@@ -36,106 +35,9 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
onTap: () => onTap?.call(), onTap: () => onTap?.call(),
child: SizedBox.square( child: SizedBox.square(
dimension: size, dimension: size,
child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
), ),
), ),
); );
} }
} }
class _AssetMarkerIcon extends StatelessWidget {
const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key});
final String id;
final String thumbhash;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
Positioned(
bottom: 0,
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: context.colorScheme.onSurface,
secondaryColor: context.colorScheme.surface,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
child: SizedBox(height: constraints.maxHeight * 0.14, width: constraints.maxWidth * 0.14),
),
),
Positioned(
top: constraints.maxHeight * 0.07,
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: context.colorScheme.onSurface,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: RemoteImageProvider(url: imageUrl),
),
),
),
],
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
const _PinPainter({
required this.primaryColor,
required this.secondaryColor,
required this.primaryRadius,
required this.secondaryRadius,
});
@override
void paint(Canvas canvas, Size size) {
Paint primaryBrush = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
Paint secondaryBrush = Paint()
..color = secondaryColor
..style = PaintingStyle.fill;
Paint lineBrush = Paint()
..color = primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush);
canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush);
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
// The line is to make the above triangluar path more prominent since it has a slight curve
canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush);
}
Path getTrianglePath(double x, double y) {
final firstEndPoint = Offset(x / 2, y);
final controlPoint = Offset(x / 2, y * 0.3);
final secondEndPoint = Offset(x, 0);
return Path()
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy)
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy)
..lineTo(0, 0);
}
@override
bool shouldRepaint(_PinPainter old) {
return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor;
}
}

View File

@@ -257,6 +257,7 @@ class PhotoView extends StatefulWidget {
this.onDragStart, this.onDragStart,
this.onDragEnd, this.onDragEnd,
this.onDragUpdate, this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd, this.onScaleEnd,
this.onLongPressStart, this.onLongPressStart,
this.customSize, this.customSize,
@@ -299,6 +300,7 @@ class PhotoView extends StatefulWidget {
this.onDragStart, this.onDragStart,
this.onDragEnd, this.onDragEnd,
this.onDragUpdate, this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd, this.onScaleEnd,
this.onLongPressStart, this.onLongPressStart,
this.customSize, this.customSize,
@@ -417,6 +419,9 @@ class PhotoView extends StatefulWidget {
/// location. /// location.
final PhotoViewImageDragUpdateCallback? onDragUpdate; final PhotoViewImageDragUpdateCallback? onDragUpdate;
/// A callback when a drag gesture is canceled by the system.
final VoidCallback? onDragCancel;
/// A pointer that will trigger a scale has stopped contacting the screen at a /// A pointer that will trigger a scale has stopped contacting the screen at a
/// particular location. /// particular location.
final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageScaleEndCallback? onScaleEnd;
@@ -543,7 +548,7 @@ class _PhotoViewState extends State<PhotoView> with AutomaticKeepAliveClientMixi
return LayoutBuilder( return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {
final computedOuterSize = widget.customSize ?? constraints.biggest; final computedOuterSize = widget.customSize ?? constraints.biggest;
final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.black); final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.transparent);
return widget._isCustomChild return widget._isCustomChild
? CustomChildWrapper( ? CustomChildWrapper(
@@ -564,6 +569,7 @@ class _PhotoViewState extends State<PhotoView> with AutomaticKeepAliveClientMixi
onDragStart: widget.onDragStart, onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd, onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate, onDragUpdate: widget.onDragUpdate,
onDragCancel: widget.onDragCancel,
onScaleEnd: widget.onScaleEnd, onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart, onLongPressStart: widget.onLongPressStart,
outerSize: computedOuterSize, outerSize: computedOuterSize,
@@ -596,6 +602,7 @@ class _PhotoViewState extends State<PhotoView> with AutomaticKeepAliveClientMixi
onDragStart: widget.onDragStart, onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd, onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate, onDragUpdate: widget.onDragUpdate,
onDragCancel: widget.onDragCancel,
onScaleEnd: widget.onScaleEnd, onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart, onLongPressStart: widget.onLongPressStart,
outerSize: computedOuterSize, outerSize: computedOuterSize,

View File

@@ -284,6 +284,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
onDragStart: pageOption.onDragStart, onDragStart: pageOption.onDragStart,
onDragEnd: pageOption.onDragEnd, onDragEnd: pageOption.onDragEnd,
onDragUpdate: pageOption.onDragUpdate, onDragUpdate: pageOption.onDragUpdate,
onDragCancel: pageOption.onDragCancel,
onScaleEnd: pageOption.onScaleEnd, onScaleEnd: pageOption.onScaleEnd,
onLongPressStart: pageOption.onLongPressStart, onLongPressStart: pageOption.onLongPressStart,
gestureDetectorBehavior: pageOption.gestureDetectorBehavior, gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
@@ -321,6 +322,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
onDragStart: pageOption.onDragStart, onDragStart: pageOption.onDragStart,
onDragEnd: pageOption.onDragEnd, onDragEnd: pageOption.onDragEnd,
onDragUpdate: pageOption.onDragUpdate, onDragUpdate: pageOption.onDragUpdate,
onDragCancel: pageOption.onDragCancel,
onScaleEnd: pageOption.onScaleEnd, onScaleEnd: pageOption.onScaleEnd,
onLongPressStart: pageOption.onLongPressStart, onLongPressStart: pageOption.onLongPressStart,
gestureDetectorBehavior: pageOption.gestureDetectorBehavior, gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
@@ -367,6 +369,7 @@ class PhotoViewGalleryPageOptions {
this.onDragStart, this.onDragStart,
this.onDragEnd, this.onDragEnd,
this.onDragUpdate, this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd, this.onScaleEnd,
this.onLongPressStart, this.onLongPressStart,
this.gestureDetectorBehavior, this.gestureDetectorBehavior,
@@ -397,6 +400,7 @@ class PhotoViewGalleryPageOptions {
this.onDragStart, this.onDragStart,
this.onDragEnd, this.onDragEnd,
this.onDragUpdate, this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd, this.onScaleEnd,
this.onLongPressStart, this.onLongPressStart,
this.gestureDetectorBehavior, this.gestureDetectorBehavior,
@@ -454,9 +458,12 @@ class PhotoViewGalleryPageOptions {
/// Mirror to [PhotoView.onDragDown] /// Mirror to [PhotoView.onDragDown]
final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragEndCallback? onDragEnd;
/// Mirror to [PhotoView.onDraUpdate] /// Mirror to [PhotoView.onDragUpdate]
final PhotoViewImageDragUpdateCallback? onDragUpdate; final PhotoViewImageDragUpdateCallback? onDragUpdate;
/// Mirror to [PhotoView.onDragCancel]
final VoidCallback? onDragCancel;
/// Mirror to [PhotoView.onTapDown] /// Mirror to [PhotoView.onTapDown]
final PhotoViewImageTapDownCallback? onTapDown; final PhotoViewImageTapDownCallback? onTapDown;

View File

@@ -36,6 +36,7 @@ class PhotoViewCore extends StatefulWidget {
required this.onDragStart, required this.onDragStart,
required this.onDragEnd, required this.onDragEnd,
required this.onDragUpdate, required this.onDragUpdate,
required this.onDragCancel,
required this.onScaleEnd, required this.onScaleEnd,
required this.onLongPressStart, required this.onLongPressStart,
required this.gestureDetectorBehavior, required this.gestureDetectorBehavior,
@@ -62,6 +63,7 @@ class PhotoViewCore extends StatefulWidget {
this.onDragStart, this.onDragStart,
this.onDragEnd, this.onDragEnd,
this.onDragUpdate, this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd, this.onScaleEnd,
this.onLongPressStart, this.onLongPressStart,
this.gestureDetectorBehavior, this.gestureDetectorBehavior,
@@ -100,6 +102,7 @@ class PhotoViewCore extends StatefulWidget {
final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate; final PhotoViewImageDragUpdateCallback? onDragUpdate;
final VoidCallback? onDragCancel;
final PhotoViewImageLongPressStartCallback? onLongPressStart; final PhotoViewImageLongPressStartCallback? onLongPressStart;
@@ -386,6 +389,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
onDragUpdate: widget.onDragUpdate != null onDragUpdate: widget.onDragUpdate != null
? (details) => widget.onDragUpdate!(context, details, widget.controller.value) ? (details) => widget.onDragUpdate!(context, details, widget.controller.value)
: null, : null,
onDragCancel: widget.onDragCancel,
hitDetector: this, hitDetector: this,
onTapUp: widget.onTapUp != null ? (details) => widget.onTapUp!(context, details, value) : null, onTapUp: widget.onTapUp != null ? (details) => widget.onTapUp!(context, details, value) : null,
onTapDown: widget.onTapDown != null ? (details) => widget.onTapDown!(context, details, value) : null, onTapDown: widget.onTapDown != null ? (details) => widget.onTapDown!(context, details, value) : null,

View File

@@ -16,6 +16,7 @@ class PhotoViewGestureDetector extends StatelessWidget {
this.onDragStart, this.onDragStart,
this.onDragEnd, this.onDragEnd,
this.onDragUpdate, this.onDragUpdate,
this.onDragCancel,
this.onLongPressStart, this.onLongPressStart,
this.child, this.child,
this.onTapUp, this.onTapUp,
@@ -34,6 +35,7 @@ class PhotoViewGestureDetector extends StatelessWidget {
final GestureDragEndCallback? onDragEnd; final GestureDragEndCallback? onDragEnd;
final GestureDragStartCallback? onDragStart; final GestureDragStartCallback? onDragStart;
final GestureDragUpdateCallback? onDragUpdate; final GestureDragUpdateCallback? onDragUpdate;
final GestureDragCancelCallback? onDragCancel;
final GestureTapUpCallback? onTapUp; final GestureTapUpCallback? onTapUp;
final GestureTapDownCallback? onTapDown; final GestureTapDownCallback? onTapDown;
@@ -73,7 +75,8 @@ class PhotoViewGestureDetector extends StatelessWidget {
instance instance
..onStart = onDragStart ..onStart = onDragStart
..onUpdate = onDragUpdate ..onUpdate = onDragUpdate
..onEnd = onDragEnd; ..onEnd = onDragEnd
..onCancel = onDragCancel;
}, },
); );
} }

View File

@@ -28,6 +28,7 @@ class ImageWrapper extends StatefulWidget {
required this.onDragStart, required this.onDragStart,
required this.onDragEnd, required this.onDragEnd,
required this.onDragUpdate, required this.onDragUpdate,
required this.onDragCancel,
required this.onScaleEnd, required this.onScaleEnd,
required this.onLongPressStart, required this.onLongPressStart,
required this.outerSize, required this.outerSize,
@@ -62,6 +63,7 @@ class ImageWrapper extends StatefulWidget {
final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate; final PhotoViewImageDragUpdateCallback? onDragUpdate;
final VoidCallback? onDragCancel;
final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageLongPressStartCallback? onLongPressStart; final PhotoViewImageLongPressStartCallback? onLongPressStart;
final Size outerSize; final Size outerSize;
@@ -203,6 +205,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
onDragStart: widget.onDragStart, onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd, onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate, onDragUpdate: widget.onDragUpdate,
onDragCancel: widget.onDragCancel,
onScaleEnd: widget.onScaleEnd, onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart, onLongPressStart: widget.onLongPressStart,
outerSize: widget.outerSize, outerSize: widget.outerSize,
@@ -233,6 +236,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
onDragStart: widget.onDragStart, onDragStart: widget.onDragStart,
onDragEnd: widget.onDragEnd, onDragEnd: widget.onDragEnd,
onDragUpdate: widget.onDragUpdate, onDragUpdate: widget.onDragUpdate,
onDragCancel: widget.onDragCancel,
onScaleEnd: widget.onScaleEnd, onScaleEnd: widget.onScaleEnd,
onLongPressStart: widget.onLongPressStart, onLongPressStart: widget.onLongPressStart,
gestureDetectorBehavior: widget.gestureDetectorBehavior, gestureDetectorBehavior: widget.gestureDetectorBehavior,
@@ -281,6 +285,7 @@ class CustomChildWrapper extends StatelessWidget {
this.onDragStart, this.onDragStart,
this.onDragEnd, this.onDragEnd,
this.onDragUpdate, this.onDragUpdate,
this.onDragCancel,
this.onScaleEnd, this.onScaleEnd,
this.onLongPressStart, this.onLongPressStart,
required this.outerSize, required this.outerSize,
@@ -313,6 +318,7 @@ class CustomChildWrapper extends StatelessWidget {
final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragStartCallback? onDragStart;
final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragEndCallback? onDragEnd;
final PhotoViewImageDragUpdateCallback? onDragUpdate; final PhotoViewImageDragUpdateCallback? onDragUpdate;
final VoidCallback? onDragCancel;
final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageScaleEndCallback? onScaleEnd;
final PhotoViewImageLongPressStartCallback? onLongPressStart; final PhotoViewImageLongPressStartCallback? onLongPressStart;
final Size outerSize; final Size outerSize;
@@ -348,6 +354,7 @@ class CustomChildWrapper extends StatelessWidget {
onDragStart: onDragStart, onDragStart: onDragStart,
onDragEnd: onDragEnd, onDragEnd: onDragEnd,
onDragUpdate: onDragUpdate, onDragUpdate: onDragUpdate,
onDragCancel: onDragCancel,
onScaleEnd: onScaleEnd, onScaleEnd: onScaleEnd,
onLongPressStart: onLongPressStart, onLongPressStart: onLongPressStart,
gestureDetectorBehavior: gestureDetectorBehavior, gestureDetectorBehavior: gestureDetectorBehavior,