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 {
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<DriftActivityTextField>
inputController = TextEditingController();
inputFocusNode = FocusNode();
inputFocusNode.requestFocus();
if (!widget.isBottomSheet) {
inputFocusNode.requestFocus();
}
inputFocusNode.addListener(() {
if (inputFocusNode.hasFocus) {
@ -72,7 +76,7 @@ class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField>
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
padding: EdgeInsets.symmetric(vertical: widget.isBottomSheet ? 0 : 10),
child: TextField(
controller: inputController,
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/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<AssetViewer> {
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<AssetViewer> {
});
}
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<AssetViewer> {
builder: (_) {
return NotificationListener<Notification>(
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';
class ViewerOpenBottomSheetEvent extends Event {
const ViewerOpenBottomSheetEvent();
final bool activitiesMode;
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
}
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/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)

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 DraggableScrollableController? controller;
final List<Widget>? 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<BaseBottomSheet>
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!,
],
),
);

View file

@ -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,