feat(mobile): chat-style album activities timeline (#23185)

* feat(mobile): open assetviewer via album activities page

* adjust ui behavior: keep current asset & disable initial forcus

* init of v2...

* refactoring...

* refactor: remove _DismissibleWrapper

* feat: initial scrolling to bottom

* refactor: use feature toggle

* refactor: new route page

* fix: file name, dcm analyze

* fix: test failure

* fix: remove toggle and the exisitng style based on review feedback

* refactorr: rename methods for clarity in comment bubble widget

* chore: styling

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
idubnori 2025-10-29 22:45:28 +09:00 committed by GitHub
parent 4ae7cadeae
commit 5e6087ea28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 169 additions and 46 deletions

View file

@ -1,5 +1,4 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -7,15 +6,18 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.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/activity.provider.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_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';
import 'package:immich_mobile/providers/user.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 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage() @RoutePage()
class DriftActivitiesPage extends HookConsumerWidget { class DriftActivitiesPage extends HookConsumerWidget {
@ -25,19 +27,14 @@ class DriftActivitiesPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; final asset = ref.read(currentAssetNotifier) as RemoteAsset?;
final user = ref.watch(currentUserProvider);
final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier);
final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); final activities = ref.watch(albumActivityProvider(album.id, asset?.id));
final listViewScrollController = useScrollController(); final listViewScrollController = useScrollController();
void scrollToBottom() { void scrollToBottom() {
listViewScrollController.animateTo( listViewScrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.fastOutSlowIn);
listViewScrollController.position.maxScrollExtent + 80,
duration: const Duration(milliseconds: 600),
curve: Curves.fastOutSlowIn,
);
} }
Future<void> onAddComment(String comment) async { Future<void> onAddComment(String comment) async {
@ -55,33 +52,24 @@ class DriftActivitiesPage extends HookConsumerWidget {
), ),
body: activities.widgetWhen( body: activities.widgetWhen(
onData: (data) { onData: (data) {
final liked = data.firstWhereOrNull( final List<Widget> activityWidgets = [];
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id, for (final activity in data.reversed) {
); activityWidgets.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: _CommentBubble(activity: activity),
),
);
}
return SafeArea( return SafeArea(
child: Stack( child: Stack(
children: [ children: [
ListView.builder( ListView(
controller: listViewScrollController, controller: listViewScrollController,
itemCount: data.length + 1, padding: const EdgeInsets.only(top: 8, bottom: 80),
itemBuilder: (context, index) { reverse: true,
if (index == data.length) { children: activityWidgets,
return const SizedBox(height: 80);
}
final activity = data[index];
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
return Padding(
padding: const EdgeInsets.all(5),
child: DismissibleActivity(
activity.id,
ActivityTile(activity),
onDismiss: canDelete
? (activityId) async => await activityNotifier.removeActivity(activity.id)
: null,
),
);
},
), ),
Align( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
@ -90,11 +78,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
color: context.scaffoldBackgroundColor, color: context.scaffoldBackgroundColor,
border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)), border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)),
), ),
child: DriftActivityTextField( child: DriftActivityTextField(isEnabled: album.isActivityEnabled, onSubmit: onAddComment),
isEnabled: album.isActivityEnabled,
likeId: liked?.id,
onSubmit: onAddComment,
),
), ),
), ),
], ],
@ -107,3 +91,139 @@ class DriftActivitiesPage extends HookConsumerWidget {
); );
} }
} }
class _CommentBubble extends ConsumerWidget {
final Activity activity;
const _CommentBubble({required this.activity});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(currentUserProvider);
final album = ref.watch(currentRemoteAlbumProvider)!;
final isOwn = activity.user.id == user?.id;
final canDelete = isOwn || album.ownerId == user?.id;
final hasAsset = activity.assetId != null && activity.assetId!.isNotEmpty;
final isLike = activity.type == ActivityType.like;
final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer;
final activityNotifier = ref.read(albumActivityProvider(album.id, activity.assetId).notifier);
Future<void> openAssetViewer() async {
final activityService = ref.read(activityServiceProvider);
final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref);
if (route != null) await context.pushRoute(route);
}
Widget avatar() {
if (isOwn) {
return const SizedBox.shrink();
}
return UserCircleAvatar(user: activity.user, size: 28, radius: 14);
}
Widget? thumbnail() {
if (!hasAsset) {
return null;
}
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150),
child: Stack(
children: [
GestureDetector(
onTap: openAssetViewer,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: Image(
image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!),
fit: BoxFit.cover,
),
),
),
if (isLike)
Positioned(
right: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle),
child: Icon(Icons.favorite, color: Colors.red[600], size: 18),
),
),
],
),
);
}
// Likes Album widget (for likes without asset)
Widget? likesToAlbum() {
if (!isLike || hasAsset) {
return null;
}
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle),
child: Icon(Icons.favorite, color: Colors.red[600], size: 18),
);
}
Widget? commentBubble() {
if (activity.comment == null || activity.comment!.isEmpty) {
return null;
}
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(12))),
child: Text(
activity.comment ?? '',
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurface),
),
),
);
}
// Combined content widgets
final List<Widget> contentChildren = [thumbnail(), likesToAlbum(), commentBubble()].whereType<Widget>().toList();
return DismissibleActivity(
onDismiss: canDelete ? (id) async => await activityNotifier.removeActivity(id) : null,
activity.id,
Align(
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOwn) ...[avatar(), const SizedBox(width: 8)],
// Content column
Column(
crossAxisAlignment: isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
...contentChildren.map((w) => Padding(padding: const EdgeInsets.only(bottom: 8.0), child: w)),
Text(
'${activity.user.name}${activity.createdAt.timeAgo()}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
if (isOwn) const SizedBox(width: 8),
],
),
),
),
),
);
}
}

