diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index 731bcb5dba..30e7dd497a 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -1,5 +1,4 @@ import 'package:auto_route/auto_route.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; 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/extensions/asyncvalue_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/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/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/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 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { @@ -25,19 +27,14 @@ class DriftActivitiesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; - final user = ref.watch(currentUserProvider); + final asset = ref.read(currentAssetNotifier) as RemoteAsset?; final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); final listViewScrollController = useScrollController(); void scrollToBottom() { - listViewScrollController.animateTo( - listViewScrollController.position.maxScrollExtent + 80, - duration: const Duration(milliseconds: 600), - curve: Curves.fastOutSlowIn, - ); + listViewScrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.fastOutSlowIn); } Future onAddComment(String comment) async { @@ -55,33 +52,24 @@ class DriftActivitiesPage extends HookConsumerWidget { ), body: activities.widgetWhen( onData: (data) { - final liked = data.firstWhereOrNull( - (a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.id, - ); + final List activityWidgets = []; + for (final activity in data.reversed) { + activityWidgets.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: _CommentBubble(activity: activity), + ), + ); + } return SafeArea( child: Stack( children: [ - ListView.builder( + ListView( controller: listViewScrollController, - itemCount: data.length + 1, - itemBuilder: (context, index) { - if (index == data.length) { - 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, - ), - ); - }, + padding: const EdgeInsets.only(top: 8, bottom: 80), + reverse: true, + children: activityWidgets, ), Align( alignment: Alignment.bottomCenter, @@ -90,11 +78,7 @@ class DriftActivitiesPage extends HookConsumerWidget { color: context.scaffoldBackgroundColor, border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)), ), - child: DriftActivityTextField( - isEnabled: album.isActivityEnabled, - likeId: liked?.id, - onSubmit: onAddComment, - ), + child: DriftActivityTextField(isEnabled: album.isActivityEnabled, 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 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 contentChildren = [thumbnail(), likesToAlbum(), commentBubble()].whereType().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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/activities/dismissible_activity.dart b/mobile/lib/widgets/activities/dismissible_activity.dart index 2f017d51ed..806181ecdc 100644 --- a/mobile/lib/widgets/activities/dismissible_activity.dart +++ b/mobile/lib/widgets/activities/dismissible_activity.dart @@ -5,13 +5,17 @@ import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; /// Wraps an [ActivityTile] and makes it dismissible class DismissibleActivity extends StatelessWidget { final String activityId; - final ActivityTile body; + final Widget body; final Function(String)? onDismiss; const DismissibleActivity(this.activityId, this.body, {this.onDismiss, super.key}); @override Widget build(BuildContext context) { + if (onDismiss == null) { + return body; + } + return Dismissible( key: Key(activityId), dismissThresholds: const {DismissDirection.horizontal: 0.7}, diff --git a/mobile/test/modules/activity/dismissible_activity_test.dart b/mobile/test/modules/activity/dismissible_activity_test.dart index e5f6258ee9..32516e73ea 100644 --- a/mobile/test/modules/activity/dismissible_activity_test.dart +++ b/mobile/test/modules/activity/dismissible_activity_test.dart @@ -29,7 +29,10 @@ void main() { }); 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); }); @@ -81,20 +84,16 @@ void main() { testWidgets('No delete dialog if onDismiss is not set', (tester) async { await tester.pumpConsumerWidget(DismissibleActivity('1', ActivityTile(activity)), overrides: overrides); - final dismissible = find.byType(Dismissible); - await tester.drag(dismissible, const Offset(500, 0)); - await tester.pumpAndSettle(); - + // When onDismiss is not set, the widget should not be wrapped by a Dismissible + expect(find.byType(Dismissible), findsNothing); expect(find.byType(ConfirmDialog), findsNothing); }); testWidgets('No icon for background if onDismiss is not set', (tester) async { await tester.pumpConsumerWidget(DismissibleActivity('1', ActivityTile(activity)), overrides: overrides); - final dismissible = find.byType(Dismissible); - await tester.drag(dismissible, const Offset(-500, 0)); - await tester.pumpAndSettle(); - + // No Dismissible should exist when onDismiss is not provided, so no delete icon either + expect(find.byType(Dismissible), findsNothing); expect(find.byIcon(Icons.delete_sweep_rounded), findsNothing); }); }