diff --git a/i18n/en.json b/i18n/en.json index 6cf5bbeaee..5efd33b8ae 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1403,6 +1403,7 @@ "link_to_oauth": "Link to OAuth", "linked_oauth_account": "Linked OAuth account", "list": "List", + "live": "Live", "loading": "Loading", "loading_search_results_failed": "Loading search results failed", "local": "Local", @@ -1584,6 +1585,7 @@ "month": "Month", "monthly_title_text_date_format": "MMMM y", "more": "More", + "motion": "Motion", "move": "Move", "move_down": "Move down", "move_off_locked_folder": "Move out of locked folder", diff --git a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart new file mode 100644 index 0000000000..800af23039 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; + +class MotionPhotoPlayButton extends ConsumerWidget { + const MotionPhotoPlayButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset)); + final isPlaying = ref.watch(isPlayingMotionVideoProvider); + final showControls = ref.watch(assetViewerProvider.select((state) => state.showingControls)); + final isShowingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + + if (asset == null || !asset.isMotionPhoto || isShowingDetails) { + return const SizedBox.shrink(); + } + + return IgnorePointer( + ignoring: !showControls, + child: AnimatedOpacity( + opacity: showControls ? 1.0 : 0.0, + duration: Durations.short2, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Center( + child: _MotionButton( + isPlaying: isPlaying, + onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, + ), + ), + ), + ), + ), + ); + } +} + +class _MotionButton extends StatelessWidget { + final bool isPlaying; + final VoidCallback onPressed; + + const _MotionButton({required this.isPlaying, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.grey[900]!.withValues(alpha: 0.4), + borderRadius: const BorderRadius.all(Radius.circular(24)), + child: InkWell( + onTap: onPressed, + borderRadius: const BorderRadius.all(Radius.circular(24)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 8), + Text( + CurrentPlatform.isAndroid ? 'motion'.t(context: context) : 'live'.t(context: context), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 5a79485daf..308f6a72a3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget { timelineOrigin: timelineOrigin, ); - final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); + final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context); return MenuAnchor( consumeOutsideTap: true, diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 42d11a8063..3b158c63a8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -1,4 +1,5 @@ import 'package:auto_route/auto_route.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'; @@ -10,11 +11,13 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/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'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/timezone.dart'; class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const ViewerTopAppBar({super.key}); @@ -95,16 +98,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { ), SafeArea( bottom: false, - child: SizedBox.square( + child: SizedBox( + height: preferredSize.height, child: Theme( data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)), - child: Row( - children: [ - const _AppBarBackButton(), - const Spacer(), - if (!showingDetails && !isReadonlyModeEnabled) - if (isInLockedView) ...lockedViewActions else ...actions, - ], + child: NavigationToolbar( + centerMiddle: true, + leading: const _AppBarBackButton(), + middle: showingDetails ? null : _AssetInfoTitle(asset: asset), + trailing: !showingDetails && !isReadonlyModeEnabled + ? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions) + : null, ), ), ), @@ -139,3 +143,32 @@ class _AppBarBackButton extends ConsumerWidget { ); } } + +class _AssetInfoTitle extends ConsumerWidget { + final BaseAsset asset; + + const _AssetInfoTitle({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + DateTime dateTime = asset.createdAt.toLocal(); + final currentYear = DateTime.now().year; + final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull; + + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, _) = applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo.timeZone); + } + + final isCurrentYear = dateTime.year == currentYear; + final dateFormatted = isCurrentYear ? DateFormat.MMMd().format(dateTime) : DateFormat.yMMMd().format(dateTime); + final timeFormatted = DateFormat.jm().format(dateTime); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(dateFormatted, style: context.textTheme.labelLarge?.copyWith(color: Colors.white)), + Text(timeFormatted, style: context.textTheme.labelMedium?.copyWith(color: Colors.white70)), + ], + ); + } +} diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 3f79cd498f..d527f3a59e 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -315,7 +315,7 @@ class ActionButtonBuilder { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } - static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) { + static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) { final visibleButtons = defaultViewerKebabMenuOrder .where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context)) .toList(); @@ -331,7 +331,7 @@ class ActionButtonBuilder { if (lastGroup != null && type.kebabMenuGroup != lastGroup) { result.add(const Divider(height: 1)); } - result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref)); + result.add(type.buildButton(context, buildContext, false, true)); lastGroup = type.kebabMenuGroup; }