View file

@ -5,13 +5,17 @@ import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
/// Wraps an [ActivityTile] and makes it dismissible /// Wraps an [ActivityTile] and makes it dismissible
class DismissibleActivity extends StatelessWidget { class DismissibleActivity extends StatelessWidget {
final String activityId; final String activityId;
final ActivityTile body; final Widget body;
final Function(String)? onDismiss; final Function(String)? onDismiss;
const DismissibleActivity(this.activityId, this.body, {this.onDismiss, super.key}); const DismissibleActivity(this.activityId, this.body, {this.onDismiss, super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (onDismiss == null) {
return body;
}
return Dismissible( return Dismissible(
key: Key(activityId), key: Key(activityId),
dismissThresholds: const {DismissDirection.horizontal: 0.7}, dismissThresholds: const {DismissDirection.horizontal: 0.7},

View file

@ -29,7 +29,10 @@ void main() {
}); });
testWidgets('Returns a Dismissible', (tester) async { testWidgets('Returns a Dismissible', (tester) async {
await tester.pumpConsumerWidget(DismissibleActivity('1', ActivityTile(activity)), overrides: overrides); await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity), onDismiss: (_) {}),
overrides: overrides,
);
expect(find.byType(Dismissible), findsOneWidget); expect(find.byType(Dismissible), findsOneWidget);
}); });
@ -81,20 +84,16 @@ void main() {
testWidgets('No delete dialog if onDismiss is not set', (tester) async { testWidgets('No delete dialog if onDismiss is not set', (tester) async {
await tester.pumpConsumerWidget(DismissibleActivity('1', ActivityTile(activity)), overrides: overrides); await tester.pumpConsumerWidget(DismissibleActivity('1', ActivityTile(activity)), overrides: overrides);
final dismissible = find.byType(Dismissible); // When onDismiss is not set, the widget should not be wrapped by a Dismissible
await tester.drag(dismissible, const Offset(500, 0)); expect(find.byType(Dismissible), findsNothing);
await tester.pumpAndSettle();
expect(find.byType(ConfirmDialog), findsNothing); expect(find.byType(ConfirmDialog), findsNothing);
}); });
testWidgets('No icon for background if onDismiss is not set', (tester) async { testWidgets('No icon for background if onDismiss is not set', (tester) async {
await tester.pumpConsumerWidget(DismissibleActivity('1', ActivityTile(activity)), overrides: overrides); await tester.pumpConsumerWidget(DismissibleActivity('1', ActivityTile(activity)), overrides: overrides);
final dismissible = find.byType(Dismissible); // No Dismissible should exist when onDismiss is not provided, so no delete icon either
await tester.drag(dismissible, const Offset(-500, 0)); expect(find.byType(Dismissible), findsNothing);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete_sweep_rounded), findsNothing); expect(find.byIcon(Icons.delete_sweep_rounded), findsNothing);
}); });
} }