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.pull/26290/head
parent
06d487782e
commit
5c6433b4ca
|
|
@ -16,9 +16,8 @@ class ScrollToDateEvent extends Event {
|
|||
}
|
||||
|
||||
// Asset Viewer Events
|
||||
class ViewerOpenBottomSheetEvent extends Event {
|
||||
final bool activitiesMode;
|
||||
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
|
||||
class ViewerShowDetailsEvent extends Event {
|
||||
const ViewerShowDetailsEvent();
|
||||
}
|
||||
|
||||
class ViewerReloadAssetEvent extends Event {
|
||||
|
|
|
|||
|
|
@ -183,8 +183,8 @@ class TimelineService {
|
|||
return _buffer.slice(start, start + count);
|
||||
}
|
||||
|
||||
// Pre-cache assets around the given index for asset viewer
|
||||
Future<void> preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||
// Preload assets around the given index for asset viewer
|
||||
Future<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||
|
||||
BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length));
|
||||
|
||||
|
|
|
|||
|
|
@ -32,3 +32,125 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
|||
@RoutePage()
|
||||
class DriftActivitiesPage extends HookConsumerWidget {
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id));
|
||||
final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier);
|
||||
final activities = ref.watch(albumActivityProvider(album.id, assetId));
|
||||
final listViewScrollController = useScrollController();
|
||||
|
||||
void scrollToBottom() {
|
||||
|
|
@ -36,7 +38,13 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
|||
overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)],
|
||||
child: Scaffold(
|
||||
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)],
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
|
|
@ -47,7 +55,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
|||
activityWidgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: CommentBubble(activity: activity),
|
||||
child: CommentBubble(activity: activity, isAssetActivity: assetId != null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/providers/asset_viewer/video_player_value_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_progress_indicator.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.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/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/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.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/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';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -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/presentation/widgets/action_buttons/base_action_button.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/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -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/infrastructure/album.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/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -8,18 +8,18 @@ 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/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:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class SheetLocationDetails extends ConsumerStatefulWidget {
|
||||
const SheetLocationDetails({super.key});
|
||||
class LocationDetails extends ConsumerStatefulWidget {
|
||||
const LocationDetails({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _SheetLocationDetailsState();
|
||||
ConsumerState createState() => _LocationDetailsState();
|
||||
}
|
||||
|
||||
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||
class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
MapLibreMapController? _mapController;
|
||||
|
||||
String? _getLocationName(ExifInfo? exifInfo) {
|
||||
|
|
@ -42,7 +42,6 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
|||
|
||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
||||
final currentExif = current.valueOrNull;
|
||||
|
||||
if (currentExif != null && currentExif.hasCoordinates) {
|
||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
||||
}
|
||||
|
|
@ -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/translate_extensions.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/routes.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/people.utils.dart';
|
||||
|
||||
class SheetPeopleDetails extends ConsumerStatefulWidget {
|
||||
const SheetPeopleDetails({super.key});
|
||||
class PeopleDetails extends ConsumerStatefulWidget {
|
||||
const PeopleDetails({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _SheetPeopleDetailsState();
|
||||
ConsumerState createState() => _PeopleDetailsState();
|
||||
}
|
||||
|
||||
class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
||||
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
|
|
@ -65,7 +65,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
|||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final person in people)
|
||||
_PeopleAvatar(
|
||||
_Avatar(
|
||||
person: person,
|
||||
assetFileCreatedAt: asset.createdAt,
|
||||
onTap: () {
|
||||
|
|
@ -97,14 +97,14 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
|||
}
|
||||
}
|
||||
|
||||
class _PeopleAvatar extends StatelessWidget {
|
||||
class _Avatar extends StatelessWidget {
|
||||
final DriftPerson person;
|
||||
final DateTime assetFileCreatedAt;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onNameTap;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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_viewer.state.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 {
|
||||
const AssetStackRow({super.key});
|
||||
|
|
@ -21,17 +21,11 @@ class AssetStackRow extends ConsumerWidget {
|
|||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0;
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 255,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity / 255,
|
||||
duration: Durations.short2,
|
||||
child: _StackList(stack: stackChildren),
|
||||
),
|
||||
);
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _StackList(stack: stackChildren);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/scroll_extensions.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.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';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.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/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.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/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/readonly_mode.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_gallery.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AssetViewerPage extends StatelessWidget {
|
||||
|
|
@ -79,10 +71,6 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||
_setAsset(ref, asset);
|
||||
}
|
||||
|
||||
void changeAsset(WidgetRef ref, BaseAsset asset) {
|
||||
_setAsset(ref, asset);
|
||||
}
|
||||
|
||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
// Always holds the current asset from the timeline
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
|
|
@ -94,45 +82,20 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
// Hide controls by default for videos
|
||||
if (asset.isVideo) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
}
|
||||
|
||||
const double _kBottomSheetMinimumExtent = 0.4;
|
||||
const double _kBottomSheetSnapExtent = 0.67;
|
||||
|
||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
||||
late PageController pageController;
|
||||
late DraggableScrollableController bottomSheetController;
|
||||
PersistentBottomSheetController? sheetCloseController;
|
||||
// PhotoViewGallery takes care of disposing it's controllers
|
||||
PhotoViewControllerBase? viewController;
|
||||
StreamSubscription? reloadSubscription;
|
||||
|
||||
StreamSubscription? _reloadSubscription;
|
||||
|
||||
late final int heroOffset;
|
||||
late PhotoViewControllerValue initialPhotoViewState;
|
||||
bool? hasDraggedDown;
|
||||
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;
|
||||
bool _assetReloadRequested = false;
|
||||
int _totalAssets = 0;
|
||||
|
||||
late final AssetPreloader _preloader;
|
||||
KeepAliveLink? _stackChildrenKeepAlive;
|
||||
|
||||
@override
|
||||
|
|
@ -140,94 +103,38 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||
super.initState();
|
||||
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
|
||||
pageController = PageController(initialPage: widget.initialIndex);
|
||||
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||
bottomSheetController = DraggableScrollableController();
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
_totalAssets = timelineService.totalAssets;
|
||||
_preloader = AssetPreloader(timelineService: timelineService, mounted: () => mounted);
|
||||
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
||||
reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||
_reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset != null) {
|
||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
}
|
||||
if (ref.read(assetViewerProvider).showingControls) {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
} else {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
|
||||
}
|
||||
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
pageController.dispose();
|
||||
bottomSheetController.dispose();
|
||||
_cancelTimers();
|
||||
reloadSubscription?.cancel();
|
||||
_prevPreCacheStream?.removeListener(_dummyListener);
|
||||
_nextPreCacheStream?.removeListener(_dummyListener);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
_preloader.dispose();
|
||||
_reloadSubscription?.cancel();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
|
||||
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);
|
||||
void _onAssetInit(Duration timeStamp) {
|
||||
_preloader.preload(widget.initialIndex, context.sizeData);
|
||||
_handleCasting();
|
||||
}
|
||||
|
||||
void _onAssetChanged(int index) async {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = await timelineService.getAssetAsync(index);
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
if (asset == null) return;
|
||||
|
||||
widget.changeAsset(ref, asset);
|
||||
_precacheAssets(index);
|
||||
AssetViewer._setAsset(ref, asset);
|
||||
_preloader.preload(index, context.sizeData);
|
||||
_handleCasting();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
|
|
@ -238,223 +145,40 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||
final asset = ref.read(currentAssetNotifier);
|
||||
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) {
|
||||
context.scaffoldMessenger.hideCurrentSnackBar();
|
||||
ref.read(castProvider.notifier).loadMedia(asset, false);
|
||||
} else {
|
||||
// casting cannot show local assets
|
||||
context.scaffoldMessenger.clearSnackBars();
|
||||
|
||||
if (ref.read(castProvider).isCasting) {
|
||||
ref.read(castProvider.notifier).stop();
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Text(
|
||||
"local_asset_cast_failed".tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
context.scaffoldMessenger.clearSnackBars();
|
||||
ref.read(castProvider.notifier).stop();
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Text(
|
||||
"local_asset_cast_failed".tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
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) {
|
||||
if (event is TimelineReloadEvent) {
|
||||
_onTimelineReloadEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
switch (event) {
|
||||
case TimelineReloadEvent():
|
||||
_onTimelineReloadEvent();
|
||||
case ViewerReloadAssetEvent():
|
||||
_assetReloadRequested = true;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
void _onTimelineReloadEvent() {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
totalAssets = timelineService.totalAssets;
|
||||
_totalAssets = timelineService.totalAssets;
|
||||
|
||||
if (totalAssets == 0) {
|
||||
if (_totalAssets == 0) {
|
||||
context.maybePop();
|
||||
return;
|
||||
}
|
||||
|
|
@ -469,229 +193,58 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||
}
|
||||
}
|
||||
|
||||
if (index >= totalAssets) {
|
||||
index = totalAssets - 1;
|
||||
if (index >= _totalAssets) {
|
||||
index = _totalAssets - 1;
|
||||
pageController.jumpToPage(index);
|
||||
}
|
||||
|
||||
if (assetReloadRequested) {
|
||||
assetReloadRequested = false;
|
||||
if (_assetReloadRequested) {
|
||||
_assetReloadRequested = false;
|
||||
_onAssetReloadEvent(index);
|
||||
}
|
||||
}
|
||||
|
||||
void _onAssetReloadEvent(int index) async {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
|
||||
if (newAsset == null) {
|
||||
return;
|
||||
}
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
if (newAsset == null) return;
|
||||
|
||||
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(() {
|
||||
_onAssetChanged(pageController.page!.round());
|
||||
sheetCloseController?.close();
|
||||
});
|
||||
}
|
||||
// Do not reload if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||
|
||||
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
|
||||
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();
|
||||
_onAssetChanged(index);
|
||||
}
|
||||
|
||||
@override
|
||||
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 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
|
||||
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async {
|
||||
ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) {
|
||||
if (!isCasting) return;
|
||||
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleCasting();
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for control visibility changes and change system UI mode accordingly
|
||||
ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async {
|
||||
if (showingControls) {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
} else {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
|
||||
}
|
||||
ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
|
||||
final (controls, details) = state;
|
||||
final mode = !controls || (CurrentPlatform.isIOS && details)
|
||||
? SystemUiMode.immersiveSticky
|
||||
: SystemUiMode.edgeToEdge;
|
||||
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(
|
||||
onPopInvokedWithResult: _onPop,
|
||||
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
|
||||
child: Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
|
|
@ -705,33 +258,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: _placeholderBuilder,
|
||||
pageController: pageController,
|
||||
scrollPhysics: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
||||
itemCount: totalAssets,
|
||||
onPageChanged: _onPageChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
builder: _assetBuilder,
|
||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||
enablePanAlways: true,
|
||||
PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: pageController,
|
||||
physics: isZoomed
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: _totalAssets,
|
||||
onPageChanged: (index) => _onAssetChanged(index),
|
||||
itemBuilder: (context, index) => AssetPage(index: index, heroOffset: heroOffset),
|
||||
),
|
||||
),
|
||||
if (!showingBottomSheet)
|
||||
const Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [AssetStackRow(), ViewerBottomBar()],
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,31 +3,35 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
|
|||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class AssetViewerState {
|
||||
final int backgroundOpacity;
|
||||
final bool showingBottomSheet;
|
||||
final double backgroundOpacity;
|
||||
final bool showingDetails;
|
||||
final bool showingControls;
|
||||
final bool isZoomed;
|
||||
final BaseAsset? currentAsset;
|
||||
final int stackIndex;
|
||||
|
||||
const AssetViewerState({
|
||||
this.backgroundOpacity = 255,
|
||||
this.showingBottomSheet = false,
|
||||
this.backgroundOpacity = 1.0,
|
||||
this.showingDetails = false,
|
||||
this.showingControls = true,
|
||||
this.isZoomed = false,
|
||||
this.currentAsset,
|
||||
this.stackIndex = 0,
|
||||
});
|
||||
|
||||
AssetViewerState copyWith({
|
||||
int? backgroundOpacity,
|
||||
bool? showingBottomSheet,
|
||||
double? backgroundOpacity,
|
||||
bool? showingDetails,
|
||||
bool? showingControls,
|
||||
bool? isZoomed,
|
||||
BaseAsset? currentAsset,
|
||||
int? stackIndex,
|
||||
}) {
|
||||
return AssetViewerState(
|
||||
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
|
||||
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet,
|
||||
showingDetails: showingDetails ?? this.showingDetails,
|
||||
showingControls: showingControls ?? this.showingControls,
|
||||
isZoomed: isZoomed ?? this.isZoomed,
|
||||
currentAsset: currentAsset ?? this.currentAsset,
|
||||
stackIndex: stackIndex ?? this.stackIndex,
|
||||
);
|
||||
|
|
@ -35,7 +39,7 @@ class AssetViewerState {
|
|||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)';
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)';
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -44,8 +48,9 @@ class AssetViewerState {
|
|||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is AssetViewerState &&
|
||||
other.backgroundOpacity == backgroundOpacity &&
|
||||
other.showingBottomSheet == showingBottomSheet &&
|
||||
other.showingDetails == showingDetails &&
|
||||
other.showingControls == showingControls &&
|
||||
other.isZoomed == isZoomed &&
|
||||
other.currentAsset == currentAsset &&
|
||||
other.stackIndex == stackIndex;
|
||||
}
|
||||
|
|
@ -53,8 +58,9 @@ class AssetViewerState {
|
|||
@override
|
||||
int get hashCode =>
|
||||
backgroundOpacity.hashCode ^
|
||||
showingBottomSheet.hashCode ^
|
||||
showingDetails.hashCode ^
|
||||
showingControls.hashCode ^
|
||||
isZoomed.hashCode ^
|
||||
currentAsset.hashCode ^
|
||||
stackIndex.hashCode;
|
||||
}
|
||||
|
|
@ -76,18 +82,18 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
}
|
||||
|
||||
void setOpacity(int opacity) {
|
||||
void setOpacity(double opacity) {
|
||||
if (opacity == state.backgroundOpacity) {
|
||||
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) {
|
||||
if (showing == state.showingBottomSheet) {
|
||||
void setShowingDetails(bool showing) {
|
||||
if (showing == state.showingDetails) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls);
|
||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||
if (showing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
}
|
||||
|
|
@ -104,6 +110,13 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||
state = state.copyWith(showingControls: !state.showingControls);
|
||||
}
|
||||
|
||||
void setZoomed(bool isZoomed) {
|
||||
if (isZoomed == state.isZoomed) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isZoomed: isZoomed);
|
||||
}
|
||||
|
||||
void setStackIndex(int index) {
|
||||
if (index == state.stackIndex) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -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/add_action_button.widget.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/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
|
@ -29,15 +29,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0;
|
||||
}
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
|
|
@ -56,37 +50,30 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||
],
|
||||
];
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 255,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity / 255,
|
||||
duration: Durations.short2,
|
||||
child: AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: isSheetOpen
|
||||
? const SizedBox.shrink()
|
||||
: Theme(
|
||||
data: context.themeData.copyWith(
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
textTheme: context.themeData.textTheme.copyWith(
|
||||
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: showingDetails
|
||||
? const SizedBox.shrink()
|
||||
: Theme(
|
||||
data: context.themeData.copyWith(
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
textTheme: context.themeData.textTheme.copyWith(
|
||||
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/cast.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/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
|
@ -205,7 +205,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
|
||||
if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) {
|
||||
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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_value_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/widgets/asset_viewer/center_play_button.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) {
|
||||
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
|
||||
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
if (showBottomSheet) {
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
showControls = false;
|
||||
}
|
||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/extensions/build_context_extensions.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/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
|
|
|||
|
|
@ -3,16 +3,15 @@ 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/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/routing/router.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/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/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/readonly_mode.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 isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
|
||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
||||
|
|
@ -44,7 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||
}
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0;
|
||||
opacity = 0.0;
|
||||
}
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
|
@ -55,7 +54,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||
IconButton(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
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)];
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: opacity < 255,
|
||||
ignoring: opacity < 1.0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity / 255,
|
||||
opacity: opacity,
|
||||
duration: Durations.short2,
|
||||
child: AppBar(
|
||||
backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125),
|
||||
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
|
||||
leading: const _AppBarBackButton(),
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
shape: const Border(),
|
||||
actions: isShowingSheet || isReadonlyModeEnabled
|
||||
actions: showingDetails || isReadonlyModeEnabled
|
||||
? null
|
||||
: isInLockedView
|
||||
? lockedViewActions
|
||||
|
|
@ -99,9 +104,9 @@ class _AppBarBackButton extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||
final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white;
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0),
|
||||
|
|
@ -112,7 +117,7 @@ class _AppBarBackButton extends ConsumerWidget {
|
|||
iconSize: 22,
|
||||
iconColor: foregroundColor,
|
||||
padding: EdgeInsets.zero,
|
||||
elevation: isShowingSheet ? 4 : 0,
|
||||
elevation: showingDetails ? 4 : 0,
|
||||
),
|
||||
onPressed: context.maybePop,
|
||||
child: const Icon(Icons.arrow_back_rounded),
|
||||
|
|
@ -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/presentation/widgets/asset_viewer/asset_viewer.state.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/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
|
|
|||
|
|
@ -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 currentAsset = ref.watch(currentAssetNotifier);
|
||||
if (currentAsset == null) {
|
||||
|
|
@ -753,10 +753,17 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
|
|||
DriftActivitiesRoute({
|
||||
Key? key,
|
||||
required RemoteAlbum album,
|
||||
String? assetId,
|
||||
String? assetName,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftActivitiesRoute.name,
|
||||
args: DriftActivitiesRouteArgs(key: key, album: album),
|
||||
args: DriftActivitiesRouteArgs(
|
||||
key: key,
|
||||
album: album,
|
||||
assetId: assetId,
|
||||
assetName: assetName,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
|
|
@ -766,21 +773,35 @@ class DriftActivitiesRoute extends PageRouteInfo<DriftActivitiesRouteArgs> {
|
|||
name,
|
||||
builder: (data) {
|
||||
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 {
|
||||
const DriftActivitiesRouteArgs({this.key, required this.album});
|
||||
const DriftActivitiesRouteArgs({
|
||||
this.key,
|
||||
required this.album,
|
||||
this.assetId,
|
||||
this.assetName,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final RemoteAlbum album;
|
||||
|
||||
final String? assetId;
|
||||
|
||||
final String? assetName;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftActivitiesRouteArgs{key: $key, album: $album}';
|
||||
return 'DriftActivitiesRouteArgs{key: $key, album: $album, assetId: $assetId, assetName: $assetName}';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ enum ActionButtonType {
|
|||
iconData: Icons.info_outline,
|
||||
iconColor: context.originalTheme?.iconTheme.color,
|
||||
menuItem: true,
|
||||
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||
onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()),
|
||||
),
|
||||
ActionButtonType.viewInTimeline => BaseActionButton(
|
||||
label: 'view_in_timeline'.tr(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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/maplibrecontroller_extensions.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';
|
||||
|
||||
/// A non-interactive thumbnail of a map in the given coordinates with optional markers
|
||||
|
|
@ -45,21 +45,12 @@ class MapThumbnail extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
|
||||
final controller = useRef<MapLibreMapController?>(null);
|
||||
final styleLoaded = useState(false);
|
||||
final position = useValueNotifier<Point<num>?>(null);
|
||||
|
||||
Future<void> onMapCreated(MapLibreMapController mapController) async {
|
||||
controller.value = mapController;
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -90,11 +81,11 @@ class MapThumbnail extends HookConsumerWidget {
|
|||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
alignment: AlignmentGeometry.topCenter,
|
||||
children: [
|
||||
style.widgetWhen(
|
||||
onData: (style) => MapLibreMap(
|
||||
initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom),
|
||||
initialCameraPosition: CameraPosition(target: centre, zoom: zoom),
|
||||
styleString: style,
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoadedCallback: onStyleLoaded,
|
||||
|
|
@ -109,17 +100,16 @@ class MapThumbnail extends HookConsumerWidget {
|
|||
attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: position,
|
||||
builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null
|
||||
? PositionedAssetMarkerIcon(
|
||||
size: height / 2,
|
||||
point: value,
|
||||
assetRemoteId: assetMarkerRemoteId!,
|
||||
assetThumbhash: assetThumbhash!,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
if (assetMarkerRemoteId != null && assetThumbhash != null)
|
||||
Container(
|
||||
width: width,
|
||||
height: height / 2,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox.square(
|
||||
dimension: height / 2.5,
|
||||
child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import 'dart:math';
|
|||
|
||||
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';
|
||||
import 'package:immich_mobile/widgets/map/asset_market_icon.dart';
|
||||
|
||||
class PositionedAssetMarkerIcon extends StatelessWidget {
|
||||
final Point<num> point;
|
||||
|
|
@ -36,106 +35,9 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
|
|||
onTap: () => onTap?.call(),
|
||||
child: SizedBox.square(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,6 +257,7 @@ class PhotoView extends StatefulWidget {
|
|||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onDragCancel,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.customSize,
|
||||
|
|
@ -299,6 +300,7 @@ class PhotoView extends StatefulWidget {
|
|||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onDragCancel,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.customSize,
|
||||
|
|
@ -417,6 +419,9 @@ class PhotoView extends StatefulWidget {
|
|||
/// location.
|
||||
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
|
||||
/// particular location.
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
|
@ -543,7 +548,7 @@ class _PhotoViewState extends State<PhotoView> with AutomaticKeepAliveClientMixi
|
|||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
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
|
||||
? CustomChildWrapper(
|
||||
|
|
@ -564,6 +569,7 @@ class _PhotoViewState extends State<PhotoView> with AutomaticKeepAliveClientMixi
|
|||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onDragCancel: widget.onDragCancel,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
onLongPressStart: widget.onLongPressStart,
|
||||
outerSize: computedOuterSize,
|
||||
|
|
@ -596,6 +602,7 @@ class _PhotoViewState extends State<PhotoView> with AutomaticKeepAliveClientMixi
|
|||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onDragCancel: widget.onDragCancel,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
onLongPressStart: widget.onLongPressStart,
|
||||
outerSize: computedOuterSize,
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||
onDragStart: pageOption.onDragStart,
|
||||
onDragEnd: pageOption.onDragEnd,
|
||||
onDragUpdate: pageOption.onDragUpdate,
|
||||
onDragCancel: pageOption.onDragCancel,
|
||||
onScaleEnd: pageOption.onScaleEnd,
|
||||
onLongPressStart: pageOption.onLongPressStart,
|
||||
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
||||
|
|
@ -321,6 +322,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||
onDragStart: pageOption.onDragStart,
|
||||
onDragEnd: pageOption.onDragEnd,
|
||||
onDragUpdate: pageOption.onDragUpdate,
|
||||
onDragCancel: pageOption.onDragCancel,
|
||||
onScaleEnd: pageOption.onScaleEnd,
|
||||
onLongPressStart: pageOption.onLongPressStart,
|
||||
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
||||
|
|
@ -367,6 +369,7 @@ class PhotoViewGalleryPageOptions {
|
|||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onDragCancel,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.gestureDetectorBehavior,
|
||||
|
|
@ -397,6 +400,7 @@ class PhotoViewGalleryPageOptions {
|
|||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onDragCancel,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.gestureDetectorBehavior,
|
||||
|
|
@ -454,9 +458,12 @@ class PhotoViewGalleryPageOptions {
|
|||
/// Mirror to [PhotoView.onDragDown]
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
|
||||
/// Mirror to [PhotoView.onDraUpdate]
|
||||
/// Mirror to [PhotoView.onDragUpdate]
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
/// Mirror to [PhotoView.onDragCancel]
|
||||
final VoidCallback? onDragCancel;
|
||||
|
||||
/// Mirror to [PhotoView.onTapDown]
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class PhotoViewCore extends StatefulWidget {
|
|||
required this.onDragStart,
|
||||
required this.onDragEnd,
|
||||
required this.onDragUpdate,
|
||||
required this.onDragCancel,
|
||||
required this.onScaleEnd,
|
||||
required this.onLongPressStart,
|
||||
required this.gestureDetectorBehavior,
|
||||
|
|
@ -62,6 +63,7 @@ class PhotoViewCore extends StatefulWidget {
|
|||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onDragCancel,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.gestureDetectorBehavior,
|
||||
|
|
@ -100,6 +102,7 @@ class PhotoViewCore extends StatefulWidget {
|
|||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
final VoidCallback? onDragCancel;
|
||||
|
||||
final PhotoViewImageLongPressStartCallback? onLongPressStart;
|
||||
|
||||
|
|
@ -386,6 +389,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
onDragUpdate: widget.onDragUpdate != null
|
||||
? (details) => widget.onDragUpdate!(context, details, widget.controller.value)
|
||||
: null,
|
||||
onDragCancel: widget.onDragCancel,
|
||||
hitDetector: this,
|
||||
onTapUp: widget.onTapUp != null ? (details) => widget.onTapUp!(context, details, value) : null,
|
||||
onTapDown: widget.onTapDown != null ? (details) => widget.onTapDown!(context, details, value) : null,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class PhotoViewGestureDetector extends StatelessWidget {
|
|||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onDragCancel,
|
||||
this.onLongPressStart,
|
||||
this.child,
|
||||
this.onTapUp,
|
||||
|
|
@ -34,6 +35,7 @@ class PhotoViewGestureDetector extends StatelessWidget {
|
|||
final GestureDragEndCallback? onDragEnd;
|
||||
final GestureDragStartCallback? onDragStart;
|
||||
final GestureDragUpdateCallback? onDragUpdate;
|
||||
final GestureDragCancelCallback? onDragCancel;
|
||||
|
||||
final GestureTapUpCallback? onTapUp;
|
||||
final GestureTapDownCallback? onTapDown;
|
||||
|
|
@ -73,7 +75,8 @@ class PhotoViewGestureDetector extends StatelessWidget {
|
|||
instance
|
||||
..onStart = onDragStart
|
||||
..onUpdate = onDragUpdate
|
||||
..onEnd = onDragEnd;
|
||||
..onEnd = onDragEnd
|
||||
..onCancel = onDragCancel;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class ImageWrapper extends StatefulWidget {
|
|||
required this.onDragStart,
|
||||
required this.onDragEnd,
|
||||
required this.onDragUpdate,
|
||||
required this.onDragCancel,
|
||||
required this.onScaleEnd,
|
||||
required this.onLongPressStart,
|
||||
required this.outerSize,
|
||||
|
|
@ -62,6 +63,7 @@ class ImageWrapper extends StatefulWidget {
|
|||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
final VoidCallback? onDragCancel;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
final PhotoViewImageLongPressStartCallback? onLongPressStart;
|
||||
final Size outerSize;
|
||||
|
|
@ -203,6 +205,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onDragCancel: widget.onDragCancel,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
onLongPressStart: widget.onLongPressStart,
|
||||
outerSize: widget.outerSize,
|
||||
|
|
@ -233,6 +236,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onDragCancel: widget.onDragCancel,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
onLongPressStart: widget.onLongPressStart,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
|
|
@ -281,6 +285,7 @@ class CustomChildWrapper extends StatelessWidget {
|
|||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onDragCancel,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
required this.outerSize,
|
||||
|
|
@ -313,6 +318,7 @@ class CustomChildWrapper extends StatelessWidget {
|
|||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
final VoidCallback? onDragCancel;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
final PhotoViewImageLongPressStartCallback? onLongPressStart;
|
||||
final Size outerSize;
|
||||
|
|
@ -348,6 +354,7 @@ class CustomChildWrapper extends StatelessWidget {
|
|||
onDragStart: onDragStart,
|
||||
onDragEnd: onDragEnd,
|
||||
onDragUpdate: onDragUpdate,
|
||||
onDragCancel: onDragCancel,
|
||||
onScaleEnd: onScaleEnd,
|
||||
onLongPressStart: onLongPressStart,
|
||||
gestureDetectorBehavior: gestureDetectorBehavior,
|
||||
|
|
|
|||
Loading…
Reference in New Issue