diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 69700269ce..f710bec06d 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -373,5 +373,8 @@ "viewer_stack_use_as_main_asset": "Use as Main Asset", "app_bar_signout_dialog_title": "Sign out", "app_bar_signout_dialog_content": "Are you sure you wanna sign out?", - "app_bar_signout_dialog_ok": "Yes" + "app_bar_signout_dialog_ok": "Yes", + "shared_album_activities_input_hint": "Say something", + "shared_album_activity_remove_title": "Delete Activity", + "shared_album_activity_remove_content": "Do you want to delete this activity?" } diff --git a/mobile/lib/modules/activities/models/activity.model.dart b/mobile/lib/modules/activities/models/activity.model.dart new file mode 100644 index 0000000000..417ba4a863 --- /dev/null +++ b/mobile/lib/modules/activities/models/activity.model.dart @@ -0,0 +1,90 @@ +import 'package:immich_mobile/shared/models/user.dart'; +import 'package:openapi/api.dart'; + +enum ActivityType { comment, like } + +class Activity { + final String id; + final String? assetId; + final String? comment; + final DateTime createdAt; + final ActivityType type; + final User user; + + const Activity({ + required this.id, + this.assetId, + this.comment, + required this.createdAt, + required this.type, + required this.user, + }); + + Activity copyWith({ + String? id, + String? assetId, + String? comment, + DateTime? createdAt, + ActivityType? type, + User? user, + }) { + return Activity( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + comment: comment ?? this.comment, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + user: user ?? this.user, + ); + } + + Activity.fromDto(ActivityResponseDto dto) + : id = dto.id, + assetId = dto.assetId, + comment = dto.comment, + createdAt = dto.createdAt, + type = dto.type == ActivityResponseDtoTypeEnum.comment + ? ActivityType.comment + : ActivityType.like, + user = User( + email: dto.user.email, + firstName: dto.user.firstName, + lastName: dto.user.lastName, + profileImagePath: dto.user.profileImagePath, + id: dto.user.id, + // Placeholder values + isAdmin: false, + updatedAt: DateTime.now(), + isPartnerSharedBy: false, + isPartnerSharedWith: false, + memoryEnabled: false, + ); + + @override + String toString() { + return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Activity && + other.id == id && + other.assetId == assetId && + other.comment == comment && + other.createdAt == createdAt && + other.type == type && + other.user == user; + } + + @override + int get hashCode { + return id.hashCode ^ + assetId.hashCode ^ + comment.hashCode ^ + createdAt.hashCode ^ + type.hashCode ^ + user.hashCode; + } +} diff --git a/mobile/lib/modules/activities/providers/activity.provider.dart b/mobile/lib/modules/activities/providers/activity.provider.dart new file mode 100644 index 0000000000..c0fa5e628f --- /dev/null +++ b/mobile/lib/modules/activities/providers/activity.provider.dart @@ -0,0 +1,130 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/models/activity.model.dart'; +import 'package:immich_mobile/modules/activities/services/activity.service.dart'; + +class ActivityNotifier extends StateNotifier>> { + final Ref _ref; + final ActivityService _activityService; + final String albumId; + final String? assetId; + + ActivityNotifier( + this._ref, + this._activityService, + this.albumId, + this.assetId, + ) : super( + const AsyncData([]), + ) { + fetchActivity(); + } + + Future fetchActivity() async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => _activityService.getAllActivities(albumId, assetId), + ); + } + + Future removeActivity(String id) async { + final activities = state.asData?.value ?? []; + if (await _activityService.removeActivity(id)) { + final removedActivity = activities.firstWhere((a) => a.id == id); + activities.remove(removedActivity); + state = AsyncData(activities); + if (removedActivity.type == ActivityType.comment) { + _ref + .read( + activityStatisticsStateProvider( + (albumId: albumId, assetId: assetId), + ).notifier, + ) + .removeActivity(); + } + } + } + + Future addComment(String comment) async { + final activity = await _activityService.addActivity( + albumId, + ActivityType.comment, + assetId: assetId, + comment: comment, + ); + + if (activity != null) { + final activities = state.asData?.value ?? []; + state = AsyncData([...activities, activity]); + _ref + .read( + activityStatisticsStateProvider( + (albumId: albumId, assetId: assetId), + ).notifier, + ) + .addActivity(); + if (assetId != null) { + // Add a count to the current album's provider as well + _ref + .read( + activityStatisticsStateProvider( + (albumId: albumId, assetId: null), + ).notifier, + ) + .addActivity(); + } + } + } + + Future addLike() async { + final activity = await _activityService + .addActivity(albumId, ActivityType.like, assetId: assetId); + if (activity != null) { + final activities = state.asData?.value ?? []; + state = AsyncData([...activities, activity]); + } + } +} + +class ActivityStatisticsNotifier extends StateNotifier { + final String albumId; + final String? assetId; + final ActivityService _activityService; + ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId) + : super(0) { + fetchStatistics(); + } + + Future fetchStatistics() async { + state = await _activityService.getStatistics(albumId, assetId: assetId); + } + + Future addActivity() async { + state = state + 1; + } + + Future removeActivity() async { + state = state - 1; + } +} + +typedef ActivityParams = ({String albumId, String? assetId}); + +final activityStateProvider = StateNotifierProvider.autoDispose + .family>, ActivityParams>( + (ref, args) { + return ActivityNotifier( + ref, + ref.watch(activityServiceProvider), + args.albumId, + args.assetId, + ); +}); + +final activityStatisticsStateProvider = StateNotifierProvider.autoDispose + .family((ref, args) { + return ActivityStatisticsNotifier( + ref.watch(activityServiceProvider), + args.albumId, + args.assetId, + ); +}); diff --git a/mobile/lib/modules/activities/services/activity.service.dart b/mobile/lib/modules/activities/services/activity.service.dart new file mode 100644 index 0000000000..fce77a1963 --- /dev/null +++ b/mobile/lib/modules/activities/services/activity.service.dart @@ -0,0 +1,85 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/models/activity.model.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; + +final activityServiceProvider = + Provider((ref) => ActivityService(ref.watch(apiServiceProvider))); + +class ActivityService { + final ApiService _apiService; + final Logger _log = Logger("ActivityService"); + + ActivityService(this._apiService); + + Future> getAllActivities( + String albumId, + String? assetId, + ) async { + try { + final list = await _apiService.activityApi + .getActivities(albumId, assetId: assetId); + return list != null ? list.map(Activity.fromDto).toList() : []; + } catch (e) { + _log.severe( + "failed to fetch activities for albumId - $albumId; assetId - $assetId -> $e", + ); + rethrow; + } + } + + Future getStatistics(String albumId, {String? assetId}) async { + try { + final dto = await _apiService.activityApi + .getActivityStatistics(albumId, assetId: assetId); + return dto?.comments ?? 0; + } catch (e) { + _log.severe( + "failed to fetch activity statistics for albumId - $albumId; assetId - $assetId -> $e", + ); + } + return 0; + } + + Future removeActivity(String id) async { + try { + await _apiService.activityApi.deleteActivity(id); + return true; + } catch (e) { + _log.severe( + "failed to remove activity id - $id -> $e", + ); + } + return false; + } + + Future addActivity( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }) async { + try { + final dto = await _apiService.activityApi.createActivity( + ActivityCreateDto( + albumId: albumId, + type: type == ActivityType.comment + ? ReactionType.comment + : ReactionType.like, + assetId: assetId, + comment: comment, + ), + ); + if (dto != null) { + return Activity.fromDto(dto); + } + } catch (e) { + _log.severe( + "failed to add activity for albumId - $albumId; assetId - $assetId -> $e", + ); + } + return null; + } +} diff --git a/mobile/lib/modules/activities/views/activities_page.dart b/mobile/lib/modules/activities/views/activities_page.dart new file mode 100644 index 0000000000..69afe2e5da --- /dev/null +++ b/mobile/lib/modules/activities/views/activities_page.dart @@ -0,0 +1,312 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +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/modules/activities/models/activity.model.dart'; +import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; +import 'package:immich_mobile/utils/datetime_extensions.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class ActivitiesPage extends HookConsumerWidget { + final String albumId; + final String? assetId; + final bool withAssetThumbs; + final String appBarTitle; + final bool isOwner; + const ActivitiesPage( + this.albumId, { + this.appBarTitle = "", + this.assetId, + this.withAssetThumbs = true, + this.isOwner = false, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = + activityStateProvider((albumId: albumId, assetId: assetId)); + final activities = ref.watch(provider); + final inputController = useTextEditingController(); + final inputFocusNode = useFocusNode(); + final listViewScrollController = useScrollController(); + final currentUser = Store.tryGet(StoreKey.currentUser); + + useEffect( + () { + inputFocusNode.requestFocus(); + return null; + }, + [], + ); + buildTitleWithTimestamp(Activity activity, {bool leftAlign = true}) { + final textColor = Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black; + final textStyle = Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: textColor.withOpacity(0.6)); + + return Row( + mainAxisAlignment: leftAlign + ? MainAxisAlignment.start + : MainAxisAlignment.spaceBetween, + mainAxisSize: leftAlign ? MainAxisSize.min : MainAxisSize.max, + children: [ + Text( + "${activity.user.firstName} ${activity.user.lastName}", + style: textStyle, + overflow: TextOverflow.ellipsis, + ), + if (leftAlign) + Text( + " • ", + style: textStyle, + ), + Expanded( + child: Text( + activity.createdAt.copyWith().timeAgo(), + style: textStyle, + overflow: TextOverflow.ellipsis, + textAlign: leftAlign ? TextAlign.left : TextAlign.right, + ), + ), + ], + ); + } + + buildAssetThumbnail(Activity activity) { + return withAssetThumbs && activity.assetId != null + ? Container( + width: 40, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + image: DecorationImage( + image: CachedNetworkImageProvider( + getThumbnailUrlForRemoteId( + activity.assetId!, + ), + cacheKey: getThumbnailCacheKeyForRemoteId( + activity.assetId!, + ), + headers: { + "Authorization": + 'Bearer ${Store.get(StoreKey.accessToken)}', + }, + ), + fit: BoxFit.cover, + ), + ), + child: const SizedBox.shrink(), + ) + : null; + } + + buildTextField(String? likedId) { + final liked = likedId != null; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: inputController, + focusNode: inputFocusNode, + textInputAction: TextInputAction.send, + autofocus: false, + decoration: InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + prefixIcon: currentUser != null + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: UserCircleAvatar( + user: currentUser, + size: 30, + radius: 15, + ), + ) + : null, + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 10), + child: IconButton( + icon: Icon( + liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + ), + onPressed: () async { + liked + ? await ref + .read(provider.notifier) + .removeActivity(likedId) + : await ref.read(provider.notifier).addLike(); + }, + ), + ), + suffixIconColor: liked ? Colors.red[700] : null, + hintText: 'shared_album_activities_input_hint'.tr(), + hintStyle: TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + color: Colors.grey[600], + ), + ), + onEditingComplete: () async { + await ref.read(provider.notifier).addComment(inputController.text); + inputController.clear(); + inputFocusNode.unfocus(); + listViewScrollController.animateTo( + listViewScrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 800), + curve: Curves.fastOutSlowIn, + ); + }, + onTapOutside: (_) => inputFocusNode.unfocus(), + ), + ); + } + + getDismissibleWidget( + Widget widget, + Activity activity, + bool canDelete, + ) { + return Dismissible( + key: Key(activity.id), + dismissThresholds: const { + DismissDirection.horizontal: 0.7, + }, + direction: DismissDirection.horizontal, + confirmDismiss: (direction) => canDelete + ? showDialog( + context: context, + builder: (context) => ConfirmDialog( + onOk: () {}, + title: "shared_album_activity_remove_title", + content: "shared_album_activity_remove_content", + ok: "delete_dialog_ok", + ), + ) + : Future.value(false), + onDismissed: (direction) async => + await ref.read(provider.notifier).removeActivity(activity.id), + background: Container( + color: canDelete ? Colors.red[400] : Colors.grey[600], + alignment: AlignmentDirectional.centerStart, + child: canDelete + ? const Padding( + padding: EdgeInsets.all(15), + child: Icon( + Icons.delete_sweep_rounded, + color: Colors.black, + ), + ) + : null, + ), + secondaryBackground: Container( + color: canDelete ? Colors.red[400] : Colors.grey[600], + alignment: AlignmentDirectional.centerEnd, + child: canDelete + ? const Padding( + padding: EdgeInsets.all(15), + child: Icon( + Icons.delete_sweep_rounded, + color: Colors.black, + ), + ) + : null, + ), + child: widget, + ); + } + + return Scaffold( + appBar: AppBar(title: Text(appBarTitle)), + body: activities.maybeWhen( + orElse: () { + return const Center(child: ImmichLoadingIndicator()); + }, + data: (data) { + final liked = data.firstWhereOrNull( + (a) => + a.type == ActivityType.like && + a.user.id == currentUser?.id && + a.assetId == assetId, + ); + + return Stack( + children: [ + ListView.builder( + controller: listViewScrollController, + itemCount: data.length + 1, + itemBuilder: (context, index) { + // Vertical gap after the last element + if (index == data.length) { + return const SizedBox( + height: 80, + ); + } + + final activity = data[index]; + final canDelete = + activity.user.id == currentUser?.id || isOwner; + + return Padding( + padding: const EdgeInsets.all(5), + child: activity.type == ActivityType.comment + ? getDismissibleWidget( + ListTile( + minVerticalPadding: 15, + leading: UserCircleAvatar(user: activity.user), + title: buildTitleWithTimestamp( + activity, + leftAlign: + withAssetThumbs && activity.assetId != null, + ), + titleAlignment: ListTileTitleAlignment.top, + trailing: buildAssetThumbnail(activity), + subtitle: Text(activity.comment!), + ), + activity, + canDelete, + ) + : getDismissibleWidget( + ListTile( + minVerticalPadding: 15, + leading: Container( + width: 44, + alignment: Alignment.center, + child: Icon( + Icons.favorite_rounded, + color: Colors.red[700], + ), + ), + title: buildTitleWithTimestamp(activity), + trailing: buildAssetThumbnail(activity), + ), + activity, + canDelete, + ), + ); + }, + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: buildTextField(liked?.id), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index 221603ed90..05db82e108 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; @@ -26,6 +27,7 @@ class AlbumViewerAppbar extends HookConsumerWidget required this.titleFocusNode, this.onAddPhotos, this.onAddUsers, + required this.onActivities, }) : super(key: key); final Album album; @@ -35,11 +37,19 @@ class AlbumViewerAppbar extends HookConsumerWidget final FocusNode titleFocusNode; final Function(Album album)? onAddPhotos; final Function(Album album)? onAddUsers; + final Function(Album album) onActivities; @override Widget build(BuildContext context, WidgetRef ref) { final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; + final comments = album.shared + ? ref.watch( + activityStatisticsStateProvider( + (albumId: album.remoteId!, assetId: null), + ), + ) + : 0; deleteAlbum() async { ImmichLoadingOverlayController.appLoader.show(); @@ -310,6 +320,33 @@ class AlbumViewerAppbar extends HookConsumerWidget ); } + Widget buildActivitiesButton() { + return IconButton( + onPressed: () { + onActivities(album); + }, + icon: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.mode_comment_outlined, + ), + if (comments != 0) + Padding( + padding: const EdgeInsets.only(left: 5), + child: Text( + comments.toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + ), + ], + ), + ); + } + buildLeadingButton() { if (selected.isNotEmpty) { return IconButton( @@ -353,6 +390,7 @@ class AlbumViewerAppbar extends HookConsumerWidget title: selected.isNotEmpty ? Text('${selected.length}') : null, centerTitle: false, actions: [ + if (album.shared) buildActivitiesButton(), if (album.isRemote) IconButton( splashRadius: 25, diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index 12ef332f4a..dc30b3718e 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -232,6 +232,18 @@ class AlbumViewerPage extends HookConsumerWidget { ); } + onActivitiesPressed(Album album) { + if (album.remoteId != null) { + AutoRouter.of(context).push( + ActivitiesRoute( + albumId: album.remoteId!, + appBarTitle: album.name, + isOwner: userId == album.ownerId, + ), + ); + } + } + return Scaffold( appBar: album.when( data: (data) => AlbumViewerAppbar( @@ -242,6 +254,7 @@ class AlbumViewerPage extends HookConsumerWidget { selectionDisabled: disableSelection, onAddPhotos: onAddPhotosPressed, onAddUsers: onAddUsersPressed, + onActivities: onActivitiesPressed, ), error: (error, stackTrace) => AppBar(title: const Text("Error")), loading: () => AppBar(), @@ -266,6 +279,7 @@ class AlbumViewerPage extends HookConsumerWidget { ], ), isOwner: userId == data.ownerId, + sharedAlbumId: data.remoteId, ), ), ), diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 69d2be9f1b..95965f6d87 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; @@ -16,6 +17,8 @@ class TopControlAppBar extends HookConsumerWidget { required this.onFavorite, required this.onUploadPressed, required this.isOwner, + required this.shareAlbumId, + required this.onActivitiesPressed, }) : super(key: key); final Asset asset; @@ -24,14 +27,23 @@ class TopControlAppBar extends HookConsumerWidget { final VoidCallback? onDownloadPressed; final VoidCallback onToggleMotionVideo; final VoidCallback onAddToAlbumPressed; + final VoidCallback onActivitiesPressed; final Function(Asset) onFavorite; final bool isPlayingMotionVideo; final bool isOwner; + final String? shareAlbumId; @override Widget build(BuildContext context, WidgetRef ref) { const double iconSize = 22.0; final a = ref.watch(assetWatcher(asset)).value ?? asset; + final comments = shareAlbumId != null + ? ref.watch( + activityStatisticsStateProvider( + (albumId: shareAlbumId!, assetId: asset.remoteId), + ), + ) + : 0; Widget buildFavoriteButton(a) { return IconButton( @@ -94,6 +106,34 @@ class TopControlAppBar extends HookConsumerWidget { ); } + Widget buildActivitiesButton() { + return IconButton( + onPressed: () { + onActivitiesPressed(); + }, + icon: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.mode_comment_outlined, + color: Colors.grey[200], + ), + if (comments != 0) + Padding( + padding: const EdgeInsets.only(left: 5), + child: Text( + comments.toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[200], + ), + ), + ), + ], + ), + ); + } + Widget buildUploadButton() { return IconButton( onPressed: onUploadPressed, @@ -130,6 +170,7 @@ class TopControlAppBar extends HookConsumerWidget { if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && isOwner) buildAddToAlbumButtom(), + if (shareAlbumId != null) buildActivitiesButton(), buildMoreInfoButton(), ], ); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 6a355a64ef..f8acef5888 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -49,6 +49,7 @@ class GalleryViewerPage extends HookConsumerWidget { final int heroOffset; final bool showStack; final bool isOwner; + final String? sharedAlbumId; GalleryViewerPage({ super.key, @@ -58,6 +59,7 @@ class GalleryViewerPage extends HookConsumerWidget { this.heroOffset = 0, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, }) : controller = PageController(initialPage: initialIndex); final PageController controller; @@ -327,6 +329,19 @@ class GalleryViewerPage extends HookConsumerWidget { ); } + handleActivities() { + if (sharedAlbumId != null) { + AutoRouter.of(context).push( + ActivitiesRoute( + albumId: sharedAlbumId!, + assetId: asset().remoteId, + withAssetThumbs: false, + isOwner: isOwner, + ), + ); + } + } + buildAppBar() { return IgnorePointer( ignoring: !ref.watch(showControlsProvider), @@ -355,6 +370,8 @@ class GalleryViewerPage extends HookConsumerWidget { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), onAddToAlbumPressed: () => addToAlbum(asset()), + shareAlbumId: sharedAlbumId, + onActivitiesPressed: handleActivities, ), ), ), diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index 50f6f3f710..2c0f63394b 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -34,6 +34,7 @@ class ImmichAssetGrid extends HookConsumerWidget { final bool showDragScroll; final bool showStack; final bool isOwner; + final String? sharedAlbumId; const ImmichAssetGrid({ super.key, @@ -55,6 +56,7 @@ class ImmichAssetGrid extends HookConsumerWidget { this.showDragScroll = true, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, }); @override @@ -120,6 +122,7 @@ class ImmichAssetGrid extends HookConsumerWidget { showDragScroll: showDragScroll, showStack: showStack, isOwner: isOwner, + sharedAlbumId: sharedAlbumId, ), ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 822a6329af..3b900a5f14 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -39,6 +39,7 @@ class ImmichAssetGridView extends StatefulWidget { final bool showDragScroll; final bool showStack; final bool isOwner; + final String? sharedAlbumId; const ImmichAssetGridView({ super.key, @@ -60,6 +61,7 @@ class ImmichAssetGridView extends StatefulWidget { this.showDragScroll = true, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, }); @override @@ -141,6 +143,7 @@ class ImmichAssetGridViewState extends State { heroOffset: widget.heroOffset, showStack: widget.showStack, isOwner: widget.isOwner, + sharedAlbumId: widget.sharedAlbumId, ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 2450e44393..16423b3b49 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -21,6 +21,7 @@ class ThumbnailImage extends StatelessWidget { final Function? onSelect; final Function? onDeselect; final int heroOffset; + final String? sharedAlbumId; const ThumbnailImage({ Key? key, @@ -31,6 +32,7 @@ class ThumbnailImage extends StatelessWidget { this.showStorageIndicator = true, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, this.useGrayBoxPlaceholder = false, this.isSelected = false, this.multiselectEnabled = false, @@ -184,6 +186,7 @@ class ThumbnailImage extends StatelessWidget { heroOffset: heroOffset, showStack: showStack, isOwner: isOwner, + sharedAlbumId: sharedAlbumId, ), ); } diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index 1110555a3b..41984106df 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -172,7 +172,7 @@ class SearchPage extends HookConsumerWidget { ), ListTile( leading: Icon( - Icons.star_outline, + Icons.favorite_border_rounded, color: categoryIconColor, ), title: diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index a4cc2401f2..01d54082ec 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/activities/views/activities_page.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/views/album_options_part.dart'; import 'package:immich_mobile/modules/album/views/album_viewer_page.dart'; @@ -160,6 +161,12 @@ part 'router.gr.dart'; AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]), + CustomRoute( + page: ActivitiesPage, + guards: [AuthGuard, DuplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + durationInMilliseconds: 200, + ), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b5b5b773af..885b556436 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -73,6 +73,7 @@ class _$AppRouter extends RootStackRouter { heroOffset: args.heroOffset, showStack: args.showStack, isOwner: args.isOwner, + sharedAlbumId: args.sharedAlbumId, ), ); }, @@ -337,6 +338,24 @@ class _$AppRouter extends RootStackRouter { ), ); }, + ActivitiesRoute.name: (routeData) { + final args = routeData.argsAs(); + return CustomPage( + routeData: routeData, + child: ActivitiesPage( + args.albumId, + appBarTitle: args.appBarTitle, + assetId: args.assetId, + withAssetThumbs: args.withAssetThumbs, + isOwner: args.isOwner, + key: args.key, + ), + transitionsBuilder: TransitionsBuilders.slideLeft, + durationInMilliseconds: 200, + opaque: true, + barrierDismissible: false, + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( routeData: routeData, @@ -674,6 +693,14 @@ class _$AppRouter extends RootStackRouter { duplicateGuard, ], ), + RouteConfig( + ActivitiesRoute.name, + path: '/activities-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), ]; } @@ -749,6 +776,7 @@ class GalleryViewerRoute extends PageRouteInfo { int heroOffset = 0, bool showStack = false, bool isOwner = true, + String? sharedAlbumId, }) : super( GalleryViewerRoute.name, path: '/gallery-viewer-page', @@ -760,6 +788,7 @@ class GalleryViewerRoute extends PageRouteInfo { heroOffset: heroOffset, showStack: showStack, isOwner: isOwner, + sharedAlbumId: sharedAlbumId, ), ); @@ -775,6 +804,7 @@ class GalleryViewerRouteArgs { this.heroOffset = 0, this.showStack = false, this.isOwner = true, + this.sharedAlbumId, }); final Key? key; @@ -791,9 +821,11 @@ class GalleryViewerRouteArgs { final bool isOwner; + final String? sharedAlbumId; + @override String toString() { - return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner}'; + return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner, sharedAlbumId: $sharedAlbumId}'; } } @@ -1527,6 +1559,60 @@ class SharedLinkEditRouteArgs { } } +/// generated route for +/// [ActivitiesPage] +class ActivitiesRoute extends PageRouteInfo { + ActivitiesRoute({ + required String albumId, + String appBarTitle = "", + String? assetId, + bool withAssetThumbs = true, + bool isOwner = false, + Key? key, + }) : super( + ActivitiesRoute.name, + path: '/activities-page', + args: ActivitiesRouteArgs( + albumId: albumId, + appBarTitle: appBarTitle, + assetId: assetId, + withAssetThumbs: withAssetThumbs, + isOwner: isOwner, + key: key, + ), + ); + + static const String name = 'ActivitiesRoute'; +} + +class ActivitiesRouteArgs { + const ActivitiesRouteArgs({ + required this.albumId, + this.appBarTitle = "", + this.assetId, + this.withAssetThumbs = true, + this.isOwner = false, + this.key, + }); + + final String albumId; + + final String appBarTitle; + + final String? assetId; + + final bool withAssetThumbs; + + final bool isOwner; + + final Key? key; + + @override + String toString() { + return 'ActivitiesRouteArgs{albumId: $albumId, appBarTitle: $appBarTitle, assetId: $assetId, withAssetThumbs: $withAssetThumbs, isOwner: $isOwner, key: $key}'; + } +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index 7c1dfc8fcb..2f422515d1 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -22,6 +22,7 @@ class ApiService { late PersonApi personApi; late AuditApi auditApi; late SharedLinkApi sharedLinkApi; + late ActivityApi activityApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -47,6 +48,7 @@ class ApiService { personApi = PersonApi(_apiClient); auditApi = AuditApi(_apiClient); sharedLinkApi = SharedLinkApi(_apiClient); + activityApi = ActivityApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/shared/ui/user_circle_avatar.dart b/mobile/lib/shared/ui/user_circle_avatar.dart index b70566d88d..df50d5071a 100644 --- a/mobile/lib/shared/ui/user_circle_avatar.dart +++ b/mobile/lib/shared/ui/user_circle_avatar.dart @@ -40,19 +40,23 @@ class UserCircleAvatar extends ConsumerWidget { final profileImageUrl = '${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}'; + + final textIcon = Text( + user.firstName[0].toUpperCase(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.black + : Colors.white, + ), + ); return CircleAvatar( backgroundColor: useRandomBackgroundColor ? randomColors[Random().nextInt(randomColors.length)] : Theme.of(context).primaryColor, radius: radius, child: user.profileImagePath == "" - ? Text( - user.firstName[0].toUpperCase(), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black, - ), - ) + ? textIcon : ClipRRect( borderRadius: BorderRadius.circular(50), child: CachedNetworkImage( @@ -66,8 +70,7 @@ class UserCircleAvatar extends ConsumerWidget { "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}", }, fadeInDuration: const Duration(milliseconds: 300), - errorWidget: (context, error, stackTrace) => - Image.memory(kTransparentImage), + errorWidget: (context, error, stackTrace) => textIcon, ), ), ); diff --git a/mobile/lib/utils/datetime_extensions.dart b/mobile/lib/utils/datetime_extensions.dart new file mode 100644 index 0000000000..d918377711 --- /dev/null +++ b/mobile/lib/utils/datetime_extensions.dart @@ -0,0 +1,36 @@ +extension TimeAgoExtension on DateTime { + String timeAgo({bool numericDates = true}) { + DateTime date = toLocal(); + final date2 = DateTime.now().toLocal(); + final difference = date2.difference(date); + + if (difference.inSeconds < 5) { + return 'Just now'; + } else if (difference.inSeconds < 60) { + return '${difference.inSeconds} seconds ago'; + } else if (difference.inMinutes <= 1) { + return (numericDates) ? '1 minute ago' : 'A minute ago'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes} minutes ago'; + } else if (difference.inHours <= 1) { + return (numericDates) ? '1 hour ago' : 'An hour ago'; + } else if (difference.inHours < 60) { + return '${difference.inHours} hours ago'; + } else if (difference.inDays <= 1) { + return (numericDates) ? '1 day ago' : 'Yesterday'; + } else if (difference.inDays < 6) { + return '${difference.inDays} days ago'; + } else if ((difference.inDays / 7).ceil() <= 1) { + return (numericDates) ? '1 week ago' : 'Last week'; + } else if ((difference.inDays / 7).ceil() < 4) { + return '${(difference.inDays / 7).ceil()} weeks ago'; + } else if ((difference.inDays / 30).ceil() <= 1) { + return (numericDates) ? '1 month ago' : 'Last month'; + } else if ((difference.inDays / 30).ceil() < 30) { + return '${(difference.inDays / 30).ceil()} months ago'; + } else if ((difference.inDays / 365).ceil() <= 1) { + return (numericDates) ? '1 year ago' : 'Last year'; + } + return '${(difference.inDays / 365).floor()} years ago'; + } +} diff --git a/server/src/domain/activity/activity.service.ts b/server/src/domain/activity/activity.service.ts index bacdab317f..44362f3fc0 100644 --- a/server/src/domain/activity/activity.service.ts +++ b/server/src/domain/activity/activity.service.ts @@ -58,6 +58,7 @@ export class ActivityService { delete dto.comment; [activity] = await this.repository.search({ ...common, + isGlobal: !dto.assetId, isLiked: true, }); duplicate = !!activity; diff --git a/server/src/infra/repositories/activity.repository.ts b/server/src/infra/repositories/activity.repository.ts index 271124db5d..138d963815 100644 --- a/server/src/infra/repositories/activity.repository.ts +++ b/server/src/infra/repositories/activity.repository.ts @@ -1,7 +1,7 @@ import { IActivityRepository } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { ActivityEntity } from '../entities/activity.entity'; export interface ActivitySearch { @@ -9,6 +9,7 @@ export interface ActivitySearch { assetId?: string; userId?: string; isLiked?: boolean; + isGlobal?: boolean; } @Injectable() @@ -16,11 +17,11 @@ export class ActivityRepository implements IActivityRepository { constructor(@InjectRepository(ActivityEntity) private repository: Repository) {} search(options: ActivitySearch): Promise { - const { userId, assetId, albumId, isLiked } = options; + const { userId, assetId, albumId, isLiked, isGlobal } = options; return this.repository.find({ where: { userId, - assetId, + assetId: isGlobal ? IsNull() : assetId, albumId, isLiked, },