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", | ||||
|   "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?" | ||||
| } | ||||
|   | ||||
							
								
								
									
										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: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, | ||||
|   | ||||
| @@ -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, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|   | ||||
| @@ -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(), | ||||
|       ], | ||||
|     ); | ||||
|   | ||||
| @@ -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, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|   | ||||
| @@ -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, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -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<ImmichAssetGridView> { | ||||
|       heroOffset: widget.heroOffset, | ||||
|       showStack: widget.showStack, | ||||
|       isOwner: widget.isOwner, | ||||
|       sharedAlbumId: widget.sharedAlbumId, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|             ), | ||||
|           ); | ||||
|         } | ||||
|   | ||||
| @@ -172,7 +172,7 @@ class SearchPage extends HookConsumerWidget { | ||||
|                 ), | ||||
|                 ListTile( | ||||
|                   leading: Icon( | ||||
|                     Icons.star_outline, | ||||
|                     Icons.favorite_border_rounded, | ||||
|                     color: categoryIconColor, | ||||
|                   ), | ||||
|                   title: | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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<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) { | ||||
|       return MaterialPageX<dynamic>( | ||||
|         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<GalleryViewerRouteArgs> { | ||||
|     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<GalleryViewerRouteArgs> { | ||||
|             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<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 | ||||
| /// [HomePage] | ||||
| class HomeRoute extends PageRouteInfo<void> { | ||||
|   | ||||
| @@ -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<String> resolveAndSetEndpoint(String serverUrl) async { | ||||
|   | ||||
| @@ -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, | ||||
|               ), | ||||
|             ), | ||||
|     ); | ||||
|   | ||||
							
								
								
									
										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; | ||||
|       [activity] = await this.repository.search({ | ||||
|         ...common, | ||||
|         isGlobal: !dto.assetId, | ||||
|         isLiked: true, | ||||
|       }); | ||||
|       duplicate = !!activity; | ||||
|   | ||||
| @@ -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<ActivityEntity>) {} | ||||
|  | ||||
|   search(options: ActivitySearch): Promise<ActivityEntity[]> { | ||||
|     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, | ||||
|       }, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user