mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 04:06:26 -04: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:
parent
c74ea7282a
commit
26fd9d7e5f
@ -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,
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user