diff --git a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart index a49ac9551a..86a0c80345 100644 --- a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart +++ b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; class DriftActivityTextField extends ConsumerStatefulWidget { final bool isEnabled; + final bool isBottomSheet; final String? likeId; final Function(String) onSubmit; final Function()? onKeyboardFocus; @@ -16,6 +17,7 @@ class DriftActivityTextField extends ConsumerStatefulWidget { this.isEnabled = true, this.likeId, this.onKeyboardFocus, + this.isBottomSheet = false, super.key, }); @@ -34,7 +36,9 @@ class _DriftActivityTextFieldState extends ConsumerState inputController = TextEditingController(); inputFocusNode = FocusNode(); - inputFocusNode.requestFocus(); + if (!widget.isBottomSheet) { + inputFocusNode.requestFocus(); + } inputFocusNode.addListener(() { if (inputFocusNode.hasFocus) { @@ -72,7 +76,7 @@ class _DriftActivityTextFieldState extends ConsumerState } return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), + padding: EdgeInsets.symmetric(vertical: widget.isBottomSheet ? 0 : 10), child: TextField( controller: inputController, enabled: widget.isEnabled, diff --git a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart new file mode 100644 index 0000000000..81e64bed89 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart @@ -0,0 +1,95 @@ +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/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'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/activities/activity_tile.dart'; +import 'package:immich_mobile/widgets/activities/dismissible_activity.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 user = ref.watch(currentUserProvider); + + final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); + final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); + + Future 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]; + final canDelete = activity.user.id == user?.id || album.ownerId == user?.id; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: DismissibleActivity( + activity.id, + ActivityTile(activity, isBottomSheet: true), + onDismiss: canDelete + ? (activityId) async => await activityNotifier.removeActivity(activity.id) + : null, + ), + ); + }, 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 ? Colors.black : Colors.white, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index e3106c7f7f..12f8771982 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widge 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/asset_viewer/activities_bottom_sheet.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'; @@ -418,7 +419,7 @@ class _AssetViewerState extends ConsumerState { if (event is ViewerOpenBottomSheetEvent) { final extent = _kBottomSheetMinimumExtent + 0.3; - _openBottomSheet(scaffoldContext!, extent: extent); + _openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode); final offset = _getVerticalOffsetForBottomSheet(extent); viewController?.position = Offset(0, -offset); return; @@ -460,7 +461,7 @@ class _AssetViewerState extends ConsumerState { }); } - void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) { + void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) { ref.read(assetViewerProvider.notifier).setBottomSheet(true); initialScale = viewController?.scale; // viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01); @@ -474,7 +475,9 @@ class _AssetViewerState extends ConsumerState { builder: (_) { return NotificationListener( onNotification: _onNotification, - child: AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent), + child: activitiesMode + ? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent) + : AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent), ); }, ); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index 354902c9d7..d0fb1f8ba0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -4,7 +4,8 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:riverpod_annotation/riverpod_annotation.dart'; class ViewerOpenBottomSheetEvent extends Event { - const ViewerOpenBottomSheetEvent(); + final bool activitiesMode; + const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); } class ViewerReloadAssetEvent extends Event { diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 38ad9d17de..ab88dffab4 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio 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/providers/activity.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/current_album.provider.dart'; @@ -53,6 +54,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { int 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) { + ref.watch(albumActivityProvider(album.id, asset.id)); + } + if (!showControls) { opacity = 0; } @@ -66,7 +71,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { IconButton( icon: const Icon(Icons.chat_outlined), onPressed: () { - context.navigateTo(const DriftActivitiesRoute()); + EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); }, ), if (showViewInTimelineButton) diff --git a/mobile/lib/presentation/widgets/bottom_sheet/activities_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/activities_bottom_sheet.widget.dart new file mode 100644 index 0000000000..e8f29a976e --- /dev/null +++ b/mobile/lib/presentation/widgets/bottom_sheet/activities_bottom_sheet.widget.dart @@ -0,0 +1,92 @@ +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/presentation/widgets/album/drift_activity_text_field.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'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/activities/activity_tile.dart'; +import 'package:immich_mobile/widgets/activities/dismissible_activity.dart'; +import 'base_bottom_sheet.widget.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 user = ref.watch(currentUserProvider); + + final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); + final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); + + Future 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(height: 5); + return const SizedBox.shrink(); + } + final activity = data[index]; + final canDelete = activity.user.id == user?.id || album.ownerId == user?.id; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: DismissibleActivity( + activity.id, + ActivityTile(activity), + onDismiss: canDelete + ? (activityId) async => await activityNotifier.removeActivity(activity.id) + : null, + ), + ); + }, childCount: data.length + 1), + ); + }, + ); + } + + return BaseBottomSheet( + actions: [], + slivers: [buildActivitiesSliver()], + footer: 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 ? Colors.black : Colors.white, + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart index 0549bceb9c..7205dad941 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart @@ -8,6 +8,7 @@ class BaseBottomSheet extends ConsumerStatefulWidget { final List actions; final DraggableScrollableController? controller; final List? slivers; + final Widget? footer; final double initialChildSize; final double minChildSize; final double maxChildSize; @@ -20,6 +21,7 @@ class BaseBottomSheet extends ConsumerStatefulWidget { super.key, required this.actions, this.slivers, + this.footer, this.controller, this.initialChildSize = 0.35, double? minChildSize, @@ -73,24 +75,35 @@ class _BaseDraggableScrollableSheetState extends ConsumerState elevation: 3.0, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))), margin: const EdgeInsets.symmetric(horizontal: 0), - child: CustomScrollView( - controller: scrollController, - slivers: [ - const SliverPersistentHeader(delegate: _DragHandleDelegate(), pinned: true), - if (widget.actions.isNotEmpty) - SliverToBoxAdapter( - child: Column( - children: [ - SizedBox( - height: 115, - child: ListView(shrinkWrap: true, scrollDirection: Axis.horizontal, children: widget.actions), + child: Column( + children: [ + Expanded( + child: CustomScrollView( + controller: scrollController, + slivers: [ + const SliverPersistentHeader(delegate: _DragHandleDelegate(), pinned: true), + if (widget.actions.isNotEmpty) + SliverToBoxAdapter( + child: Column( + children: [ + SizedBox( + height: 115, + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: widget.actions, + ), + ), + const Divider(indent: 16, endIndent: 16), + const SizedBox(height: 16), + ], + ), ), - const Divider(indent: 16, endIndent: 16), - const SizedBox(height: 16), - ], - ), + if (widget.slivers != null) ...widget.slivers!, + ], ), - if (widget.slivers != null) ...widget.slivers!, + ), + if (widget.footer != null) widget.footer!, ], ), ); diff --git a/mobile/lib/widgets/activities/activity_tile.dart b/mobile/lib/widgets/activities/activity_tile.dart index 4b66bd5eaf..e0cced0d22 100644 --- a/mobile/lib/widgets/activities/activity_tile.dart +++ b/mobile/lib/widgets/activities/activity_tile.dart @@ -9,8 +9,9 @@ import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; class ActivityTile extends HookConsumerWidget { final Activity activity; + final bool isBottomSheet; - const ActivityTile(this.activity, {super.key}); + const ActivityTile(this.activity, {super.key, this.isBottomSheet = false}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -18,21 +19,23 @@ class ActivityTile extends HookConsumerWidget { final isLike = activity.type == ActivityType.like; // Asset thumbnail is displayed when we are accessing activities from the album page // currentAssetProvider will not be set until we open the gallery viewer - final showAssetThumbnail = asset == null && activity.assetId != null; + final showAssetThumbnail = asset == null && activity.assetId != null && !isBottomSheet; return ListTile( minVerticalPadding: 15, leading: isLike ? Container( - width: 44, + width: isBottomSheet ? 30 : 44, alignment: Alignment.center, child: Icon(Icons.favorite_rounded, color: Colors.red[700]), ) + : isBottomSheet + ? UserCircleAvatar(user: activity.user, size: 30, radius: 15) : UserCircleAvatar(user: activity.user), title: _ActivityTitle( userName: activity.user.name, createdAt: activity.createdAt.timeAgo(), - leftAlign: isLike || showAssetThumbnail, + leftAlign: isBottomSheet ? false : (isLike || showAssetThumbnail), ), // No subtitle for like, so center title titleAlignment: !isLike ? ListTileTitleAlignment.top : ListTileTitleAlignment.center,