You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(mobile): shared album activities (#4833)
* fix(server): global activity like duplicate search * mobile: user_circle_avatar - fallback to text icon if no profile pic available * mobile: use favourite icon in search "your activity" * feat(mobile): shared album activities * mobile: align hearts with user profile icon * styling * replace bottom sheet with dismissible * add auto focus to the input --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
		| @@ -373,5 +373,8 @@ | |||||||
|   "viewer_stack_use_as_main_asset": "Use as Main Asset", |   "viewer_stack_use_as_main_asset": "Use as Main Asset", | ||||||
|   "app_bar_signout_dialog_title": "Sign out", |   "app_bar_signout_dialog_title": "Sign out", | ||||||
|   "app_bar_signout_dialog_content": "Are you sure you wanna 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?" | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										90
									
								
								mobile/lib/modules/activities/models/activity.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								mobile/lib/modules/activities/models/activity.model.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										130
									
								
								mobile/lib/modules/activities/providers/activity.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								mobile/lib/modules/activities/providers/activity.provider.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AsyncValue<List<Activity>>> { | ||||||
|  |   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<void> fetchActivity() async { | ||||||
|  |     state = const AsyncLoading(); | ||||||
|  |     state = await AsyncValue.guard( | ||||||
|  |       () => _activityService.getAllActivities(albumId, assetId), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> 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<void> 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<void> 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<int> { | ||||||
|  |   final String albumId; | ||||||
|  |   final String? assetId; | ||||||
|  |   final ActivityService _activityService; | ||||||
|  |   ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId) | ||||||
|  |       : super(0) { | ||||||
|  |     fetchStatistics(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> fetchStatistics() async { | ||||||
|  |     state = await _activityService.getStatistics(albumId, assetId: assetId); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> addActivity() async { | ||||||
|  |     state = state + 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> removeActivity() async { | ||||||
|  |     state = state - 1; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | typedef ActivityParams = ({String albumId, String? assetId}); | ||||||
|  |  | ||||||
|  | final activityStateProvider = StateNotifierProvider.autoDispose | ||||||
|  |     .family<ActivityNotifier, AsyncValue<List<Activity>>, ActivityParams>( | ||||||
|  |         (ref, args) { | ||||||
|  |   return ActivityNotifier( | ||||||
|  |     ref, | ||||||
|  |     ref.watch(activityServiceProvider), | ||||||
|  |     args.albumId, | ||||||
|  |     args.assetId, | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | final activityStatisticsStateProvider = StateNotifierProvider.autoDispose | ||||||
|  |     .family<ActivityStatisticsNotifier, int, ActivityParams>((ref, args) { | ||||||
|  |   return ActivityStatisticsNotifier( | ||||||
|  |     ref.watch(activityServiceProvider), | ||||||
|  |     args.albumId, | ||||||
|  |     args.assetId, | ||||||
|  |   ); | ||||||
|  | }); | ||||||
							
								
								
									
										85
									
								
								mobile/lib/modules/activities/services/activity.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								mobile/lib/modules/activities/services/activity.service.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<List<Activity>> 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<int> 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<bool> 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<Activity?> 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; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										312
									
								
								mobile/lib/modules/activities/views/activities_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										312
									
								
								mobile/lib/modules/activities/views/activities_page.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:fluttertoast/fluttertoast.dart'; | import 'package:fluttertoast/fluttertoast.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.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.provider.dart'; | ||||||
| import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; | import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; | ||||||
| import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; | import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; | ||||||
| @@ -26,6 +27,7 @@ class AlbumViewerAppbar extends HookConsumerWidget | |||||||
|     required this.titleFocusNode, |     required this.titleFocusNode, | ||||||
|     this.onAddPhotos, |     this.onAddPhotos, | ||||||
|     this.onAddUsers, |     this.onAddUsers, | ||||||
|  |     required this.onActivities, | ||||||
|   }) : super(key: key); |   }) : super(key: key); | ||||||
|  |  | ||||||
|   final Album album; |   final Album album; | ||||||
| @@ -35,11 +37,19 @@ class AlbumViewerAppbar extends HookConsumerWidget | |||||||
|   final FocusNode titleFocusNode; |   final FocusNode titleFocusNode; | ||||||
|   final Function(Album album)? onAddPhotos; |   final Function(Album album)? onAddPhotos; | ||||||
|   final Function(Album album)? onAddUsers; |   final Function(Album album)? onAddUsers; | ||||||
|  |   final Function(Album album) onActivities; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; |     final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; | ||||||
|     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; |     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; | ||||||
|  |     final comments = album.shared | ||||||
|  |         ? ref.watch( | ||||||
|  |             activityStatisticsStateProvider( | ||||||
|  |               (albumId: album.remoteId!, assetId: null), | ||||||
|  |             ), | ||||||
|  |           ) | ||||||
|  |         : 0; | ||||||
|  |  | ||||||
|     deleteAlbum() async { |     deleteAlbum() async { | ||||||
|       ImmichLoadingOverlayController.appLoader.show(); |       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() { |     buildLeadingButton() { | ||||||
|       if (selected.isNotEmpty) { |       if (selected.isNotEmpty) { | ||||||
|         return IconButton( |         return IconButton( | ||||||
| @@ -353,6 +390,7 @@ class AlbumViewerAppbar extends HookConsumerWidget | |||||||
|       title: selected.isNotEmpty ? Text('${selected.length}') : null, |       title: selected.isNotEmpty ? Text('${selected.length}') : null, | ||||||
|       centerTitle: false, |       centerTitle: false, | ||||||
|       actions: [ |       actions: [ | ||||||
|  |         if (album.shared) buildActivitiesButton(), | ||||||
|         if (album.isRemote) |         if (album.isRemote) | ||||||
|           IconButton( |           IconButton( | ||||||
|             splashRadius: 25, |             splashRadius: 25, | ||||||
|   | |||||||
| @@ -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( |     return Scaffold( | ||||||
|       appBar: album.when( |       appBar: album.when( | ||||||
|         data: (data) => AlbumViewerAppbar( |         data: (data) => AlbumViewerAppbar( | ||||||
| @@ -242,6 +254,7 @@ class AlbumViewerPage extends HookConsumerWidget { | |||||||
|           selectionDisabled: disableSelection, |           selectionDisabled: disableSelection, | ||||||
|           onAddPhotos: onAddPhotosPressed, |           onAddPhotos: onAddPhotosPressed, | ||||||
|           onAddUsers: onAddUsersPressed, |           onAddUsers: onAddUsersPressed, | ||||||
|  |           onActivities: onActivitiesPressed, | ||||||
|         ), |         ), | ||||||
|         error: (error, stackTrace) => AppBar(title: const Text("Error")), |         error: (error, stackTrace) => AppBar(title: const Text("Error")), | ||||||
|         loading: () => AppBar(), |         loading: () => AppBar(), | ||||||
| @@ -266,6 +279,7 @@ class AlbumViewerPage extends HookConsumerWidget { | |||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|               isOwner: userId == data.ownerId, |               isOwner: userId == data.ownerId, | ||||||
|  |               sharedAlbumId: data.remoteId, | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import 'package:auto_route/auto_route.dart'; | import 'package:auto_route/auto_route.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.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/models/asset.dart'; | ||||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||||
|  |  | ||||||
| @@ -16,6 +17,8 @@ class TopControlAppBar extends HookConsumerWidget { | |||||||
|     required this.onFavorite, |     required this.onFavorite, | ||||||
|     required this.onUploadPressed, |     required this.onUploadPressed, | ||||||
|     required this.isOwner, |     required this.isOwner, | ||||||
|  |     required this.shareAlbumId, | ||||||
|  |     required this.onActivitiesPressed, | ||||||
|   }) : super(key: key); |   }) : super(key: key); | ||||||
|  |  | ||||||
|   final Asset asset; |   final Asset asset; | ||||||
| @@ -24,14 +27,23 @@ class TopControlAppBar extends HookConsumerWidget { | |||||||
|   final VoidCallback? onDownloadPressed; |   final VoidCallback? onDownloadPressed; | ||||||
|   final VoidCallback onToggleMotionVideo; |   final VoidCallback onToggleMotionVideo; | ||||||
|   final VoidCallback onAddToAlbumPressed; |   final VoidCallback onAddToAlbumPressed; | ||||||
|  |   final VoidCallback onActivitiesPressed; | ||||||
|   final Function(Asset) onFavorite; |   final Function(Asset) onFavorite; | ||||||
|   final bool isPlayingMotionVideo; |   final bool isPlayingMotionVideo; | ||||||
|   final bool isOwner; |   final bool isOwner; | ||||||
|  |   final String? shareAlbumId; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     const double iconSize = 22.0; |     const double iconSize = 22.0; | ||||||
|     final a = ref.watch(assetWatcher(asset)).value ?? asset; |     final a = ref.watch(assetWatcher(asset)).value ?? asset; | ||||||
|  |     final comments = shareAlbumId != null | ||||||
|  |         ? ref.watch( | ||||||
|  |             activityStatisticsStateProvider( | ||||||
|  |               (albumId: shareAlbumId!, assetId: asset.remoteId), | ||||||
|  |             ), | ||||||
|  |           ) | ||||||
|  |         : 0; | ||||||
|  |  | ||||||
|     Widget buildFavoriteButton(a) { |     Widget buildFavoriteButton(a) { | ||||||
|       return IconButton( |       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() { |     Widget buildUploadButton() { | ||||||
|       return IconButton( |       return IconButton( | ||||||
|         onPressed: onUploadPressed, |         onPressed: onUploadPressed, | ||||||
| @@ -130,6 +170,7 @@ class TopControlAppBar extends HookConsumerWidget { | |||||||
|         if (asset.isLocal && !asset.isRemote) buildUploadButton(), |         if (asset.isLocal && !asset.isRemote) buildUploadButton(), | ||||||
|         if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), |         if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), | ||||||
|         if (asset.isRemote && isOwner) buildAddToAlbumButtom(), |         if (asset.isRemote && isOwner) buildAddToAlbumButtom(), | ||||||
|  |         if (shareAlbumId != null) buildActivitiesButton(), | ||||||
|         buildMoreInfoButton(), |         buildMoreInfoButton(), | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -49,6 +49,7 @@ class GalleryViewerPage extends HookConsumerWidget { | |||||||
|   final int heroOffset; |   final int heroOffset; | ||||||
|   final bool showStack; |   final bool showStack; | ||||||
|   final bool isOwner; |   final bool isOwner; | ||||||
|  |   final String? sharedAlbumId; | ||||||
|  |  | ||||||
|   GalleryViewerPage({ |   GalleryViewerPage({ | ||||||
|     super.key, |     super.key, | ||||||
| @@ -58,6 +59,7 @@ class GalleryViewerPage extends HookConsumerWidget { | |||||||
|     this.heroOffset = 0, |     this.heroOffset = 0, | ||||||
|     this.showStack = false, |     this.showStack = false, | ||||||
|     this.isOwner = true, |     this.isOwner = true, | ||||||
|  |     this.sharedAlbumId, | ||||||
|   }) : controller = PageController(initialPage: initialIndex); |   }) : controller = PageController(initialPage: initialIndex); | ||||||
|  |  | ||||||
|   final PageController controller; |   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() { |     buildAppBar() { | ||||||
|       return IgnorePointer( |       return IgnorePointer( | ||||||
|         ignoring: !ref.watch(showControlsProvider), |         ignoring: !ref.watch(showControlsProvider), | ||||||
| @@ -355,6 +370,8 @@ class GalleryViewerPage extends HookConsumerWidget { | |||||||
|                 isPlayingMotionVideo.value = !isPlayingMotionVideo.value; |                 isPlayingMotionVideo.value = !isPlayingMotionVideo.value; | ||||||
|               }), |               }), | ||||||
|               onAddToAlbumPressed: () => addToAlbum(asset()), |               onAddToAlbumPressed: () => addToAlbum(asset()), | ||||||
|  |               shareAlbumId: sharedAlbumId, | ||||||
|  |               onActivitiesPressed: handleActivities, | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|   | |||||||
| @@ -34,6 +34,7 @@ class ImmichAssetGrid extends HookConsumerWidget { | |||||||
|   final bool showDragScroll; |   final bool showDragScroll; | ||||||
|   final bool showStack; |   final bool showStack; | ||||||
|   final bool isOwner; |   final bool isOwner; | ||||||
|  |   final String? sharedAlbumId; | ||||||
|  |  | ||||||
|   const ImmichAssetGrid({ |   const ImmichAssetGrid({ | ||||||
|     super.key, |     super.key, | ||||||
| @@ -55,6 +56,7 @@ class ImmichAssetGrid extends HookConsumerWidget { | |||||||
|     this.showDragScroll = true, |     this.showDragScroll = true, | ||||||
|     this.showStack = false, |     this.showStack = false, | ||||||
|     this.isOwner = true, |     this.isOwner = true, | ||||||
|  |     this.sharedAlbumId, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -120,6 +122,7 @@ class ImmichAssetGrid extends HookConsumerWidget { | |||||||
|           showDragScroll: showDragScroll, |           showDragScroll: showDragScroll, | ||||||
|           showStack: showStack, |           showStack: showStack, | ||||||
|           isOwner: isOwner, |           isOwner: isOwner, | ||||||
|  |           sharedAlbumId: sharedAlbumId, | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -39,6 +39,7 @@ class ImmichAssetGridView extends StatefulWidget { | |||||||
|   final bool showDragScroll; |   final bool showDragScroll; | ||||||
|   final bool showStack; |   final bool showStack; | ||||||
|   final bool isOwner; |   final bool isOwner; | ||||||
|  |   final String? sharedAlbumId; | ||||||
|  |  | ||||||
|   const ImmichAssetGridView({ |   const ImmichAssetGridView({ | ||||||
|     super.key, |     super.key, | ||||||
| @@ -60,6 +61,7 @@ class ImmichAssetGridView extends StatefulWidget { | |||||||
|     this.showDragScroll = true, |     this.showDragScroll = true, | ||||||
|     this.showStack = false, |     this.showStack = false, | ||||||
|     this.isOwner = true, |     this.isOwner = true, | ||||||
|  |     this.sharedAlbumId, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -141,6 +143,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> { | |||||||
|       heroOffset: widget.heroOffset, |       heroOffset: widget.heroOffset, | ||||||
|       showStack: widget.showStack, |       showStack: widget.showStack, | ||||||
|       isOwner: widget.isOwner, |       isOwner: widget.isOwner, | ||||||
|  |       sharedAlbumId: widget.sharedAlbumId, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -21,6 +21,7 @@ class ThumbnailImage extends StatelessWidget { | |||||||
|   final Function? onSelect; |   final Function? onSelect; | ||||||
|   final Function? onDeselect; |   final Function? onDeselect; | ||||||
|   final int heroOffset; |   final int heroOffset; | ||||||
|  |   final String? sharedAlbumId; | ||||||
|  |  | ||||||
|   const ThumbnailImage({ |   const ThumbnailImage({ | ||||||
|     Key? key, |     Key? key, | ||||||
| @@ -31,6 +32,7 @@ class ThumbnailImage extends StatelessWidget { | |||||||
|     this.showStorageIndicator = true, |     this.showStorageIndicator = true, | ||||||
|     this.showStack = false, |     this.showStack = false, | ||||||
|     this.isOwner = true, |     this.isOwner = true, | ||||||
|  |     this.sharedAlbumId, | ||||||
|     this.useGrayBoxPlaceholder = false, |     this.useGrayBoxPlaceholder = false, | ||||||
|     this.isSelected = false, |     this.isSelected = false, | ||||||
|     this.multiselectEnabled = false, |     this.multiselectEnabled = false, | ||||||
| @@ -184,6 +186,7 @@ class ThumbnailImage extends StatelessWidget { | |||||||
|               heroOffset: heroOffset, |               heroOffset: heroOffset, | ||||||
|               showStack: showStack, |               showStack: showStack, | ||||||
|               isOwner: isOwner, |               isOwner: isOwner, | ||||||
|  |               sharedAlbumId: sharedAlbumId, | ||||||
|             ), |             ), | ||||||
|           ); |           ); | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -172,7 +172,7 @@ class SearchPage extends HookConsumerWidget { | |||||||
|                 ), |                 ), | ||||||
|                 ListTile( |                 ListTile( | ||||||
|                   leading: Icon( |                   leading: Icon( | ||||||
|                     Icons.star_outline, |                     Icons.favorite_border_rounded, | ||||||
|                     color: categoryIconColor, |                     color: categoryIconColor, | ||||||
|                   ), |                   ), | ||||||
|                   title: |                   title: | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import 'package:auto_route/auto_route.dart'; | import 'package:auto_route/auto_route.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.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/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_options_part.dart'; | ||||||
| import 'package:immich_mobile/modules/album/views/album_viewer_page.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: TrashPage, guards: [AuthGuard, DuplicateGuard]), | ||||||
|     AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]), |     AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]), | ||||||
|     AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]), |     AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]), | ||||||
|  |     CustomRoute( | ||||||
|  |       page: ActivitiesPage, | ||||||
|  |       guards: [AuthGuard, DuplicateGuard], | ||||||
|  |       transitionsBuilder: TransitionsBuilders.slideLeft, | ||||||
|  |       durationInMilliseconds: 200, | ||||||
|  |     ), | ||||||
|   ], |   ], | ||||||
| ) | ) | ||||||
| class AppRouter extends _$AppRouter { | class AppRouter extends _$AppRouter { | ||||||
|   | |||||||
| @@ -73,6 +73,7 @@ class _$AppRouter extends RootStackRouter { | |||||||
|           heroOffset: args.heroOffset, |           heroOffset: args.heroOffset, | ||||||
|           showStack: args.showStack, |           showStack: args.showStack, | ||||||
|           isOwner: args.isOwner, |           isOwner: args.isOwner, | ||||||
|  |           sharedAlbumId: args.sharedAlbumId, | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
| @@ -337,6 +338,24 @@ class _$AppRouter extends RootStackRouter { | |||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|     }, |     }, | ||||||
|  |     ActivitiesRoute.name: (routeData) { | ||||||
|  |       final args = routeData.argsAs<ActivitiesRouteArgs>(); | ||||||
|  |       return CustomPage<dynamic>( | ||||||
|  |         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) { |     HomeRoute.name: (routeData) { | ||||||
|       return MaterialPageX<dynamic>( |       return MaterialPageX<dynamic>( | ||||||
|         routeData: routeData, |         routeData: routeData, | ||||||
| @@ -674,6 +693,14 @@ class _$AppRouter extends RootStackRouter { | |||||||
|             duplicateGuard, |             duplicateGuard, | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|  |         RouteConfig( | ||||||
|  |           ActivitiesRoute.name, | ||||||
|  |           path: '/activities-page', | ||||||
|  |           guards: [ | ||||||
|  |             authGuard, | ||||||
|  |             duplicateGuard, | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|       ]; |       ]; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -749,6 +776,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> { | |||||||
|     int heroOffset = 0, |     int heroOffset = 0, | ||||||
|     bool showStack = false, |     bool showStack = false, | ||||||
|     bool isOwner = true, |     bool isOwner = true, | ||||||
|  |     String? sharedAlbumId, | ||||||
|   }) : super( |   }) : super( | ||||||
|           GalleryViewerRoute.name, |           GalleryViewerRoute.name, | ||||||
|           path: '/gallery-viewer-page', |           path: '/gallery-viewer-page', | ||||||
| @@ -760,6 +788,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> { | |||||||
|             heroOffset: heroOffset, |             heroOffset: heroOffset, | ||||||
|             showStack: showStack, |             showStack: showStack, | ||||||
|             isOwner: isOwner, |             isOwner: isOwner, | ||||||
|  |             sharedAlbumId: sharedAlbumId, | ||||||
|           ), |           ), | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
| @@ -775,6 +804,7 @@ class GalleryViewerRouteArgs { | |||||||
|     this.heroOffset = 0, |     this.heroOffset = 0, | ||||||
|     this.showStack = false, |     this.showStack = false, | ||||||
|     this.isOwner = true, |     this.isOwner = true, | ||||||
|  |     this.sharedAlbumId, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   final Key? key; |   final Key? key; | ||||||
| @@ -791,9 +821,11 @@ class GalleryViewerRouteArgs { | |||||||
|  |  | ||||||
|   final bool isOwner; |   final bool isOwner; | ||||||
|  |  | ||||||
|  |   final String? sharedAlbumId; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String toString() { |   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<ActivitiesRouteArgs> { | ||||||
|  |   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 | /// generated route for | ||||||
| /// [HomePage] | /// [HomePage] | ||||||
| class HomeRoute extends PageRouteInfo<void> { | class HomeRoute extends PageRouteInfo<void> { | ||||||
|   | |||||||
| @@ -22,6 +22,7 @@ class ApiService { | |||||||
|   late PersonApi personApi; |   late PersonApi personApi; | ||||||
|   late AuditApi auditApi; |   late AuditApi auditApi; | ||||||
|   late SharedLinkApi sharedLinkApi; |   late SharedLinkApi sharedLinkApi; | ||||||
|  |   late ActivityApi activityApi; | ||||||
|  |  | ||||||
|   ApiService() { |   ApiService() { | ||||||
|     final endpoint = Store.tryGet(StoreKey.serverEndpoint); |     final endpoint = Store.tryGet(StoreKey.serverEndpoint); | ||||||
| @@ -47,6 +48,7 @@ class ApiService { | |||||||
|     personApi = PersonApi(_apiClient); |     personApi = PersonApi(_apiClient); | ||||||
|     auditApi = AuditApi(_apiClient); |     auditApi = AuditApi(_apiClient); | ||||||
|     sharedLinkApi = SharedLinkApi(_apiClient); |     sharedLinkApi = SharedLinkApi(_apiClient); | ||||||
|  |     activityApi = ActivityApi(_apiClient); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<String> resolveAndSetEndpoint(String serverUrl) async { |   Future<String> resolveAndSetEndpoint(String serverUrl) async { | ||||||
|   | |||||||
| @@ -40,19 +40,23 @@ class UserCircleAvatar extends ConsumerWidget { | |||||||
|  |  | ||||||
|     final profileImageUrl = |     final profileImageUrl = | ||||||
|         '${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}'; |         '${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( |     return CircleAvatar( | ||||||
|       backgroundColor: useRandomBackgroundColor |       backgroundColor: useRandomBackgroundColor | ||||||
|           ? randomColors[Random().nextInt(randomColors.length)] |           ? randomColors[Random().nextInt(randomColors.length)] | ||||||
|           : Theme.of(context).primaryColor, |           : Theme.of(context).primaryColor, | ||||||
|       radius: radius, |       radius: radius, | ||||||
|       child: user.profileImagePath == "" |       child: user.profileImagePath == "" | ||||||
|           ? Text( |           ? textIcon | ||||||
|               user.firstName[0].toUpperCase(), |  | ||||||
|               style: const TextStyle( |  | ||||||
|                 fontWeight: FontWeight.bold, |  | ||||||
|                 color: Colors.black, |  | ||||||
|               ), |  | ||||||
|             ) |  | ||||||
|           : ClipRRect( |           : ClipRRect( | ||||||
|               borderRadius: BorderRadius.circular(50), |               borderRadius: BorderRadius.circular(50), | ||||||
|               child: CachedNetworkImage( |               child: CachedNetworkImage( | ||||||
| @@ -66,8 +70,7 @@ class UserCircleAvatar extends ConsumerWidget { | |||||||
|                   "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}", |                   "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}", | ||||||
|                 }, |                 }, | ||||||
|                 fadeInDuration: const Duration(milliseconds: 300), |                 fadeInDuration: const Duration(milliseconds: 300), | ||||||
|                 errorWidget: (context, error, stackTrace) => |                 errorWidget: (context, error, stackTrace) => textIcon, | ||||||
|                     Image.memory(kTransparentImage), |  | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|     ); |     ); | ||||||
|   | |||||||
							
								
								
									
										36
									
								
								mobile/lib/utils/datetime_extensions.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								mobile/lib/utils/datetime_extensions.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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'; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -58,6 +58,7 @@ export class ActivityService { | |||||||
|       delete dto.comment; |       delete dto.comment; | ||||||
|       [activity] = await this.repository.search({ |       [activity] = await this.repository.search({ | ||||||
|         ...common, |         ...common, | ||||||
|  |         isGlobal: !dto.assetId, | ||||||
|         isLiked: true, |         isLiked: true, | ||||||
|       }); |       }); | ||||||
|       duplicate = !!activity; |       duplicate = !!activity; | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import { IActivityRepository } from '@app/domain'; | import { IActivityRepository } from '@app/domain'; | ||||||
| import { Injectable } from '@nestjs/common'; | import { Injectable } from '@nestjs/common'; | ||||||
| import { InjectRepository } from '@nestjs/typeorm'; | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
| import { Repository } from 'typeorm'; | import { IsNull, Repository } from 'typeorm'; | ||||||
| import { ActivityEntity } from '../entities/activity.entity'; | import { ActivityEntity } from '../entities/activity.entity'; | ||||||
|  |  | ||||||
| export interface ActivitySearch { | export interface ActivitySearch { | ||||||
| @@ -9,6 +9,7 @@ export interface ActivitySearch { | |||||||
|   assetId?: string; |   assetId?: string; | ||||||
|   userId?: string; |   userId?: string; | ||||||
|   isLiked?: boolean; |   isLiked?: boolean; | ||||||
|  |   isGlobal?: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
| @Injectable() | @Injectable() | ||||||
| @@ -16,11 +17,11 @@ export class ActivityRepository implements IActivityRepository { | |||||||
|   constructor(@InjectRepository(ActivityEntity) private repository: Repository<ActivityEntity>) {} |   constructor(@InjectRepository(ActivityEntity) private repository: Repository<ActivityEntity>) {} | ||||||
|  |  | ||||||
|   search(options: ActivitySearch): Promise<ActivityEntity[]> { |   search(options: ActivitySearch): Promise<ActivityEntity[]> { | ||||||
|     const { userId, assetId, albumId, isLiked } = options; |     const { userId, assetId, albumId, isLiked, isGlobal } = options; | ||||||
|     return this.repository.find({ |     return this.repository.find({ | ||||||
|       where: { |       where: { | ||||||
|         userId, |         userId, | ||||||
|         assetId, |         assetId: isGlobal ? IsNull() : assetId, | ||||||
|         albumId, |         albumId, | ||||||
|         isLiked, |         isLiked, | ||||||
|       }, |       }, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user