diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index 2bc13c82cb..d8f8799f7d 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -4,11 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.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'; @@ -18,10 +15,6 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da 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'; -// activity_tile and dismissible_activity are no longer used in this page -import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; -import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; -import 'package:immich_mobile/providers/activity_service.provider.dart'; @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { @@ -29,12 +22,8 @@ class DriftActivitiesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - if (Store.get(StoreKey.betaActivitiesStyle, false)) { - return _BetaDriftActivities().build(context, ref); - } - final album = ref.watch(currentRemoteAlbumProvider)!; - 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); @@ -113,197 +102,3 @@ class DriftActivitiesPage extends HookConsumerWidget { ); } } - -class _BetaDriftActivities { - Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(currentRemoteAlbumProvider)!; - final asset = ref.read(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)); - final listViewScrollController = useScrollController(); - - void scrollToBottom() { - listViewScrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.fastOutSlowIn); - } - - Future onAddComment(String comment) async { - await activityNotifier.addComment(comment); - scrollToBottom(); - } - - return Scaffold( - appBar: AppBar( - title: asset == null ? Text(album.name) : null, - actions: [const LikeActivityActionButton(menuItem: true)], - actionsPadding: const EdgeInsets.only(right: 8), - ), - body: activities.widgetWhen( - onData: (data) { - final List activityWidgets = []; - for (final activity in data.reversed) { - activityWidgets.add( - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: _CommentBubble(activity: activity), - ), - ); - } - - // Ensure initial render scrolls to bottom so newest activities are visible - // WidgetsBinding.instance.addPostFrameCallback((_) => scrollToBottom()); - - return SafeArea( - child: Stack( - children: [ - ListView( - controller: listViewScrollController, - padding: const EdgeInsets.only(top: 8, bottom: 80), - reverse: true, - children: activityWidgets, - ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - decoration: BoxDecoration( - color: context.scaffoldBackgroundColor, - border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)), - ), - child: DriftActivityTextField(isEnabled: album.isActivityEnabled, onSubmit: onAddComment), - ), - ), - ], - ), - ); - }, - ), - resizeToAvoidBottomInset: true, - ); - } -} - -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); - } - - // avatar (hidden for own messages) - Widget avatar() => isOwn ? const SizedBox.shrink() : UserCircleAvatar(user: activity.user, size: 28, radius: 14); - - // Thumbnail with tappable behavior and optional heart overlay - Widget? thumbnail() { - if (!hasAsset) return null; - return ConstrainedBox( - // constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5, maxHeight: 200), - constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150), - child: Stack( - children: [ - GestureDetector( - onTap: openAssetViewer, - child: ClipRRect( - borderRadius: BorderRadius.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), - ), - ), - ], - ), - ); - } - - // Heart-only widget (for likes without asset) - Widget? likedToAlbum() { - 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), - ); - } - - // Comment bubble; for comment-only we constrain width to ~50%. - 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: BorderRadius.circular(12)), - child: Text( - activity.comment ?? '', - style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurface), - ), - ), - ); - } - - // Combined content: optional thumbnail, optional heart-only, optional comment - final List contentChildren = [thumbnail(), likedToAlbum(), 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.labelSmall?.copyWith( - color: context.colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - if (isOwn) const SizedBox(width: 8), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/pages/drift_beta_activities.dart b/mobile/lib/presentation/pages/drift_beta_activities.dart new file mode 100644 index 0000000000..602ac10586 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_beta_activities.dart @@ -0,0 +1,212 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.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/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/dismissible_activity.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; + +@RoutePage() +class DriftBetaActivitiesPage extends HookConsumerWidget { + const DriftBetaActivitiesPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final album = ref.watch(currentRemoteAlbumProvider)!; + 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(0, duration: const Duration(milliseconds: 300), curve: Curves.fastOutSlowIn); + } + + Future onAddComment(String comment) async { + await activityNotifier.addComment(comment); + scrollToBottom(); + } + + return Scaffold( + appBar: AppBar( + title: asset == null ? Text(album.name) : null, + actions: [const LikeActivityActionButton(menuItem: true)], + actionsPadding: const EdgeInsets.only(right: 8), + ), + body: activities.widgetWhen( + onData: (data) { + 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( + controller: listViewScrollController, + padding: const EdgeInsets.only(top: 8, bottom: 80), + reverse: true, + children: activityWidgets, + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: context.scaffoldBackgroundColor, + border: Border(top: BorderSide(color: context.colorScheme.secondaryContainer, width: 1)), + ), + child: DriftActivityTextField(isEnabled: album.isActivityEnabled, onSubmit: onAddComment), + ), + ), + ], + ), + ); + }, + ), + resizeToAvoidBottomInset: true, + ); + } +} + +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); + } + + // avatar (hidden for own messages) + Widget avatar() => isOwn ? const SizedBox.shrink() : UserCircleAvatar(user: activity.user, size: 28, radius: 14); + + // Thumbnail with tappable behavior and optional heart overlay + Widget? thumbnail() { + if (!hasAsset) return null; + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150, maxHeight: 150), + child: Stack( + children: [ + GestureDetector( + onTap: openAssetViewer, + child: ClipRRect( + borderRadius: BorderRadius.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), + ), + ), + ], + ), + ); + } + + // Heart-only widget (for likes without asset) + Widget? likedToAlbum() { + 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), + ); + } + + // Comment bubble; for comment-only we constrain width to ~50%. + 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: BorderRadius.circular(12)), + child: Text( + activity.comment ?? '', + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurface), + ), + ), + ); + } + + // Combined content: optional thumbnail, optional heart-only, optional comment + final List contentChildren = [thumbnail(), likedToAlbum(), 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.labelSmall?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + if (isOwn) const SizedBox(width: 8), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index 23d82dcb92..9a97567f24 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -4,6 +4,8 @@ import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart'; @@ -166,6 +168,10 @@ class _RemoteAlbumPageState extends ConsumerState { } Future showActivity(BuildContext context) async { + if (Store.get(StoreKey.betaActivitiesStyle, false)) { + context.pushRoute(const DriftBetaActivitiesRoute()); + return; + } context.pushRoute(const DriftActivitiesRoute()); } diff --git a/mobile/lib/providers/activity_service.provider.g.dart b/mobile/lib/providers/activity_service.provider.g.dart index 2bf160c487..4641738fc4 100644 --- a/mobile/lib/providers/activity_service.provider.g.dart +++ b/mobile/lib/providers/activity_service.provider.g.dart @@ -6,7 +6,7 @@ part of 'activity_service.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$activityServiceHash() => r'ce775779787588defe1e76406e09a9c109470310'; +String _$activityServiceHash() => r'3ce0eb33948138057cc63f07a7598047b99e7599'; /// See also [activityService]. @ProviderFor(activityService) diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 7554c7b1cf..3a73ebb101 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -78,6 +78,7 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/settings/sync_status.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_beta_activities.dart'; import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; @@ -344,6 +345,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftCropImageRoute.page), AutoRoute(page: DriftFilterImageRoute.page), AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: DriftBetaActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 4e488a30c7..c052423bd2 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -881,6 +881,22 @@ class DriftBackupRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftBetaActivitiesPage] +class DriftBetaActivitiesRoute extends PageRouteInfo { + const DriftBetaActivitiesRoute({List? children}) + : super(DriftBetaActivitiesRoute.name, initialChildren: children); + + static const String name = 'DriftBetaActivitiesRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftBetaActivitiesPage(); + }, + ); +} + /// generated route for /// [DriftCreateAlbumPage] class DriftCreateAlbumRoute extends PageRouteInfo {