feat(mobile): Change the UI of asset activity list to bottom sheet (#23075)

* init of activities bottom sheet

* reverse list order, padding bottom...

* chore: remove scrolling

* chore: clean up

* chore

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
idubnori 2025-10-21 03:35:52 +09:00 committed by GitHub
parent 05f174a180
commit becb56e1b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 243 additions and 27 deletions

View file

@ -7,6 +7,7 @@ import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class DriftActivityTextField extends ConsumerStatefulWidget { class DriftActivityTextField extends ConsumerStatefulWidget {
final bool isEnabled; final bool isEnabled;
final bool isBottomSheet;
final String? likeId; final String? likeId;
final Function(String) onSubmit; final Function(String) onSubmit;
final Function()? onKeyboardFocus; final Function()? onKeyboardFocus;
@ -16,6 +17,7 @@ class DriftActivityTextField extends ConsumerStatefulWidget {
this.isEnabled = true, this.isEnabled = true,
this.likeId, this.likeId,
this.onKeyboardFocus, this.onKeyboardFocus,
this.isBottomSheet = false,
super.key, super.key,
}); });
@ -34,7 +36,9 @@ class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField>
inputController = TextEditingController(); inputController = TextEditingController();
inputFocusNode = FocusNode(); inputFocusNode = FocusNode();
if (!widget.isBottomSheet) {
inputFocusNode.requestFocus(); inputFocusNode.requestFocus();
}
inputFocusNode.addListener(() { inputFocusNode.addListener(() {
if (inputFocusNode.hasFocus) { if (inputFocusNode.hasFocus) {
@ -72,7 +76,7 @@ class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField>
} }
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 10), padding: EdgeInsets.symmetric(vertical: widget.isBottomSheet ? 0 : 10),
child: TextField( child: TextField(
controller: inputController, controller: inputController,
enabled: widget.isEnabled, enabled: widget.isEnabled,

View file

@ -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<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];
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,
);
}
}

View file

@ -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/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/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/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/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
@ -418,7 +419,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (event is ViewerOpenBottomSheetEvent) { if (event is ViewerOpenBottomSheetEvent) {
final extent = _kBottomSheetMinimumExtent + 0.3; final extent = _kBottomSheetMinimumExtent + 0.3;
_openBottomSheet(scaffoldContext!, extent: extent); _openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode);
final offset = _getVerticalOffsetForBottomSheet(extent); final offset = _getVerticalOffsetForBottomSheet(extent);
viewController?.position = Offset(0, -offset); viewController?.position = Offset(0, -offset);
return; return;
@ -460,7 +461,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}); });
} }
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) { void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true); ref.read(assetViewerProvider.notifier).setBottomSheet(true);
initialScale = viewController?.scale; initialScale = viewController?.scale;
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01); // viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
@ -474,7 +475,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
builder: (_) { builder: (_) {
return NotificationListener<Notification>( return NotificationListener<Notification>(
onNotification: _onNotification, onNotification: _onNotification,
child: AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent), child: activitiesMode
? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent)
: AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent),
); );
}, },
); );

View file

@ -4,7 +4,8 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
class ViewerOpenBottomSheetEvent extends Event { class ViewerOpenBottomSheetEvent extends Event {
const ViewerOpenBottomSheetEvent(); final bool activitiesMode;
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
} }
class ViewerReloadAssetEvent extends Event { class ViewerReloadAssetEvent extends Event {

View file

@ -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/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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)); int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
ref.watch(albumActivityProvider(album.id, asset.id));
}
if (!showControls) { if (!showControls) {
opacity = 0; opacity = 0;
} }
@ -66,7 +71,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
IconButton( IconButton(
icon: const Icon(Icons.chat_outlined), icon: const Icon(Icons.chat_outlined),
onPressed: () { onPressed: () {
context.navigateTo(const DriftActivitiesRoute()); EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
}, },
), ),
if (showViewInTimelineButton) if (showViewInTimelineButton)

View file

@ -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<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(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,
);
}
}

View file

@ -8,6 +8,7 @@ class BaseBottomSheet extends ConsumerStatefulWidget {
final List<Widget> actions; final List<Widget> actions;
final DraggableScrollableController? controller; final DraggableScrollableController? controller;
final List<Widget>? slivers; final List<Widget>? slivers;
final Widget? footer;
final double initialChildSize; final double initialChildSize;
final double minChildSize; final double minChildSize;
final double maxChildSize; final double maxChildSize;
@ -20,6 +21,7 @@ class BaseBottomSheet extends ConsumerStatefulWidget {
super.key, super.key,
required this.actions, required this.actions,
this.slivers, this.slivers,
this.footer,
this.controller, this.controller,
this.initialChildSize = 0.35, this.initialChildSize = 0.35,
double? minChildSize, double? minChildSize,
@ -73,6 +75,9 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
elevation: 3.0, elevation: 3.0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
margin: const EdgeInsets.symmetric(horizontal: 0), margin: const EdgeInsets.symmetric(horizontal: 0),
child: Column(
children: [
Expanded(
child: CustomScrollView( child: CustomScrollView(
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
@ -83,7 +88,11 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
children: [ children: [
SizedBox( SizedBox(
height: 115, height: 115,
child: ListView(shrinkWrap: true, scrollDirection: Axis.horizontal, children: widget.actions), child: ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
children: widget.actions,
),
), ),
const Divider(indent: 16, endIndent: 16), const Divider(indent: 16, endIndent: 16),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -93,6 +102,10 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
if (widget.slivers != null) ...widget.slivers!, if (widget.slivers != null) ...widget.slivers!,
], ],
), ),
),
if (widget.footer != null) widget.footer!,
],
),
); );
}, },
); );

View file

@ -9,8 +9,9 @@ import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class ActivityTile extends HookConsumerWidget { class ActivityTile extends HookConsumerWidget {
final Activity activity; final Activity activity;
final bool isBottomSheet;
const ActivityTile(this.activity, {super.key}); const ActivityTile(this.activity, {super.key, this.isBottomSheet = false});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -18,21 +19,23 @@ class ActivityTile extends HookConsumerWidget {
final isLike = activity.type == ActivityType.like; final isLike = activity.type == ActivityType.like;
// Asset thumbnail is displayed when we are accessing activities from the album page // Asset thumbnail is displayed when we are accessing activities from the album page
// currentAssetProvider will not be set until we open the gallery viewer // 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( return ListTile(
minVerticalPadding: 15, minVerticalPadding: 15,
leading: isLike leading: isLike
? Container( ? Container(
width: 44, width: isBottomSheet ? 30 : 44,
alignment: Alignment.center, alignment: Alignment.center,
child: Icon(Icons.favorite_rounded, color: Colors.red[700]), child: Icon(Icons.favorite_rounded, color: Colors.red[700]),
) )
: isBottomSheet
? UserCircleAvatar(user: activity.user, size: 30, radius: 15)
: UserCircleAvatar(user: activity.user), : UserCircleAvatar(user: activity.user),
title: _ActivityTitle( title: _ActivityTitle(
userName: activity.user.name, userName: activity.user.name,
createdAt: activity.createdAt.timeAgo(), createdAt: activity.createdAt.timeAgo(),
leftAlign: isLike || showAssetThumbnail, leftAlign: isBottomSheet ? false : (isLike || showAssetThumbnail),
), ),
// No subtitle for like, so center title // No subtitle for like, so center title
titleAlignment: !isLike ? ListTileTitleAlignment.top : ListTileTitleAlignment.center, titleAlignment: !isLike ? ListTileTitleAlignment.top : ListTileTitleAlignment.center,