1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-23 02:06:15 +02:00

refactor(mobile): Activities (#5990)

* refactor: autoroutex pushroute

* refactor: autoroutex popRoute

* refactor: autoroutex navigate and replace

* chore: add doc comments for extension methods

* refactor: Add LoggerMixin and refactor Album activities to use mixin

* refactor: Activity page

* chore: activity user from user constructor

* fix: update current asset after build method

* refactor: tests with similar structure as lib

* chore: remove avoid-declaring-call-method rule from dcm analysis

* test: fix proper expect order

* test: activity_statistics_provider_test

* test: activity_provider_test

* test: use proper matchers

* test: activity_text_field_test & dismissible_activity_test added

* test: add http mock to return transparent image

* test: download isar core libs during test

* test: add widget tags to widget test cases

* test: activity_tile_test

* build: currentAlbumProvider to generator

* movie add / remove like to activity input tile

* test: activities_page_test.dart

* chore: better error logs

* chore: dismissibleactivity as statelesswidget

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong 2024-01-05 05:20:55 +00:00 committed by GitHub
parent d1e16025cf
commit af32183728
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
108 changed files with 2354 additions and 825 deletions

View File

@ -52,7 +52,6 @@ dart_code_metrics:
- avoid-cascade-after-if-null
- avoid-collapsible-if
- avoid-collection-methods-with-unrelated-types
- avoid-declaring-call-method
- avoid-double-slash-imports
- avoid-duplicate-cascades
- avoid-duplicate-patterns

3
mobile/dart_test.yaml Normal file
View File

@ -0,0 +1,3 @@
# Used to filter out tags from test runs
tags:
widget:

View File

@ -0,0 +1,9 @@
/// Base class which is used to check if an Exception is a custom exception
sealed class ImmichErrors {
const ImmichErrors();
}
class NoResponseDtoError extends ImmichErrors implements Exception {
@override
String toString() => "Response Dto is null";
}

View File

@ -7,6 +7,8 @@ import 'package:logging/logging.dart';
extension LogOnError<T> on AsyncValue<T> {
static final Logger _asyncErrorLogger = Logger("AsyncValue");
/// Used to return the [ImmichLoadingIndicator] and [ScaffoldErrorBody] widgets by default on loading
/// and error cases respectively
Widget widgetWhen({
bool skipLoadingOnRefresh = true,
Widget Function()? onLoading,
@ -28,8 +30,9 @@ extension LogOnError<T> on AsyncValue<T> {
}
if (hasError && !hasValue) {
_asyncErrorLogger.severe("Error occured", error, stackTrace);
return onError?.call(error, stackTrace) ?? const ScaffoldErrorBody();
_asyncErrorLogger.severe("$error", error, stackTrace);
return onError?.call(error, stackTrace) ??
ScaffoldErrorBody(errorMsg: error?.toString());
}
return onData(requireValue);

View File

@ -1,4 +1,3 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
extension ContextHelper on BuildContext {
@ -34,21 +33,4 @@ extension ContextHelper on BuildContext {
// Pop-out from the current context with optional result
void pop<T>([T? result]) => Navigator.of(this).pop(result);
// Auto-Push new route from the current context
Future<T?> autoPush<T extends Object?>(PageRouteInfo<dynamic> route) =>
AutoRouter.of(this).push(route);
// Auto-Push navigate route from the current context
Future<dynamic> autoNavigate<T extends Object?>(
PageRouteInfo<dynamic> route,
) =>
AutoRouter.of(this).navigate(route);
// Auto-Push replace route from the current context
Future<T?> autoReplace<T extends Object?>(PageRouteInfo<dynamic> route) =>
AutoRouter.of(this).replace(route);
// Auto-Pop from the current context
Future<bool> autoPop<T>([T? result]) => AutoRouter.of(this).pop(result);
}

View File

@ -1,8 +1,9 @@
extension TimeAgoExtension on DateTime {
/// Displays the time difference of this [DateTime] object to the current time as a [String]
String timeAgo({bool numericDates = true}) {
DateTime date = toLocal();
final date2 = DateTime.now().toLocal();
final difference = date2.difference(date);
final now = DateTime.now().toLocal();
final difference = now.difference(date);
if (difference.inSeconds < 5) {
return 'Just now';

View File

@ -1,4 +1,5 @@
extension TZOffsetExtension on Duration {
/// Formats the duration in the format of ±HH:MM
String formatAsOffset() =>
"${isNegative ? '-' : '+'}${inHours.abs().toString().padLeft(2, '0')}:${inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
}

View File

@ -9,6 +9,7 @@ extension StringExtension on String {
}
extension DurationExtension on String {
/// Parses and returns the string of format HH:MM:SS as a duration object else null
Duration? toDuration() {
try {
final parts = split(':')

View File

@ -73,14 +73,14 @@ Future<void> initApp() async {
FlutterError.onError = (details) {
FlutterError.presentError(details);
log.severe(
'Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
details,
details.stack,
);
};
PlatformDispatcher.instance.onError = (error, stack) {
log.severe('Catch all error: ${error.toString()} - $error', error, stack);
log.severe('PlatformDispatcher - Catch all error: $error', error, stack);
return true;
};

View File

@ -0,0 +1,38 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
typedef AsyncFuture<T> = Future<AsyncValue<T>>;
mixin ErrorLoggerMixin {
abstract final Logger logger;
/// Returns an AsyncValue<T> if the future is successfully executed
/// Else, logs the error to the overrided logger and returns an AsyncError<>
AsyncFuture<T> guardError<T>(
Future<T> Function() fn, {
Level logLevel = Level.SEVERE,
}) async {
try {
final result = await fn();
return AsyncData(result);
} catch (error, stackTrace) {
logger.log(logLevel, "$error", error, stackTrace);
return AsyncError(error, stackTrace);
}
}
/// Returns the result of the future if success
/// Else, logs the error and returns the default value
Future<T> logError<T>(
Future<T> Function() fn, {
required T defaultValue,
Level logLevel = Level.SEVERE,
}) async {
try {
return await fn();
} catch (error, stackTrace) {
logger.log(logLevel, "$error", error, stackTrace);
}
return defaultValue;
}
}

View File

@ -46,18 +46,7 @@ class Activity {
type = dto.type == ActivityResponseDtoTypeEnum.comment
? ActivityType.comment
: ActivityType.like,
user = User(
email: dto.user.email,
name: dto.user.name,
profileImagePath: dto.user.profileImagePath,
id: dto.user.id,
// Placeholder values
isAdmin: false,
updatedAt: DateTime.now(),
isPartnerSharedBy: false,
isPartnerSharedWith: false,
memoryEnabled: false,
);
user = User.fromSimpleUserDto(dto.user);
@override
String toString() {
@ -65,11 +54,10 @@ class Activity {
}
@override
bool operator ==(Object other) {
bool operator ==(covariant Activity other) {
if (identical(this, other)) return true;
return other is Activity &&
other.id == id &&
return other.id == id &&
other.assetId == assetId &&
other.comment == comment &&
other.createdAt == createdAt &&

View File

@ -1,134 +1,67 @@
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';
import 'package:immich_mobile/modules/activities/providers/activity_service.provider.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class ActivityNotifier extends StateNotifier<AsyncValue<List<Activity>>> {
final Ref _ref;
final ActivityService _activityService;
final String albumId;
final String? assetId;
part 'activity.provider.g.dart';
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),
);
/// Maintains the current list of all activities for <share-album-id, asset>
@riverpod
class AlbumActivity extends _$AlbumActivity {
@override
Future<List<Activity>> build(String albumId, [String? assetId]) async {
return ref
.watch(activityServiceProvider)
.getAllActivities(albumId, assetId: assetId);
}
Future<void> removeActivity(String id) async {
final activities = state.asData?.value ?? [];
if (await _activityService.removeActivity(id)) {
if (await ref.watch(activityServiceProvider).removeActivity(id)) {
final activities = state.valueOrNull ?? [];
final removedActivity = activities.firstWhere((a) => a.id == id);
activities.remove(removedActivity);
state = AsyncData(activities);
// Decrement activity count only for comments
if (removedActivity.type == ActivityType.comment) {
_ref
.read(
activityStatisticsStateProvider(
(albumId: albumId, assetId: assetId),
).notifier,
)
ref
.watch(activityStatisticsProvider(albumId, assetId).notifier)
.removeActivity();
}
}
}
Future<void> addComment(String comment) async {
final activity = await _activityService.addActivity(
albumId,
ActivityType.comment,
assetId: assetId,
comment: comment,
);
if (activity != null) {
Future<void> addLike() async {
final activity = await ref
.watch(activityServiceProvider)
.addActivity(albumId, ActivityType.like, assetId: assetId);
if (activity.hasValue) {
final activities = state.asData?.value ?? [];
state = AsyncData([...activities, activity]);
_ref
.read(
activityStatisticsStateProvider(
(albumId: albumId, assetId: assetId),
).notifier,
)
state = AsyncData([...activities, activity.requireValue]);
}
}
Future<void> addComment(String comment) async {
final activity = await ref.watch(activityServiceProvider).addActivity(
albumId,
ActivityType.comment,
assetId: assetId,
comment: comment,
);
if (activity.hasValue) {
final activities = state.valueOrNull ?? [];
state = AsyncData([...activities, activity.requireValue]);
ref
.watch(activityStatisticsProvider(albumId, assetId).notifier)
.addActivity();
// The previous addActivity call would increase the count of an asset if assetId != null
// To also increase the activity count of the album, calling it once again with assetId set to null
if (assetId != null) {
// Add a count to the current album's provider as well
_ref
.read(
activityStatisticsStateProvider(
(albumId: albumId, assetId: null),
).notifier,
)
.addActivity();
ref.watch(activityStatisticsProvider(albumId).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 {
final count =
await _activityService.getStatistics(albumId, assetId: assetId);
if (mounted) {
state = count;
}
}
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,
);
});
/// Mock class for testing
abstract class AlbumActivityInternal extends _$AlbumActivity {}

Binary file not shown.

View File

@ -0,0 +1,9 @@
import 'package:immich_mobile/modules/activities/services/activity.service.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'activity_service.provider.g.dart';
@riverpod
ActivityService activityService(ActivityServiceRef ref) =>
ActivityService(ref.watch(apiServiceProvider));

View File

@ -0,0 +1,24 @@
import 'package:immich_mobile/modules/activities/providers/activity_service.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'activity_statistics.provider.g.dart';
/// Maintains the current number of comments by <shared-album, asset>
@riverpod
class ActivityStatistics extends _$ActivityStatistics {
@override
int build(String albumId, [String? assetId]) {
ref
.watch(activityServiceProvider)
.getStatistics(albumId, assetId: assetId)
.then((comments) => state = comments);
return 0;
}
void addActivity() => state = state + 1;
void removeActivity() => state = state - 1;
}
/// Mock class for testing
abstract class ActivityStatisticsInternal extends _$ActivityStatistics {}

View File

@ -1,67 +1,60 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/errors.dart';
import 'package:immich_mobile/mixins/error_logger.mixin.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 {
class ActivityService with ErrorLoggerMixin {
final ApiService _apiService;
final Logger _log = Logger("ActivityService");
@override
final Logger logger = Logger("ActivityService");
ActivityService(this._apiService);
Future<List<Activity>> getAllActivities(
String albumId,
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;
}
}) async {
return logError(
() async {
final list = await _apiService.activityApi
.getActivities(albumId, assetId: assetId);
return list != null ? list.map(Activity.fromDto).toList() : [];
},
defaultValue: [],
);
}
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;
return logError(
() async {
final dto = await _apiService.activityApi
.getActivityStatistics(albumId, assetId: assetId);
return dto?.comments ?? 0;
},
defaultValue: 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;
return logError(
() async {
await _apiService.activityApi.deleteActivity(id);
return true;
},
defaultValue: false,
);
}
Future<Activity?> addActivity(
AsyncFuture<Activity> addActivity(
String albumId,
ActivityType type, {
String? assetId,
String? comment,
}) async {
try {
return guardError(() async {
final dto = await _apiService.activityApi.createActivity(
ActivityCreateDto(
albumId: albumId,
@ -75,11 +68,7 @@ class ActivityService {
if (dto != null) {
return Activity.fromDto(dto);
}
} catch (e) {
_log.severe(
"failed to add activity for albumId - $albumId; assetId - $assetId -> $e",
);
}
return null;
throw NoResponseDtoError();
});
}
}

View File

@ -1,6 +1,4 @@
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';
@ -8,236 +6,51 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/user_circle_avatar.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
class ActivitiesPage extends HookConsumerWidget {
final String albumId;
final String? assetId;
final bool withAssetThumbs;
final String appBarTitle;
final bool isOwner;
final bool isReadOnly;
const ActivitiesPage(
this.albumId, {
this.appBarTitle = "",
this.assetId,
this.withAssetThumbs = true,
this.isOwner = false,
this.isReadOnly = false,
const ActivitiesPage({
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();
// Album has to be set in the provider before reaching this page
final album = ref.watch(currentAlbumProvider)!;
final asset = ref.watch(currentAssetProvider);
final user = ref.watch(currentUserProvider);
final activityNotifier = ref
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final activities =
ref.watch(albumActivityProvider(album.remoteId!, asset?.remoteId));
final listViewScrollController = useScrollController();
final currentUser = Store.tryGet(StoreKey.currentUser);
useEffect(
() {
inputFocusNode.requestFocus();
return null;
},
[],
);
buildTitleWithTimestamp(Activity activity, {bool leftAlign = true}) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
final textStyle = 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.name,
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: const BorderRadius.all(Radius.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,
enabled: !isReadOnly,
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: isReadOnly
? 'shared_album_activities_input_disable'.tr()
: '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,
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
// Scroll to the end of the list to show the newly added activity
listViewScrollController.animateTo(
listViewScrollController.position.maxScrollExtent + 200,
duration: const Duration(milliseconds: 600),
curve: Curves.fastOutSlowIn,
);
}
return Scaffold(
appBar: AppBar(title: Text(appBarTitle)),
appBar: AppBar(title: asset == null ? Text(album.name) : null),
body: activities.widgetWhen(
onData: (data) {
final liked = data.firstWhereOrNull(
(a) =>
a.type == ActivityType.like &&
a.user.id == currentUser?.id &&
a.assetId == assetId,
a.user.id == user?.id &&
a.assetId == asset?.remoteId,
);
return SafeArea(
@ -245,9 +58,10 @@ class ActivitiesPage extends HookConsumerWidget {
children: [
ListView.builder(
controller: listViewScrollController,
// +1 to display an additional over-scroll space after the last element
itemCount: data.length + 1,
itemBuilder: (context, index) {
// Vertical gap after the last element
// Additional vertical gap after the last element
if (index == data.length) {
return const SizedBox(
height: 80,
@ -255,45 +69,19 @@ class ActivitiesPage extends HookConsumerWidget {
}
final activity = data[index];
final canDelete =
activity.user.id == currentUser?.id || isOwner;
final canDelete = activity.user.id == user?.id ||
album.ownerId == user?.id;
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,
),
child: DismissibleActivity(
activity.id,
ActivityTile(activity),
onDismiss: canDelete
? (activityId) async => await activityNotifier
.removeActivity(activity.id)
: null,
),
);
},
),
@ -301,7 +89,11 @@ class ActivitiesPage extends HookConsumerWidget {
alignment: Alignment.bottomCenter,
child: Container(
color: context.scaffoldBackgroundColor,
child: buildTextField(liked?.id),
child: ActivityTextField(
isEnabled: album.activityEnabled,
likeId: liked?.id,
onSubmit: onAddComment,
),
),
),
],

View File

@ -0,0 +1,105 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
class ActivityTextField extends HookConsumerWidget {
final bool isEnabled;
final String? likeId;
final Function(String) onSubmit;
const ActivityTextField({
required this.onSubmit,
this.isEnabled = true,
this.likeId,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentAlbumProvider)!;
final asset = ref.watch(currentAssetProvider);
final activityNotifier = ref
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final user = ref.watch(currentUserProvider);
final inputController = useTextEditingController();
final inputFocusNode = useFocusNode();
final liked = likeId != null;
// Show keyboard immediately on activities open
useEffect(
() {
inputFocusNode.requestFocus();
return null;
},
[],
);
// Pass text to callback and reset controller
void onEditingComplete() {
onSubmit(inputController.text);
inputController.clear();
inputFocusNode.unfocus();
}
Future<void> addLike() async {
await activityNotifier.addLike();
}
Future<void> removeLike() async {
if (liked) {
await activityNotifier.removeActivity(likeId!);
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TextField(
controller: inputController,
enabled: isEnabled,
focusNode: inputFocusNode,
textInputAction: TextInputAction.send,
autofocus: false,
decoration: InputDecoration(
border: InputBorder.none,
focusedBorder: InputBorder.none,
prefixIcon: user != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: UserCircleAvatar(
user: user,
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: liked ? removeLike : addLike,
),
),
suffixIconColor: liked ? Colors.red[700] : null,
hintText: !isEnabled
? 'shared_album_activities_input_disable'.tr()
: 'shared_album_activities_input_hint'.tr(),
hintStyle: TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
color: Colors.grey[600],
),
),
onEditingComplete: onEditingComplete,
onTapOutside: (_) => inputFocusNode.unfocus(),
),
);
}
}

View File

@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
class ActivityTile extends HookConsumerWidget {
final Activity activity;
const ActivityTile(this.activity, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetProvider);
final isLike = activity.type == ActivityType.like;
// Asset thumbnail is displayed when we are accessing activities from the album page
// currentAssetProvider will not be set until we open the gallery viewer
final showAssetThumbnail = asset == null && activity.assetId != null;
return ListTile(
minVerticalPadding: 15,
leading: isLike
? Container(
width: 44,
alignment: Alignment.center,
child: Icon(
Icons.favorite_rounded,
color: Colors.red[700],
),
)
: UserCircleAvatar(user: activity.user),
title: _ActivityTitle(
userName: activity.user.name,
createdAt: activity.createdAt.timeAgo(),
leftAlign: isLike || showAssetThumbnail,
),
// No subtitle for like, so center title
titleAlignment:
!isLike ? ListTileTitleAlignment.top : ListTileTitleAlignment.center,
trailing: showAssetThumbnail
? _ActivityAssetThumbnail(activity.assetId!)
: null,
subtitle: !isLike ? Text(activity.comment!) : null,
);
}
}
class _ActivityTitle extends StatelessWidget {
final String userName;
final String createdAt;
final bool leftAlign;
const _ActivityTitle({
required this.userName,
required this.createdAt,
required this.leftAlign,
});
@override
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
final textStyle = 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(
userName,
style: textStyle,
overflow: TextOverflow.ellipsis,
),
if (leftAlign)
Text(
"",
style: textStyle,
),
Expanded(
child: Text(
createdAt,
style: textStyle,
overflow: TextOverflow.ellipsis,
textAlign: leftAlign ? TextAlign.left : TextAlign.right,
),
),
],
);
}
}
class _ActivityAssetThumbnail extends StatelessWidget {
final String assetId;
const _ActivityAssetThumbnail(this.assetId);
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 30,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
image: DecorationImage(
image: ImmichImage.remoteThumbnailProviderForId(assetId),
fit: BoxFit.cover,
),
),
child: const SizedBox.shrink(),
);
}
}

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
/// Wraps an [ActivityTile] and makes it dismissible
class DismissibleActivity extends StatelessWidget {
final String activityId;
final ActivityTile body;
final Function(String)? onDismiss;
const DismissibleActivity(
this.activityId,
this.body, {
this.onDismiss,
super.key,
});
@override
Widget build(BuildContext context) {
return Dismissible(
key: Key(activityId),
dismissThresholds: const {
DismissDirection.horizontal: 0.7,
},
direction: DismissDirection.horizontal,
confirmDismiss: (direction) => onDismiss != null
? 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: (_) async => onDismiss?.call(activityId),
// LTR
background: _DismissBackground(withDeleteIcon: onDismiss != null),
// RTL
secondaryBackground: _DismissBackground(
withDeleteIcon: onDismiss != null,
alignment: AlignmentDirectional.centerEnd,
),
child: body,
);
}
}
class _DismissBackground extends StatelessWidget {
final AlignmentDirectional alignment;
final bool withDeleteIcon;
const _DismissBackground({
required this.withDeleteIcon,
this.alignment = AlignmentDirectional.centerStart,
});
@override
Widget build(BuildContext context) {
return Container(
alignment: alignment,
color: withDeleteIcon ? Colors.red[400] : Colors.grey[600],
child: withDeleteIcon
? const Padding(
padding: EdgeInsets.all(15),
child: Icon(
Icons.delete_sweep_rounded,
color: Colors.black,
),
)
: null,
);
}
}

View File

@ -1,6 +1,15 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
final currentAlbumProvider = StateProvider<Album?>((ref) {
return null;
});
part 'current_album.provider.g.dart';
@riverpod
class CurrentAlbum extends _$CurrentAlbum {
@override
Album? build() => null;
void set(Album? a) => state = a;
}
/// Mock class for testing
abstract class CurrentAlbumInternal extends _$CurrentAlbum {}

Binary file not shown.

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -104,7 +105,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
style: TextStyle(color: context.primaryColor),
),
onPressed: () {
context.autoPush(
context.pushRoute(
CreateAlbumRoute(
isSharedAlbum: false,
initialAssets: assets,

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -60,7 +61,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
behavior: HitTestBehavior.opaque,
onTap: onTap ??
() {
context.autoPush(AlbumViewerRoute(albumId: album.id));
context.pushRoute(AlbumViewerRoute(albumId: album.id));
},
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),

View File

@ -1,9 +1,10 @@
import 'package:auto_route/auto_route.dart';
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
@ -37,11 +38,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
final isProcessing = useProcessingOverlay();
final comments = album.shared
? ref.watch(
activityStatisticsStateProvider(
(albumId: album.remoteId!, assetId: null),
),
)
? ref.watch(activityStatisticsProvider(album.remoteId!))
: 0;
deleteAlbum() async {
@ -52,11 +49,11 @@ class AlbumViewerAppbar extends HookConsumerWidget
success =
await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
context
.autoNavigate(const TabControllerRoute(children: [SharingRoute()]));
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
} else {
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
context
.autoNavigate(const TabControllerRoute(children: [LibraryRoute()]));
.navigateTo(const TabControllerRoute(children: [LibraryRoute()]));
}
if (!success) {
ImmichToast.show(
@ -122,7 +119,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
if (isSuccess) {
context
.autoNavigate(const TabControllerRoute(children: [SharingRoute()]));
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
} else {
context.pop();
ImmichToast.show(
@ -175,7 +172,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
ListTile(
leading: const Icon(Icons.share_rounded),
onTap: () {
context.autoPush(SharedLinkEditRoute(albumId: album.remoteId));
context.pushRoute(SharedLinkEditRoute(albumId: album.remoteId));
context.pop();
},
title: const Text(
@ -185,7 +182,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
),
ListTile(
leading: const Icon(Icons.settings_rounded),
onTap: () => context.autoNavigate(AlbumOptionsRoute(album: album)),
onTap: () => context.navigateTo(AlbumOptionsRoute(album: album)),
title: const Text(
"translated_text_options",
style: TextStyle(fontWeight: FontWeight.w500),
@ -280,7 +277,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
);
} else {
return IconButton(
onPressed: () async => await context.autoPop(),
onPressed: () async => await context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
splashRadius: 25,
);

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -45,7 +46,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
await ref.read(sharedAlbumProvider.notifier).leaveAlbum(album);
if (isSuccess) {
context.autoNavigate(
context.navigateTo(
const TabControllerRoute(children: [SharingRoute()]),
);
} else {
@ -181,7 +182,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: () => context.autoPop(null),
onPressed: () => context.popRoute(null),
),
centerTitle: true,
title: Text("translated_text_options".tr()),

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -33,9 +36,12 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode();
final album = ref.watch(albumWatcher(albumId));
// Listen provider to prevent autoDispose when navigating to other routes from within the viewer page
ref.listen(currentAlbumProvider, (_, __) {});
album.whenData(
(value) =>
Future((() => ref.read(currentAlbumProvider.notifier).state = value)),
(value) => Future.microtask(
() => ref.read(currentAlbumProvider.notifier).set(value),
),
);
final userId = ref.watch(authenticationProvider).userId;
final isProcessing = useProcessingOverlay();
@ -62,7 +68,7 @@ class AlbumViewerPage extends HookConsumerWidget {
/// If they exist, add to selected asset state to show they are already selected.
void onAddPhotosPressed(Album albumInfo) async {
AssetSelectionPageResult? returnPayload =
await context.autoPush<AssetSelectionPageResult?>(
await context.pushRoute<AssetSelectionPageResult?>(
AssetSelectionRoute(
existingAssets: albumInfo.assets,
canDeselect: false,
@ -84,7 +90,7 @@ class AlbumViewerPage extends HookConsumerWidget {
}
void onAddUsersPressed(Album album) async {
List<String>? sharedUserIds = await context.autoPush<List<String>?>(
List<String>? sharedUserIds = await context.pushRoute<List<String>?>(
SelectAdditionalUserForSharingRoute(album: album),
);
@ -178,7 +184,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildSharedUserIconsRow(Album album) {
return GestureDetector(
onTap: () => context.autoPush(AlbumOptionsRoute(album: album)),
onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)),
child: SizedBox(
height: 50,
child: ListView.builder(
@ -214,13 +220,8 @@ class AlbumViewerPage extends HookConsumerWidget {
onActivitiesPressed(Album album) {
if (album.remoteId != null) {
context.autoPush(
ActivitiesRoute(
albumId: album.remoteId!,
appBarTitle: album.name,
isOwner: userId == album.ownerId,
isReadOnly: !album.activityEnabled,
),
context.pushRoute(
const ActivitiesRoute(),
);
}
}

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -36,7 +37,7 @@ class CreateAlbumPage extends HookConsumerWidget {
);
showSelectUserPage() async {
final bool? ok = await context.autoPush<bool?>(
final bool? ok = await context.pushRoute<bool?>(
SelectUserForSharingRoute(assets: selectedAssets.value),
);
if (ok == true) {
@ -58,7 +59,7 @@ class CreateAlbumPage extends HookConsumerWidget {
onSelectPhotosButtonPressed() async {
AssetSelectionPageResult? selectedAsset =
await context.autoPush<AssetSelectionPageResult?>(
await context.pushRoute<AssetSelectionPageResult?>(
AssetSelectionRoute(
existingAssets: selectedAssets.value,
canDeselect: true,
@ -202,7 +203,7 @@ class CreateAlbumPage extends HookConsumerWidget {
selectedAssets.value = {};
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
context.autoReplace(AlbumViewerRoute(albumId: newAlbum.id));
context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id));
}
}
@ -214,7 +215,7 @@ class CreateAlbumPage extends HookConsumerWidget {
leading: IconButton(
onPressed: () {
selectedAssets.value = {};
context.autoPop();
context.popRoute();
},
icon: const Icon(Icons.close_rounded),
),

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -102,7 +103,7 @@ class LibraryPage extends HookConsumerWidget {
return GestureDetector(
onTap: () =>
context.autoPush(CreateAlbumRoute(isSharedAlbum: false)),
context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)),
child: Padding(
padding:
const EdgeInsets.only(bottom: 32), // Adjust padding to suit
@ -190,7 +191,7 @@ class LibraryPage extends HookConsumerWidget {
Widget? shareTrashButton() {
return trashEnabled
? InkWell(
onTap: () => context.autoPush(const TrashRoute()),
onTap: () => context.pushRoute(const TrashRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: const Icon(
Icons.delete_rounded,
@ -219,12 +220,12 @@ class LibraryPage extends HookConsumerWidget {
children: [
buildLibraryNavButton(
"library_page_favorites".tr(), Icons.favorite_border, () {
context.autoNavigate(const FavoritesRoute());
context.navigateTo(const FavoritesRoute());
}),
const SizedBox(width: 12.0),
buildLibraryNavButton(
"library_page_archive".tr(), Icons.archive_outlined, () {
context.autoNavigate(const ArchiveRoute());
context.navigateTo(const ArchiveRoute());
}),
],
),
@ -270,7 +271,7 @@ class LibraryPage extends HookConsumerWidget {
return AlbumThumbnailCard(
album: sorted[index - 1],
onTap: () => context.autoPush(
onTap: () => context.pushRoute(
AlbumViewerRoute(
albumId: sorted[index - 1].id,
),
@ -314,7 +315,7 @@ class LibraryPage extends HookConsumerWidget {
childCount: local.length,
(context, index) => AlbumThumbnailCard(
album: local[index],
onTap: () => context.autoPush(
onTap: () => context.pushRoute(
AlbumViewerRoute(
albumId: local[index].id,
),

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -22,7 +23,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
final sharedUsersList = useState<Set<User>>({});
addNewUsersHandler() {
context.autoPop(sharedUsersList.value.map((e) => e.id).toList());
context.popRoute(sharedUsersList.value.map((e) => e.id).toList());
}
buildTileIcon(User user) {
@ -123,7 +124,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
leading: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
context.autoPop(null);
context.popRoute(null);
},
),
actions: [

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -35,9 +36,9 @@ class SelectUserForSharingPage extends HookConsumerWidget {
await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
// ref.watch(assetSelectionProvider.notifier).removeAll();
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
context.autoPop(true);
context.popRoute(true);
context
.autoNavigate(const TabControllerRoute(children: [SharingRoute()]));
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
}
ScaffoldMessenger(
@ -151,7 +152,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
leading: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () async {
context.autoPop();
context.popRoute();
},
),
actions: [

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -48,11 +49,9 @@ class SharingPage extends HookConsumerWidget {
return AlbumThumbnailCard(
album: sharedAlbums[index],
showOwner: true,
onTap: () {
context.autoPush(
AlbumViewerRoute(albumId: sharedAlbums[index].id),
);
},
onTap: () => context.pushRoute(
AlbumViewerRoute(albumId: sharedAlbums[index].id),
),
);
},
childCount: sharedAlbums.length,
@ -99,11 +98,8 @@ class SharingPage extends HookConsumerWidget {
style: context.textTheme.bodyMedium,
)
: null,
onTap: () {
context.autoPush(
AlbumViewerRoute(albumId: sharedAlbums[index].id),
);
},
onTap: () => context
.pushRoute(AlbumViewerRoute(albumId: sharedAlbums[index].id)),
);
},
childCount: sharedAlbums.length,
@ -124,9 +120,8 @@ class SharingPage extends HookConsumerWidget {
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () {
context.autoPush(CreateAlbumRoute(isSharedAlbum: true));
},
onPressed: () =>
context.pushRoute(CreateAlbumRoute(isSharedAlbum: true)),
icon: const Icon(
Icons.photo_album_outlined,
size: 20,
@ -144,7 +139,7 @@ class SharingPage extends HookConsumerWidget {
const SizedBox(width: 12.0),
Expanded(
child: ElevatedButton.icon(
onPressed: () => context.autoPush(const SharedLinkRoute()),
onPressed: () => context.pushRoute(const SharedLinkRoute()),
icon: const Icon(
Icons.link,
size: 20,
@ -214,7 +209,7 @@ class SharingPage extends HookConsumerWidget {
Widget sharePartnerButton() {
return InkWell(
onTap: () => context.autoPush(const PartnerRoute()),
onTap: () => context.pushRoute(const PartnerRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: const Icon(
Icons.swap_horizontal_circle_rounded,

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
@ -16,7 +16,7 @@ class ArchivePage extends HookConsumerWidget {
final count = archivedAssets.value?.totalAssets.toString() ?? "?";
return AppBar(
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
centerTitle: true,

View File

@ -0,0 +1,15 @@
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'current_asset.provider.g.dart';
@riverpod
class CurrentAsset extends _$CurrentAsset {
@override
Asset? build() => null;
void set(Asset? a) => state = a;
}
/// Mock class for testing
abstract class CurrentAssetInternal extends _$CurrentAsset {}

View File

@ -1,7 +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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
@ -39,12 +39,8 @@ class TopControlAppBar extends HookConsumerWidget {
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
final album = ref.watch(currentAlbumProvider);
final comments = album != null && album.remoteId != null
? ref.watch(
activityStatisticsStateProvider(
(albumId: album.remoteId!, assetId: asset.remoteId),
),
)
final comments = album != null
? ref.watch(activityStatisticsProvider(album.remoteId!, asset.remoteId))
: 0;
Widget buildFavoriteButton(a) {
@ -149,7 +145,7 @@ class TopControlAppBar extends HookConsumerWidget {
Widget buildBackButton() {
return IconButton(
onPressed: () {
context.autoPop();
context.popRoute();
},
icon: Icon(
Icons.arrow_back_ios_new_rounded,

View File

@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
@ -106,6 +107,19 @@ class GalleryViewerPage extends HookConsumerWidget {
bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
ref.listen(currentAssetProvider, (_, __) {});
useEffect(
() {
// Delay state update to after the execution of build method
Future.microtask(
() => ref.read(currentAssetProvider.notifier).set(asset()),
);
return null;
},
[asset()],
);
useEffect(
() {
isLoadPreview.value =
@ -214,7 +228,7 @@ class GalleryViewerPage extends HookConsumerWidget {
if (isDeleted && isParent) {
if (totalAssets == 1) {
// Handle only one asset
context.autoPop();
context.popRoute();
} else {
// Go to next page otherwise
controller.nextPage(
@ -298,7 +312,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final ratio = d.dy / max(d.dx.abs(), 1);
if (d.dy > sensitivity && ratio > ratioThreshold) {
context.autoPop();
context.popRoute();
} else if (d.dy < -sensitivity && ratio < -ratioThreshold) {
showInfo();
}
@ -311,7 +325,7 @@ class GalleryViewerPage extends HookConsumerWidget {
handleArchive(Asset asset) {
ref.watch(assetProvider.notifier).toggleArchive([asset]);
if (isParent) {
context.autoPop();
context.popRoute();
return;
}
removeAssetFromStack();
@ -334,14 +348,7 @@ class GalleryViewerPage extends HookConsumerWidget {
handleActivities() {
if (album != null && album.shared && album.remoteId != null) {
context.autoPush(
ActivitiesRoute(
albumId: album.remoteId!,
assetId: asset().remoteId,
withAssetThumbs: false,
isOwner: isOwner,
),
);
context.pushRoute(const ActivitiesRoute());
}
}
@ -517,7 +524,7 @@ class GalleryViewerPage extends HookConsumerWidget {
stackElements.elementAt(stackIndex.value),
);
ctx.pop();
context.autoPop();
context.popRoute();
},
title: const Text(
"viewer_stack_use_as_main_asset",
@ -544,7 +551,7 @@ class GalleryViewerPage extends HookConsumerWidget {
childrenToRemove: [currentAsset],
);
ctx.pop();
context.autoPop();
context.popRoute();
} else {
await ref.read(assetStackServiceProvider).updateStack(
currentAsset,
@ -572,7 +579,7 @@ class GalleryViewerPage extends HookConsumerWidget {
childrenToRemove: stack,
);
ctx.pop();
context.autoPop();
context.popRoute();
},
title: const Text(
"viewer_unstack",

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -201,7 +202,7 @@ class AlbumInfoCard extends HookConsumerWidget {
),
IconButton(
onPressed: () {
context.autoPush(
context.pushRoute(
AlbumPreviewRoute(album: albumInfo.albumEntity),
);
},

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -134,7 +135,7 @@ class AlbumInfoListTile extends HookConsumerWidget {
subtitle: Text(assetCount.value.toString()),
trailing: IconButton(
onPressed: () {
context.autoPush(
context.pushRoute(
AlbumPreviewRoute(album: albumInfo.albumEntity),
);
},

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -56,9 +57,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
args: [ref.watch(errorBackupListProvider).length.toString()],
),
backgroundColor: Colors.white,
onPressed: () {
context.autoPush(const FailedBackupStatusRoute());
},
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
);
}

View File

@ -1,9 +1,9 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
@ -53,7 +53,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
],
),
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
),

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -193,7 +194,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
title: const Text(

View File

@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -151,7 +152,7 @@ class BackupControllerPage extends HookConsumerWidget {
),
trailing: ElevatedButton(
onPressed: () async {
await context.autoPush(const BackupAlbumSelectionRoute());
await context.pushRoute(const BackupAlbumSelectionRoute());
// waited until returning from selection
await ref
.read(backupProvider.notifier)
@ -242,7 +243,7 @@ class BackupControllerPage extends HookConsumerWidget {
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
context.autoPop(true);
context.popRoute(true);
},
splashRadius: 24,
icon: const Icon(
@ -253,7 +254,7 @@ class BackupControllerPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
onPressed: () => context.autoPush(const BackupOptionsRoute()),
onPressed: () => context.pushRoute(const BackupOptionsRoute()),
splashRadius: 24,
icon: const Icon(
Icons.settings_outlined,

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -487,9 +488,7 @@ class BackupOptionsPage extends HookConsumerWidget {
"Backup options",
),
leading: IconButton(
onPressed: () {
context.autoPop(true);
},
onPressed: () => context.popRoute(true),
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@ -20,7 +21,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
),
leading: IconButton(
onPressed: () {
context.autoPop(true);
context.popRoute(true);
},
splashRadius: 24,
icon: const Icon(

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
@ -14,7 +14,7 @@ class FavoritesPage extends HookConsumerWidget {
AppBar buildAppBar() {
return AppBar(
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
centerTitle: true,

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@ -174,7 +175,7 @@ class ThumbnailImage extends StatelessWidget {
onSelect?.call();
}
} else {
context.autoPush(
context.pushRoute(
GalleryViewerRoute(
initialIndex: index,
loadAsset: loadAsset,

View File

@ -1,4 +1,5 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
@ -157,7 +158,7 @@ class LoginForm extends HookConsumerWidget {
// Resume backup (if enable) then navigate
if (ref.read(authenticationProvider).shouldChangePassword &&
!ref.read(authenticationProvider).isAdmin) {
context.autoPush(const ChangePasswordRoute());
context.pushRoute(const ChangePasswordRoute());
} else {
final hasPermission = await ref
.read(galleryPermissionNotifier.notifier)
@ -166,7 +167,7 @@ class LoginForm extends HookConsumerWidget {
// Don't resume the backup until we have gallery permission
ref.read(backupProvider.notifier).resumeBackup();
}
context.autoReplace(const TabControllerRoute());
context.replaceRoute(const TabControllerRoute());
}
} else {
ImmichToast.show(
@ -218,7 +219,7 @@ class LoginForm extends HookConsumerWidget {
if (permission.isGranted || permission.isLimited) {
ref.watch(backupProvider.notifier).resumeBackup();
}
context.autoReplace(const TabControllerRoute());
context.replaceRoute(const TabControllerRoute());
} else {
ImmichToast.show(
context: context,
@ -264,7 +265,7 @@ class LoginForm extends HookConsumerWidget {
),
),
),
onPressed: () => context.autoPush(const SettingsRoute()),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const SizedBox.shrink(),
),

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -53,7 +54,7 @@ class LoginPage extends HookConsumerWidget {
),
),
onTap: () {
context.autoPush(const AppLogRoute());
context.pushRoute(const AppLogRoute());
},
),
],

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -90,12 +91,12 @@ class MapLocationPickerPage extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => context.autoPop(selectedLatLng.value),
onPressed: () => context.popRoute(selectedLatLng.value),
child: const Text("map_location_picker_page_use_location")
.tr(),
),
ElevatedButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
style: ElevatedButton.styleFrom(
backgroundColor: context.colorScheme.error,
),

View File

@ -1,8 +1,8 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/map/ui/map_settings_dialog.dart';
@ -30,7 +30,7 @@ class MapAppBar extends HookWidget implements PreferredSizeWidget {
Padding(
padding: const EdgeInsets.only(left: 15, top: 15),
child: ElevatedButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -102,7 +103,7 @@ class MapPageState extends ConsumerState<MapPage> {
}
void openAssetInViewer(Asset asset) {
context.autoPush(
context.pushRoute(
GalleryViewerRoute(
initialIndex: 0,
loadAsset: (index) => asset,

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
@ -31,7 +31,7 @@ class MemoryLane extends HookConsumerWidget {
child: GestureDetector(
onTap: () {
HapticFeedback.heavyImpact();
context.autoPush(
context.pushRoute(
MemoryRoute(
memories: memories,
memoryIndex: index,

View File

@ -1,8 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/modules/memories/ui/memory_card.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@ -182,14 +182,14 @@ class MemoryPage extends HookConsumerWidget {
currentMemory.value.assets.length;
if (isLastAsset &&
(offset > notification.metrics.maxScrollExtent + 150)) {
context.autoPop();
context.popRoute();
return true;
}
}
// Horizontal scroll handling
if (notification.depth == 1 &&
(offset > notification.metrics.maxScrollExtent + 100)) {
context.autoPop();
context.popRoute();
return true;
}
}
@ -244,7 +244,7 @@ class MemoryPage extends HookConsumerWidget {
child: MemoryCard(
asset: asset,
onTap: () => toNextAsset(index),
onClose: () => context.autoPop(),
onClose: () => context.popRoute(),
rightCornerText: assetProgress.value,
title: memories[mIndex].title,
showTitle: index == 0,

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -16,7 +17,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
final PermissionStatus permission = ref.watch(galleryPermissionNotifier);
// Navigate to the main Tab Controller when permission is granted
void goToBackup() => context.autoReplace(const BackupControllerRoute());
void goToBackup() => context.replaceRoute(const BackupControllerRoute());
// When the permission is denied, we show a request permission page
buildRequestPermission() {
@ -174,7 +175,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
),
TextButton(
child: const Text('permission_onboarding_back').tr(),
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
),
],
),

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@ -36,7 +37,7 @@ class PartnerList extends HookConsumerWidget {
color: context.primaryColor,
),
),
onTap: () => context.autoPush((PartnerDetailRoute(partner: p))),
onTap: () => context.pushRoute((PartnerDetailRoute(partner: p))),
);
}
}

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@ -26,7 +27,7 @@ class CuratedPlacesRow extends CuratedRow {
final int actualContentIndex = isMapEnabled ? 1 : 0;
Widget buildMapThumbnail() {
return GestureDetector(
onTap: () => context.autoPush(
onTap: () => context.pushRoute(
const MapRoute(),
),
child: SizedBox.square(

View File

@ -1,5 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
@ -50,13 +50,13 @@ class ExploreGrid extends StatelessWidget {
borderRadius: 0,
onTap: () {
isPeople
? context.autoPush(
? context.pushRoute(
PersonResultRoute(
personId: content.id,
personName: content.label,
),
)
: context.autoPush(
: context.pushRoute(
SearchResultRoute(searchTerm: 'm:${content.label}'),
);
},

View File

@ -1,8 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/search/providers/all_motion_photos.provider.dart';
@ -17,7 +17,7 @@ class AllMotionPhotosPage extends HookConsumerWidget {
appBar: AppBar(
title: const Text('motion_photos_page_title').tr(),
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),

View File

@ -1,8 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
@ -19,7 +19,7 @@ class AllPeoplePage extends HookConsumerWidget {
'all_people_page_title',
).tr(),
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),

View File

@ -1,8 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/search/providers/all_video_assets.provider.dart';
@ -17,7 +17,7 @@ class AllVideosPage extends HookConsumerWidget {
appBar: AppBar(
title: const Text('all_videos_page_title').tr(),
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),

View File

@ -1,8 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
@ -22,7 +22,7 @@ class CuratedLocationPage extends HookConsumerWidget {
'curated_location_page_title',
).tr(),
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -101,7 +102,7 @@ class PersonResultPage extends HookConsumerWidget {
appBar: AppBar(
title: Text(name.value),
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
actions: [

View File

@ -1,8 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/search/providers/recently_added.provider.dart';
@ -17,7 +17,7 @@ class RecentlyAddedPage extends HookConsumerWidget {
appBar: AppBar(
title: const Text('recently_added_page_title').tr(),
leading: IconButton(
onPressed: () => context.autoPop(),
onPressed: () => context.popRoute(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),

View File

@ -1,4 +1,5 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
@ -52,7 +53,7 @@ class SearchPage extends HookConsumerWidget {
searchFocusNode.unfocus();
ref.watch(searchPageStateProvider.notifier).disableSearch();
context.autoPush(
context.pushRoute(
SearchResultRoute(
searchTerm: searchTerm,
),
@ -79,7 +80,7 @@ class SearchPage extends HookConsumerWidget {
onData: (people) => CuratedPeopleRow(
content: people.take(12).toList(),
onTap: (content, index) {
context.autoPush(
context.pushRoute(
PersonResultRoute(
personId: content.id,
personName: content.label,
@ -111,7 +112,7 @@ class SearchPage extends HookConsumerWidget {
.toList(),
imageSize: imageSize,
onTap: (content, index) {
context.autoPush(
context.pushRoute(
SearchResultRoute(
searchTerm: 'm:${content.label}',
),
@ -139,13 +140,13 @@ class SearchPage extends HookConsumerWidget {
SearchRowTitle(
title: "search_page_people".tr(),
onViewAllPressed: () =>
context.autoPush(const AllPeopleRoute()),
context.pushRoute(const AllPeopleRoute()),
),
buildPeople(),
SearchRowTitle(
title: "search_page_places".tr(),
onViewAllPressed: () =>
context.autoPush(const CuratedLocationRoute()),
context.pushRoute(const CuratedLocationRoute()),
top: 0,
),
const SizedBox(height: 10.0),
@ -168,7 +169,7 @@ class SearchPage extends HookConsumerWidget {
title:
Text('search_page_favorites', style: categoryTitleStyle)
.tr(),
onTap: () => context.autoPush(const FavoritesRoute()),
onTap: () => context.pushRoute(const FavoritesRoute()),
),
const CategoryDivider(),
ListTile(
@ -180,7 +181,7 @@ class SearchPage extends HookConsumerWidget {
'search_page_recently_added',
style: categoryTitleStyle,
).tr(),
onTap: () => context.autoPush(const RecentlyAddedRoute()),
onTap: () => context.pushRoute(const RecentlyAddedRoute()),
),
const SizedBox(height: 24.0),
Padding(
@ -200,7 +201,7 @@ class SearchPage extends HookConsumerWidget {
Icons.screenshot,
color: categoryIconColor,
),
onTap: () => context.autoPush(
onTap: () => context.pushRoute(
SearchResultRoute(
searchTerm: 'screenshots',
),
@ -214,7 +215,7 @@ class SearchPage extends HookConsumerWidget {
Icons.photo_camera_front_outlined,
color: categoryIconColor,
),
onTap: () => context.autoPush(
onTap: () => context.pushRoute(
SearchResultRoute(
searchTerm: 'selfies',
),
@ -228,7 +229,7 @@ class SearchPage extends HookConsumerWidget {
Icons.play_circle_outline,
color: categoryIconColor,
),
onTap: () => context.autoPush(const AllVideosRoute()),
onTap: () => context.pushRoute(const AllVideosRoute()),
),
const CategoryDivider(),
ListTile(
@ -240,7 +241,7 @@ class SearchPage extends HookConsumerWidget {
Icons.motion_photos_on_outlined,
color: categoryIconColor,
),
onTap: () => context.autoPush(const AllMotionPhotosRoute()),
onTap: () => context.pushRoute(const AllMotionPhotosRoute()),
),
],
),

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -185,7 +186,7 @@ class SearchResultPage extends HookConsumerWidget {
if (isNewSearch.value) {
isNewSearch.value = false;
} else {
context.autoPop(true);
context.popRoute(true);
}
},
icon: const Icon(Icons.arrow_back_ios_rounded),

View File

@ -1,4 +1,5 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -210,8 +211,8 @@ class SharedLinkItem extends ConsumerWidget {
tapTargetSize:
MaterialTapTargetSize.shrinkWrap, // the '2023' part
),
onPressed: () =>
context.autoPush(SharedLinkEditRoute(existingLink: sharedLink)),
onPressed: () => context
.pushRoute(SharedLinkEditRoute(existingLink: sharedLink)),
),
IconButton(
splashRadius: 25,

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -317,7 +318,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
alignment: Alignment.bottomRight,
child: ElevatedButton(
onPressed: () {
context.autoPop();
context.popRoute();
},
child: const Text(
"share_done",
@ -417,7 +418,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
changeExpiry: changeExpiry,
);
ref.invalidate(sharedLinksStateProvider);
context.autoPop();
context.popRoute();
}
return Scaffold(

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -138,7 +139,7 @@ class TrashPage extends HookConsumerWidget {
return AppBar(
leading: IconButton(
onPressed: !selectionEnabledHook.value
? () => context.autoPop()
? () => context.popRoute()
: () {
selectionEnabledHook.value = false;
selection.value = {};

View File

@ -340,18 +340,9 @@ 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,
isReadOnly: args.isReadOnly,
key: args.key,
),
child: const ActivitiesPage(),
transitionsBuilder: TransitionsBuilders.slideLeft,
durationInMilliseconds: 200,
opaque: true,
@ -1587,63 +1578,16 @@ 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,
bool isReadOnly = false,
Key? key,
}) : super(
class ActivitiesRoute extends PageRouteInfo<void> {
const ActivitiesRoute()
: super(
ActivitiesRoute.name,
path: '/activities-page',
args: ActivitiesRouteArgs(
albumId: albumId,
appBarTitle: appBarTitle,
assetId: assetId,
withAssetThumbs: withAssetThumbs,
isOwner: isOwner,
isReadOnly: isReadOnly,
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.isReadOnly = false,
this.key,
});
final String albumId;
final String appBarTitle;
final String? assetId;
final bool withAssetThumbs;
final bool isOwner;
final bool isReadOnly;
final Key? key;
@override
String toString() {
return 'ActivitiesRouteArgs{albumId: $albumId, appBarTitle: $appBarTitle, assetId: $assetId, withAssetThumbs: $withAssetThumbs, isOwner: $isOwner, isReadOnly: $isReadOnly, key: $key}';
}
}
/// generated route for
/// [MapLocationPickerPage]
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {

View File

@ -51,6 +51,21 @@ class User {
avatarColor = dto.avatarColor.toAvatarColor(),
inTimeline = dto.inTimeline ?? false;
/// Base user dto used where the complete user object is not required
User.fromSimpleUserDto(UserDto dto)
: id = dto.id,
email = dto.email,
name = dto.name,
profileImagePath = dto.profileImagePath,
avatarColor = dto.avatarColor.toAvatarColor(),
// Fill the remaining fields with placeholders
isAdmin = false,
inTimeline = false,
memoryEnabled = false,
isPartnerSharedBy = false,
isPartnerSharedWith = false,
updatedAt = DateTime.now();
@Index(unique: true, replace: false, type: IndexType.hash)
String id;
DateTime updatedAt;

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -90,7 +91,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
return buildActionButton(
Icons.settings_rounded,
"profile_drawer_settings",
() => context.autoPush(const SettingsRoute()),
() => context.pushRoute(const SettingsRoute()),
);
}
@ -98,7 +99,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
return buildActionButton(
Icons.assignment_outlined,
"profile_drawer_app_logs",
() => context.autoPush(const AppLogRoute()),
() => context.pushRoute(const AppLogRoute()),
);
}
@ -121,7 +122,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
context.autoReplace(const LoginRoute());
context.replaceRoute(const LoginRoute());
},
);
},

View File

@ -1,12 +1,12 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
@ -158,7 +158,7 @@ class MultiselectGrid extends HookConsumerWidget {
final ids =
remoteSelection(errorMessage: "home_page_share_err_local".tr())
.map((e) => e.remoteId!);
context.autoPush(SharedLinkEditRoute(assetsList: ids.toList()));
context.pushRoute(SharedLinkEditRoute(assetsList: ids.toList()));
}
processing.value = false;
selectionEnabledHook.value = false;
@ -301,7 +301,7 @@ class MultiselectGrid extends HookConsumerWidget {
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
selectionEnabledHook.value = false;
context.autoPush(AlbumViewerRoute(albumId: result.id));
context.pushRoute(AlbumViewerRoute(albumId: result.id));
}
} finally {
processing.value = false;

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@ -106,7 +107,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
final badgeBackground = isDarkTheme ? Colors.blueGrey[800] : Colors.white;
return InkWell(
onTap: () => context.autoPush(const BackupControllerRoute()),
onTap: () => context.pushRoute(const BackupControllerRoute()),
borderRadius: BorderRadius.circular(12),
child: Badge(
label: Container(

View File

@ -162,6 +162,19 @@ class ImmichImage extends StatelessWidget {
headers: authHeader,
);
/// TODO: refactor image providers to separate class
static CachedNetworkImageProvider remoteThumbnailProviderForId(
String assetId, {
api.ThumbnailFormat type = api.ThumbnailFormat.WEBP,
}) =>
CachedNetworkImageProvider(
getThumbnailUrlForRemoteId(assetId, type: type),
cacheKey: getThumbnailCacheKeyForRemoteId(assetId, type: type),
headers: {
"Authorization": 'Bearer ${Store.get(StoreKey.accessToken)}',
},
);
/// Precaches this asset for instant load the next time it is shown
static Future<void> precacheAsset(
Asset asset,

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -97,7 +98,7 @@ class _LocationPicker extends HookWidget {
zoom: 6,
showAttribution: false,
onTap: (p0, p1) async {
final newLatLng = await context.autoPush<LatLng?>(
final newLatLng = await context.pushRoute<LatLng?>(
MapLocationPickerRoute(initialLatLng: latlng),
);
if (newLatLng != null) {

View File

@ -5,8 +5,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
// Error widget to be used in Scaffold when an AsyncError is received
class ScaffoldErrorBody extends StatelessWidget {
final bool withIcon;
final String? errorMsg;
const ScaffoldErrorBody({super.key, this.withIcon = true});
const ScaffoldErrorBody({super.key, this.withIcon = true, this.errorMsg});
@override
Widget build(BuildContext context) {
@ -30,6 +31,15 @@ class ScaffoldErrorBody extends StatelessWidget {
),
),
),
if (withIcon && errorMsg != null)
Padding(
padding: const EdgeInsets.all(20),
child: Text(
errorMsg!,
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
),
],
);
}

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -103,7 +104,7 @@ class AppLogPage extends HookConsumerWidget {
],
leading: IconButton(
onPressed: () {
context.autoPop();
context.popRoute();
},
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
@ -123,7 +124,7 @@ class AppLogPage extends HookConsumerWidget {
itemBuilder: (context, index) {
var logMessage = logMessages.value[index];
return ListTile(
onTap: () => context.autoPush(
onTap: () => context.pushRoute(
AppLogDetailRoute(
logMessage: logMessage,
),

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
@ -57,14 +57,14 @@ class SplashScreenPage extends HookConsumerWidget {
stackTrace,
);
context.autoPush(const LoginRoute());
context.pushRoute(const LoginRoute());
}
}
// If the device is offline and there is a currentUser stored locallly
// Proceed into the app
if (deviceIsOffline && Store.tryGet(StoreKey.currentUser) != null) {
context.autoReplace(const TabControllerRoute());
context.replaceRoute(const TabControllerRoute());
} else if (isSuccess) {
// If device was able to login through the internet successfully
final hasPermission =
@ -73,10 +73,10 @@ class SplashScreenPage extends HookConsumerWidget {
// Resume backup (if enable) then navigate
ref.watch(backupProvider.notifier).resumeBackup();
}
context.autoReplace(const TabControllerRoute());
context.replaceRoute(const TabControllerRoute());
} else {
// User was unable to login through either offline or online methods
context.autoReplace(const LoginRoute());
context.replaceRoute(const LoginRoute());
}
}
@ -85,7 +85,7 @@ class SplashScreenPage extends HookConsumerWidget {
if (serverUrl != null && accessToken != null) {
performLoggingIn();
} else {
context.autoReplace(const LoginRoute());
context.replaceRoute(const LoginRoute());
}
return null;
},

View File

@ -50,5 +50,8 @@ final class AlbumStub {
activityEnabled: false,
startDate: DateTime(2019),
endDate: DateTime(2020),
)..assets.addAll([AssetStub.image1, AssetStub.image2]);
)
..assets.addAll([AssetStub.image1, AssetStub.image2])
..activityEnabled = true
..owner.value = UserStub.admin;
}

View File

@ -6,6 +6,7 @@ final class AssetStub {
static final image1 = Asset(
checksum: "image1-checksum",
localId: "image1",
remoteId: 'image1-remote',
ownerId: 1,
fileCreatedAt: DateTime.now(),
fileModifiedAt: DateTime.now(),
@ -22,6 +23,7 @@ final class AssetStub {
static final image2 = Asset(
checksum: "image2-checksum",
localId: "image2",
remoteId: 'image2-remote',
ownerId: 1,
fileCreatedAt: DateTime(2000),
fileModifiedAt: DateTime(2010),

View File

@ -8,6 +8,8 @@ final class UserStub {
updatedAt: DateTime(2021),
email: "admin@test.com",
name: "admin",
avatarColor: AvatarColorEnum.green,
profileImagePath: '',
isAdmin: true,
);
@ -16,6 +18,18 @@ final class UserStub {
updatedAt: DateTime(2022),
email: "user1@test.com",
name: "user1",
avatarColor: AvatarColorEnum.red,
profileImagePath: '',
isAdmin: false,
);
static final user2 = User(
id: "user2",
updatedAt: DateTime(2023),
email: "user2@test.com",
name: "user2",
avatarColor: AvatarColorEnum.primary,
profileImagePath: '',
isAdmin: false,
);
}

View File

@ -0,0 +1,67 @@
import 'dart:io';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
import 'package:mocktail/mocktail.dart';
/// Mocks the http client to always return a transparent image for all the requests. Only useful in widget
/// tests to return network images
class MockHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
final client = _MockHttpClient();
final request = _MockHttpClientRequest();
final response = _MockHttpClientResponse();
final headers = _MockHttpHeaders();
// Client mocks
when(() => client.autoUncompress).thenReturn(true);
// Request mocks
when(() => request.headers).thenAnswer((_) => headers);
when(() => request.close())
.thenAnswer((_) => Future<HttpClientResponse>.value(response));
// Response mocks
when(() => response.statusCode).thenReturn(HttpStatus.ok);
when(() => response.compressionState)
.thenReturn(HttpClientResponseCompressionState.decompressed);
when(() => response.contentLength)
.thenAnswer((_) => kTransparentImage.length);
when(
() => response.listen(
captureAny(),
cancelOnError: captureAny(named: 'cancelOnError'),
onDone: captureAny(named: 'onDone'),
onError: captureAny(named: 'onError'),
),
).thenAnswer((invocation) {
final onData =
invocation.positionalArguments[0] as void Function(List<int>);
final onDone = invocation.namedArguments[#onDone] as void Function();
final onError = invocation.namedArguments[#onError] as void
Function(Object, [StackTrace]);
final cancelOnError = invocation.namedArguments[#cancelOnError] as bool;
return Stream<List<int>>.fromIterable([kTransparentImage.toList()])
.listen(
onData,
onDone: onDone,
onError: onError,
cancelOnError: cancelOnError,
);
});
return client;
}
}
class _MockHttpClient extends Mock implements HttpClient {}
class _MockHttpClientRequest extends Mock implements HttpClientRequest {}
class _MockHttpClientResponse extends Mock implements HttpClientResponse {}
class _MockHttpHeaders extends Mock implements HttpHeaders {}

View File

@ -1,9 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:mocktail/mocktail.dart';
class AppSettingsServiceMock with Mock implements AppSettingsService {}
Override getAppSettingsServiceMock(AppSettingsService service) =>
appSettingsServiceProvider.overrideWith((ref) => service);

View File

@ -0,0 +1,250 @@
@Tags(['widget'])
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/modules/activities/views/activities_page.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../fixtures/album.stub.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
import '../asset_viewer/asset_viewer_mocks.dart';
import '../album/album_mocks.dart';
import '../shared/shared_mocks.dart';
import 'activity_mocks.dart';
final _activities = [
Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.comment,
comment: 'First Activity',
assetId: 'asset-2',
user: UserStub.admin,
),
Activity(
id: '2',
createdAt: DateTime(200),
type: ActivityType.comment,
comment: 'Second Activity',
user: UserStub.user1,
),
Activity(
id: '3',
createdAt: DateTime(300),
type: ActivityType.like,
assetId: 'asset-1',
user: UserStub.user2,
),
Activity(
id: '4',
createdAt: DateTime(400),
type: ActivityType.like,
user: UserStub.user1,
),
];
void main() {
late MockAlbumActivity activityMock;
late MockCurrentAlbumProvider mockCurrentAlbumProvider;
late MockCurrentAssetProvider mockCurrentAssetProvider;
late List<Override> overrides;
late Isar db;
setUpAll(() async {
TestUtils.init();
db = await TestUtils.initIsar();
Store.init(db);
Store.put(StoreKey.currentUser, UserStub.admin);
Store.put(StoreKey.serverEndpoint, '');
Store.put(StoreKey.accessToken, '');
});
setUp(() async {
mockCurrentAlbumProvider = MockCurrentAlbumProvider(AlbumStub.twoAsset);
mockCurrentAssetProvider = MockCurrentAssetProvider(AssetStub.image1);
activityMock = MockAlbumActivity(_activities);
overrides = [
albumActivityProvider(
AlbumStub.twoAsset.remoteId!,
AssetStub.image1.remoteId!,
).overrideWith(() => activityMock),
currentAlbumProvider.overrideWith(() => mockCurrentAlbumProvider),
currentAssetProvider.overrideWith(() => mockCurrentAssetProvider),
];
await db.writeTxn(() async {
await db.clear();
// Save all assets
await db.users.put(UserStub.admin);
await db.assets.putAll([AssetStub.image1, AssetStub.image2]);
await db.albums.put(AlbumStub.twoAsset);
await AlbumStub.twoAsset.owner.save();
await AlbumStub.twoAsset.assets.save();
});
expect(db.albums.countSync(), 1);
expect(db.assets.countSync(), 2);
expect(db.users.countSync(), 1);
});
group("App bar", () {
testWidgets(
"No title when currentAsset != null",
(tester) async {
await tester.pumpConsumerWidget(
const ActivitiesPage(),
overrides: overrides,
);
final listTile = tester.widget<AppBar>(find.byType(AppBar));
expect(listTile.title, isNull);
},
);
testWidgets(
"Album name as title when currentAsset == null",
(tester) async {
await tester.pumpConsumerWidget(
const ActivitiesPage(),
overrides: overrides,
);
await tester.pumpAndSettle();
mockCurrentAssetProvider.state = null;
await tester.pumpAndSettle();
expect(find.text(AlbumStub.twoAsset.name), findsOneWidget);
final listTile = tester.widget<AppBar>(find.byType(AppBar));
expect(listTile.title, isNotNull);
},
);
});
group("Body", () {
testWidgets(
"Contains a stack with Activity List and Activity Input",
(tester) async {
await tester.pumpConsumerWidget(
const ActivitiesPage(),
overrides: overrides,
);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(Stack),
matching: find.byType(ActivityTextField),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byType(Stack),
matching: find.byType(ListView),
),
findsOneWidget,
);
},
);
testWidgets(
"List Contains all dismissible activities",
(tester) async {
await tester.pumpConsumerWidget(
const ActivitiesPage(),
overrides: overrides,
);
await tester.pumpAndSettle();
final listFinder = find.descendant(
of: find.byType(Stack),
matching: find.byType(ListView),
);
final listChildren = find.descendant(
of: listFinder,
matching: find.byType(DismissibleActivity),
);
expect(listChildren, findsNWidgets(_activities.length));
},
);
testWidgets(
"Submitting text input adds a comment with the text",
(tester) async {
await tester.pumpConsumerWidget(
const ActivitiesPage(),
overrides: overrides,
);
await tester.pumpAndSettle();
when(() => activityMock.addComment(any()))
.thenAnswer((_) => Future.value());
final textField = find.byType(TextField);
await tester.enterText(textField, 'Test comment');
await tester.testTextInput.receiveAction(TextInputAction.done);
verify(() => activityMock.addComment('Test comment'));
},
);
testWidgets(
"Owner can remove all activities",
(tester) async {
await tester.pumpConsumerWidget(
const ActivitiesPage(),
overrides: overrides,
);
await tester.pumpAndSettle();
final deletableActivityFinder = find.byWidgetPredicate(
(widget) => widget is DismissibleActivity && widget.onDismiss != null,
);
expect(deletableActivityFinder, findsNWidgets(_activities.length));
},
);
testWidgets(
"Non-Owner can remove only their activities",
(tester) async {
final mockCurrentUser = MockCurrentUserProvider();
await tester.pumpConsumerWidget(
const ActivitiesPage(),
overrides: [
...overrides,
currentUserProvider.overrideWith((ref) => mockCurrentUser),
],
);
mockCurrentUser.state = UserStub.user1;
await tester.pumpAndSettle();
final deletableActivityFinder = find.byWidgetPredicate(
(widget) => widget is DismissibleActivity && widget.onDismiss != null,
);
expect(
deletableActivityFinder,
findsNWidgets(
_activities.where((a) => a.user == UserStub.user1).length,
),
);
},
);
});
}

View File

@ -0,0 +1,23 @@
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/modules/activities/services/activity.service.dart';
import 'package:mocktail/mocktail.dart';
class ActivityServiceMock extends Mock implements ActivityService {}
class MockAlbumActivity extends AlbumActivityInternal
with Mock
implements AlbumActivity {
List<Activity>? initActivities;
MockAlbumActivity([this.initActivities]);
@override
Future<List<Activity>> build(String albumId, [String? assetId]) async {
return initActivities ?? [];
}
}
class ActivityStatisticsMock extends ActivityStatisticsInternal
with Mock
implements ActivityStatistics {}

View File

@ -0,0 +1,353 @@
import 'package:flutter_test/flutter_test.dart';
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/modules/activities/providers/activity_service.provider.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import 'activity_mocks.dart';
final _activities = [
Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.comment,
comment: 'First Activity',
assetId: 'asset-2',
user: UserStub.admin,
),
Activity(
id: '2',
createdAt: DateTime(200),
type: ActivityType.comment,
comment: 'Second Activity',
user: UserStub.user1,
),
Activity(
id: '3',
createdAt: DateTime(300),
type: ActivityType.like,
assetId: 'asset-1',
user: UserStub.admin,
),
Activity(
id: '4',
createdAt: DateTime(400),
type: ActivityType.like,
user: UserStub.user1,
),
];
void main() {
late ActivityServiceMock activityMock;
late ActivityStatisticsMock activityStatisticsMock;
late ProviderContainer container;
late AlbumActivityProvider provider;
late ListenerMock<AsyncValue<List<Activity>>> listener;
setUpAll(() {
registerFallbackValue(AsyncData<List<Activity>>([..._activities]));
});
setUp(() async {
activityMock = ActivityServiceMock();
activityStatisticsMock = ActivityStatisticsMock();
container = TestUtils.createContainer(
overrides: [
activityServiceProvider.overrideWith((ref) => activityMock),
activityStatisticsProvider('test-album', 'test-asset')
.overrideWith(() => activityStatisticsMock),
],
);
// Mock values
when(
() => activityMock.getAllActivities('test-album', assetId: 'test-asset'),
).thenAnswer((_) async => [..._activities]);
// Init and wait for providers future to complete
provider = albumActivityProvider('test-album', 'test-asset');
listener = ListenerMock();
container.listen(
provider,
listener,
fireImmediately: true,
);
await container.read(provider.future);
});
test('Returns a list of activity', () async {
verifyInOrder([
() => listener.call(null, const AsyncLoading()),
() => listener.call(
const AsyncLoading(),
any(
that: allOf(
[
isA<AsyncData<List<Activity>>>(),
predicate(
(AsyncData<List<Activity>> ad) =>
ad.requireValue.every((e) => _activities.contains(e)),
),
],
),
),
),
]);
verifyNoMoreInteractions(listener);
});
group('addLike()', () {
test('Like successfully added', () async {
final like = Activity(
id: '5',
createdAt: DateTime(2023),
type: ActivityType.like,
user: UserStub.admin,
);
when(
() => activityMock.addActivity(
'test-album',
ActivityType.like,
assetId: 'test-asset',
),
).thenAnswer((_) async => AsyncData(like));
await container.read(provider.notifier).addLike();
verify(
() => activityMock.addActivity(
'test-album',
ActivityType.like,
assetId: 'test-asset',
),
);
final activities = await container.read(provider.future);
expect(activities, hasLength(5));
expect(activities, contains(like));
// Never bump activity count for new likes
verifyNever(() => activityStatisticsMock.addActivity());
});
test('Like failed', () async {
final like = Activity(
id: '5',
createdAt: DateTime(2023),
type: ActivityType.like,
user: UserStub.admin,
);
when(
() => activityMock.addActivity(
'test-album',
ActivityType.like,
assetId: 'test-asset',
),
).thenAnswer(
(_) async => AsyncError(Exception('Mock'), StackTrace.current),
);
await container.read(provider.notifier).addLike();
verify(
() => activityMock.addActivity(
'test-album',
ActivityType.like,
assetId: 'test-asset',
),
);
final activities = await container.read(provider.future);
expect(activities, hasLength(4));
expect(activities, isNot(contains(like)));
});
});
group('removeActivity()', () {
test('Like successfully removed', () async {
when(() => activityMock.removeActivity('3'))
.thenAnswer((_) async => true);
await container.read(provider.notifier).removeActivity('3');
verify(
() => activityMock.removeActivity('3'),
);
final activities = await container.read(provider.future);
expect(activities, hasLength(3));
expect(
activities,
isNot(anyElement(predicate((Activity a) => a.id == '3'))),
);
verifyNever(() => activityStatisticsMock.removeActivity());
});
test('Remove Like failed', () async {
when(() => activityMock.removeActivity('3'))
.thenAnswer((_) async => false);
await container.read(provider.notifier).removeActivity('3');
final activities = await container.read(provider.future);
expect(activities, hasLength(4));
expect(
activities,
anyElement(predicate((Activity a) => a.id == '3')),
);
});
test('Comment successfully removed', () async {
when(() => activityMock.removeActivity('1'))
.thenAnswer((_) async => true);
await container.read(provider.notifier).removeActivity('1');
final activities = await container.read(provider.future);
expect(
activities,
isNot(anyElement(predicate((Activity a) => a.id == '1'))),
);
verify(() => activityStatisticsMock.removeActivity());
});
});
group('addComment()', () {
late ActivityStatisticsMock albumActivityStatisticsMock;
setUp(() {
albumActivityStatisticsMock = ActivityStatisticsMock();
container = TestUtils.createContainer(
overrides: [
activityServiceProvider.overrideWith((ref) => activityMock),
activityStatisticsProvider('test-album', 'test-asset')
.overrideWith(() => activityStatisticsMock),
activityStatisticsProvider('test-album')
.overrideWith(() => albumActivityStatisticsMock),
],
);
});
test('Comment successfully added', () async {
final comment = Activity(
id: '5',
createdAt: DateTime(2023),
type: ActivityType.comment,
user: UserStub.admin,
comment: 'Test-Comment',
assetId: 'test-asset',
);
when(
() => activityMock.addActivity(
'test-album',
ActivityType.comment,
assetId: 'test-asset',
comment: 'Test-Comment',
),
).thenAnswer((_) async => AsyncData(comment));
when(() => activityStatisticsMock.build('test-album', 'test-asset'))
.thenReturn(4);
when(() => albumActivityStatisticsMock.build('test-album')).thenReturn(2);
await container.read(provider.notifier).addComment('Test-Comment');
verify(
() => activityMock.addActivity(
'test-album',
ActivityType.comment,
assetId: 'test-asset',
comment: 'Test-Comment',
),
);
final activities = await container.read(provider.future);
expect(activities, hasLength(5));
expect(activities, contains(comment));
verify(() => activityStatisticsMock.addActivity());
verify(() => albumActivityStatisticsMock.addActivity());
});
test('Comment successfully added without assetId', () async {
final comment = Activity(
id: '5',
createdAt: DateTime(2023),
type: ActivityType.comment,
user: UserStub.admin,
assetId: 'test-asset',
comment: 'Test-Comment',
);
when(
() => activityMock.addActivity(
'test-album',
ActivityType.comment,
comment: 'Test-Comment',
),
).thenAnswer((_) async => AsyncData(comment));
when(() => albumActivityStatisticsMock.build('test-album')).thenReturn(2);
when(() => activityMock.getAllActivities('test-album'))
.thenAnswer((_) async => [..._activities]);
final albumProvider = albumActivityProvider('test-album');
await container.read(albumProvider.notifier).addComment('Test-Comment');
verify(
() => activityMock.addActivity(
'test-album',
ActivityType.comment,
assetId: null,
comment: 'Test-Comment',
),
);
final activities = await container.read(albumProvider.future);
expect(activities, hasLength(5));
expect(activities, contains(comment));
verifyNever(() => activityStatisticsMock.addActivity());
verify(() => albumActivityStatisticsMock.addActivity());
});
test('Comment failed', () async {
final comment = Activity(
id: '5',
createdAt: DateTime(2023),
type: ActivityType.comment,
user: UserStub.admin,
comment: 'Test-Comment',
assetId: 'test-asset',
);
when(
() => activityMock.addActivity(
'test-album',
ActivityType.comment,
assetId: 'test-asset',
comment: 'Test-Comment',
),
).thenAnswer(
(_) async => AsyncError(Exception('Error'), StackTrace.current),
);
await container.read(provider.notifier).addComment('Test-Comment');
final activities = await container.read(provider.future);
expect(activities, hasLength(4));
expect(activities, isNot(contains(comment)));
verifyNever(() => activityStatisticsMock.addActivity());
verifyNever(() => albumActivityStatisticsMock.addActivity());
});
});
}

View File

@ -0,0 +1,91 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/providers/activity_service.provider.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../test_utils.dart';
import 'activity_mocks.dart';
void main() {
late ActivityServiceMock activityMock;
late ProviderContainer container;
late ListenerMock<int> listener;
setUp(() async {
activityMock = ActivityServiceMock();
container = TestUtils.createContainer(
overrides: [
activityServiceProvider.overrideWith((ref) => activityMock),
],
);
listener = ListenerMock();
});
test('Returns the proper count family', () async {
when(
() => activityMock.getStatistics('test-album', assetId: 'test-asset'),
).thenAnswer((_) async => 5);
// Read here to make the getStatistics call
container.read(activityStatisticsProvider('test-album', 'test-asset'));
container.listen(
activityStatisticsProvider('test-album', 'test-asset'),
listener,
fireImmediately: true,
);
// Sleep for the getStatistics future to resolve
await Future.delayed(const Duration(milliseconds: 1));
verifyInOrder([
() => listener.call(null, 0),
() => listener.call(0, 5),
]);
verifyNoMoreInteractions(listener);
});
test('Adds activity', () async {
when(
() => activityMock.getStatistics('test-album'),
).thenAnswer((_) async => 10);
final provider = activityStatisticsProvider('test-album');
container.listen(
provider,
listener,
fireImmediately: true,
);
// Sleep for the getStatistics future to resolve
await Future.delayed(const Duration(milliseconds: 1));
container.read(provider.notifier).addActivity();
container.read(provider.notifier).addActivity();
expect(container.read(provider), 12);
});
test('Removes activity', () async {
when(
() => activityMock.getStatistics('new-album', assetId: 'test-asset'),
).thenAnswer((_) async => 10);
final provider = activityStatisticsProvider('new-album', 'test-asset');
container.listen(
provider,
listener,
fireImmediately: true,
);
// Sleep for the getStatistics future to resolve
await Future.delayed(const Duration(milliseconds: 1));
container.read(provider.notifier).removeActivity();
container.read(provider.notifier).removeActivity();
expect(container.read(provider), 8);
});
}

View File

@ -0,0 +1,199 @@
@Tags(['widget'])
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../fixtures/album.stub.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
import '../album/album_mocks.dart';
import '../shared/shared_mocks.dart';
import 'activity_mocks.dart';
void main() {
late Isar db;
late MockCurrentAlbumProvider mockCurrentAlbumProvider;
late MockAlbumActivity activityMock;
late List<Override> overrides;
setUpAll(() async {
TestUtils.init();
db = await TestUtils.initIsar();
Store.init(db);
Store.put(StoreKey.currentUser, UserStub.admin);
Store.put(StoreKey.serverEndpoint, '');
});
setUp(() {
mockCurrentAlbumProvider = MockCurrentAlbumProvider(AlbumStub.twoAsset);
activityMock = MockAlbumActivity();
overrides = [
currentAlbumProvider.overrideWith(() => mockCurrentAlbumProvider),
albumActivityProvider(AlbumStub.twoAsset.remoteId!)
.overrideWith(() => activityMock),
];
});
testWidgets('Returns an Input text field', (tester) async {
await tester.pumpConsumerWidget(
ActivityTextField(
onSubmit: (_) {},
),
overrides: overrides,
);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('No UserCircleAvatar when user == null', (tester) async {
final userProvider = MockCurrentUserProvider();
await tester.pumpConsumerWidget(
ActivityTextField(
onSubmit: (_) {},
),
overrides: [
currentUserProvider.overrideWith((ref) => userProvider),
...overrides,
],
);
expect(find.byType(UserCircleAvatar), findsNothing);
});
testWidgets('UserCircleAvatar displayed when user != null', (tester) async {
await tester.pumpConsumerWidget(
ActivityTextField(
onSubmit: (_) {},
),
overrides: overrides,
);
expect(find.byType(UserCircleAvatar), findsOneWidget);
});
testWidgets(
'Filled icon if likedId != null',
(tester) async {
await tester.pumpConsumerWidget(
ActivityTextField(
onSubmit: (_) {},
likeId: '1',
),
overrides: overrides,
);
expect(
find.widgetWithIcon(IconButton, Icons.favorite_rounded),
findsOneWidget,
);
expect(
find.widgetWithIcon(IconButton, Icons.favorite_border_rounded),
findsNothing,
);
},
);
testWidgets('Bordered icon if likedId == null', (tester) async {
await tester.pumpConsumerWidget(
ActivityTextField(
onSubmit: (_) {},
),
overrides: overrides,
);
expect(
find.widgetWithIcon(IconButton, Icons.favorite_border_rounded),
findsOneWidget,
);
expect(
find.widgetWithIcon(IconButton, Icons.favorite_rounded),
findsNothing,
);
});
testWidgets('Adds new like', (tester) async {
await tester.pumpConsumerWidget(
ActivityTextField(
onSubmit: (_) {},
),
overrides: overrides,
);
when(() => activityMock.addLike()).thenAnswer((_) => Future.value());
final suffixIcon = find.byType(IconButton);
await tester.tap(suffixIcon);
verify(() => activityMock.addLike());
});
testWidgets('Removes like if already liked', (tester) async {
await tester.pumpConsumerWidget(
ActivityTextField(
onSubmit: (_) {},
likeId: 'test-suffix',
),
overrides: overrides,
);
when(() => activityMock.removeActivity(any()))
.thenAnswer((_) => Future.value());
final suffixIcon = find.byType(IconButton);
await tester.tap(suffixIcon);
verify(() => activityMock.removeActivity('test-suffix'));
});
testWidgets('Passes text entered to onSubmit on submit', (tester) async {
String? receivedText;
await tester.pumpConsumerWidget(
ActivityTextField(
onSubmit: (text) => receivedText = text,
likeId: 'test-suffix',
),
overrides: overrides,
);
final textField = find.byType(TextField);
await tester.enterText(textField, 'This is a test comment');
await tester.testTextInput.receiveAction(TextInputAction.done);
expect(receivedText, 'This is a test comment');
});
testWidgets('Input disabled when isEnabled false', (tester) async {
String? receviedText;
await tester.pumpConsumerWidget(
ActivityTextField(
onSubmit: (text) => receviedText = text,
isEnabled: false,
likeId: 'test-suffix',
),
overrides: overrides,
);
final suffixIcon = find.byType(IconButton);
await tester.tap(suffixIcon, warnIfMissed: false);
final textField = find.byType(TextField);
await tester.enterText(textField, 'This is a test comment');
await tester.testTextInput.receiveAction(TextInputAction.done);
expect(receviedText, isNull);
verifyNever(() => activityMock.addLike());
verifyNever(() => activityMock.removeActivity(any()));
});
}

View File

@ -0,0 +1,222 @@
@Tags(['widget'])
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:isar/isar.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
import '../asset_viewer/asset_viewer_mocks.dart';
void main() {
late MockCurrentAssetProvider assetProvider;
late List<Override> overrides;
late Isar db;
setUpAll(() async {
TestUtils.init();
db = await TestUtils.initIsar();
// For UserCircleAvatar
Store.init(db);
Store.put(StoreKey.currentUser, UserStub.admin);
Store.put(StoreKey.serverEndpoint, '');
Store.put(StoreKey.accessToken, '');
});
setUp(() {
assetProvider = MockCurrentAssetProvider();
overrides = [currentAssetProvider.overrideWith(() => assetProvider)];
});
testWidgets('Returns a ListTile', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(
Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.like,
user: UserStub.admin,
),
),
overrides: overrides,
);
expect(find.byType(ListTile), findsOneWidget);
});
testWidgets('No trailing widget when activity assetId == null',
(tester) async {
await tester.pumpConsumerWidget(
ActivityTile(
Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.like,
user: UserStub.admin,
),
),
overrides: overrides,
);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.trailing, isNull);
});
testWidgets(
'Asset Thumbanil as trailing widget when activity assetId != null',
(tester) async {
await tester.pumpConsumerWidget(
ActivityTile(
Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.like,
user: UserStub.admin,
assetId: '1',
),
),
overrides: overrides,
);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.trailing, isNotNull);
// TODO: Validate this to be the common class after migrating ActivityTile#_ActivityAssetThumbnail to a common class
});
testWidgets('No trailing widget when current asset != null', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(
Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.like,
user: UserStub.admin,
assetId: '1',
),
),
overrides: overrides,
);
assetProvider.state = AssetStub.image1;
await tester.pumpAndSettle();
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.trailing, isNull);
});
group('Like Activity', () {
final activity = Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.like,
user: UserStub.admin,
);
testWidgets('Like contains filled heart as leading', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(activity),
overrides: overrides,
);
// Leading widget should not be null
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.leading, isNotNull);
// And should have a favorite icon
final favoIconFinder = find.widgetWithIcon(
listTile.leading!.runtimeType,
Icons.favorite_rounded,
);
expect(favoIconFinder, findsOneWidget);
});
testWidgets('Like title is center aligned', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(activity),
overrides: overrides,
);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.titleAlignment, ListTileTitleAlignment.center);
});
testWidgets('No subtitle for likes', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(activity),
overrides: overrides,
);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.subtitle, isNull);
});
});
group('Comment Activity', () {
final activity = Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.comment,
comment: 'This is a test comment',
user: UserStub.admin,
);
testWidgets('Comment contains User Circle Avatar as leading',
(tester) async {
await tester.pumpConsumerWidget(
ActivityTile(activity),
overrides: overrides,
);
final userAvatarFinder = find.byType(UserCircleAvatar);
expect(userAvatarFinder, findsOneWidget);
// Leading widget should not be null
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.leading, isNotNull);
// Make sure that the leading widget is the UserCircleAvatar
final userAvatar = tester.widget<UserCircleAvatar>(userAvatarFinder);
expect(listTile.leading, userAvatar);
});
testWidgets('Comment title is top aligned', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(activity),
overrides: overrides,
);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.titleAlignment, ListTileTitleAlignment.top);
});
testWidgets('Contains comment text as subtitle', (tester) async {
await tester.pumpConsumerWidget(
ActivityTile(activity),
overrides: overrides,
);
final listTile = tester.widget<ListTile>(find.byType(ListTile));
expect(listTile.subtitle, isNotNull);
expect(
find.descendant(
of: find.byType(ListTile),
matching: find.text(activity.comment!),
),
findsOneWidget,
);
});
});
}

View File

@ -0,0 +1,119 @@
@Tags(['widget'])
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../fixtures/user.stub.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
import '../asset_viewer/asset_viewer_mocks.dart';
final activity = Activity(
id: '1',
createdAt: DateTime(100),
type: ActivityType.like,
user: UserStub.admin,
);
void main() {
late MockCurrentAssetProvider assetProvider;
late List<Override> overrides;
setUpAll(() => TestUtils.init());
setUp(() {
assetProvider = MockCurrentAssetProvider();
overrides = [currentAssetProvider.overrideWith(() => assetProvider)];
});
testWidgets('Returns a Dismissible', (tester) async {
await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity)),
overrides: overrides,
);
expect(find.byType(Dismissible), findsOneWidget);
});
testWidgets('Dialog displayed when onDismiss is set', (tester) async {
await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity), onDismiss: (_) {}),
overrides: overrides,
);
final dismissible = find.byType(Dismissible);
await tester.drag(dismissible, const Offset(500, 0));
await tester.pumpAndSettle();
expect(find.byType(ConfirmDialog), findsOneWidget);
});
testWidgets(
'Ok action in ConfirmDialog should call onDismiss with activityId',
(tester) async {
String? receivedActivityId;
await tester.pumpConsumerWidget(
DismissibleActivity(
'1',
ActivityTile(activity),
onDismiss: (id) => receivedActivityId = id,
),
overrides: overrides,
);
final dismissible = find.byType(Dismissible);
await tester.drag(dismissible, const Offset(-500, 0));
await tester.pumpAndSettle();
final okButton = find.text('delete_dialog_ok');
await tester.tap(okButton);
await tester.pumpAndSettle();
expect(receivedActivityId, '1');
});
testWidgets('Delete icon for background if onDismiss is set', (tester) async {
await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity), onDismiss: (_) {}),
overrides: overrides,
);
final dismissible = find.byType(Dismissible);
await tester.drag(dismissible, const Offset(500, 0));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete_sweep_rounded), findsOneWidget);
});
testWidgets('No delete dialog if onDismiss is not set', (tester) async {
await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity)),
overrides: overrides,
);
final dismissible = find.byType(Dismissible);
await tester.drag(dismissible, const Offset(500, 0));
await tester.pumpAndSettle();
expect(find.byType(ConfirmDialog), findsNothing);
});
testWidgets('No icon for background if onDismiss is not set', (tester) async {
await tester.pumpConsumerWidget(
DismissibleActivity('1', ActivityTile(activity)),
overrides: overrides,
);
final dismissible = find.byType(Dismissible);
await tester.drag(dismissible, const Offset(-500, 0));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete_sweep_rounded), findsNothing);
});
}

View File

@ -0,0 +1,15 @@
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:mocktail/mocktail.dart';
class MockCurrentAlbumProvider extends CurrentAlbum
with Mock
implements CurrentAlbumInternal {
Album? initAlbum;
MockCurrentAlbumProvider([this.initAlbum]);
@override
Album? build() {
return initAlbum;
}
}

View File

@ -1,17 +1,17 @@
import 'package:collection/collection.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'fixtures/album.stub.dart';
import 'fixtures/asset.stub.dart';
import 'mocks/app_settings_provider.mock.dart';
import 'test_utils.dart';
import '../../fixtures/album.stub.dart';
import '../../fixtures/asset.stub.dart';
import '../../test_utils.dart';
import '../settings/settings_mocks.dart';
void main() {
/// Verify the sort modes
@ -48,15 +48,24 @@ void main() {
const created = AlbumSortMode.created;
test("Created time - ASC", () {
final sorted = created.sortFn(albums, false);
expect(sorted.isSortedBy((a) => a.createdAt), true);
final sortedList = [
AlbumStub.emptyAlbum,
AlbumStub.twoAsset,
AlbumStub.oneAsset,
AlbumStub.sharedWithUser,
];
expect(sorted, orderedEquals(sortedList));
});
test("Created time - DESC", () {
final sorted = created.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.createdAt.compareTo(b.createdAt)),
true,
);
final sortedList = [
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
AlbumStub.twoAsset,
AlbumStub.emptyAlbum,
];
expect(sorted, orderedEquals(sortedList));
});
});
@ -64,18 +73,24 @@ void main() {
const assetCount = AlbumSortMode.assetCount;
test("Asset Count - ASC", () {
final sorted = assetCount.sortFn(albums, false);
expect(
sorted.isSorted((a, b) => a.assetCount.compareTo(b.assetCount)),
true,
);
final sortedList = [
AlbumStub.emptyAlbum,
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
AlbumStub.twoAsset,
];
expect(sorted, orderedEquals(sortedList));
});
test("Asset Count - DESC", () {
final sorted = assetCount.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.assetCount.compareTo(b.assetCount)),
true,
);
final sortedList = [
AlbumStub.twoAsset,
AlbumStub.oneAsset,
AlbumStub.sharedWithUser,
AlbumStub.emptyAlbum,
];
expect(sorted, orderedEquals(sortedList));
});
});
@ -83,18 +98,24 @@ void main() {
const lastModified = AlbumSortMode.lastModified;
test("Last modified - ASC", () {
final sorted = lastModified.sortFn(albums, false);
expect(
sorted.isSorted((a, b) => a.modifiedAt.compareTo(b.modifiedAt)),
true,
);
final sortedList = [
AlbumStub.twoAsset,
AlbumStub.emptyAlbum,
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
];
expect(sorted, orderedEquals(sortedList));
});
test("Last modified - DESC", () {
final sorted = lastModified.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.modifiedAt.compareTo(b.modifiedAt)),
true,
);
final sortedList = [
AlbumStub.oneAsset,
AlbumStub.sharedWithUser,
AlbumStub.emptyAlbum,
AlbumStub.twoAsset,
];
expect(sorted, orderedEquals(sortedList));
});
});
@ -102,18 +123,24 @@ void main() {
const created = AlbumSortMode.created;
test("Created - ASC", () {
final sorted = created.sortFn(albums, false);
expect(
sorted.isSorted((a, b) => a.createdAt.compareTo(b.createdAt)),
true,
);
final sortedList = [
AlbumStub.emptyAlbum,
AlbumStub.twoAsset,
AlbumStub.oneAsset,
AlbumStub.sharedWithUser,
];
expect(sorted, orderedEquals(sortedList));
});
test("Created - DESC", () {
final sorted = created.sortFn(albums, true);
expect(
sorted.isSorted((b, a) => a.createdAt.compareTo(b.createdAt)),
true,
);
final sortedList = [
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
AlbumStub.twoAsset,
AlbumStub.emptyAlbum,
];
expect(sorted, orderedEquals(sortedList));
});
});
@ -122,28 +149,24 @@ void main() {
test("Most Recent - ASC", () {
final sorted = mostRecent.sortFn(albums, false);
expect(
sorted,
[
AlbumStub.sharedWithUser,
AlbumStub.twoAsset,
AlbumStub.oneAsset,
AlbumStub.emptyAlbum,
],
);
final sortedList = [
AlbumStub.sharedWithUser,
AlbumStub.twoAsset,
AlbumStub.oneAsset,
AlbumStub.emptyAlbum,
];
expect(sorted, orderedEquals(sortedList));
});
test("Most Recent - DESC", () {
final sorted = mostRecent.sortFn(albums, true);
expect(
sorted,
[
AlbumStub.emptyAlbum,
AlbumStub.oneAsset,
AlbumStub.twoAsset,
AlbumStub.sharedWithUser,
],
);
final sortedList = [
AlbumStub.emptyAlbum,
AlbumStub.oneAsset,
AlbumStub.twoAsset,
AlbumStub.sharedWithUser,
];
expect(sorted, orderedEquals(sortedList));
});
});
@ -152,28 +175,24 @@ void main() {
test("Most Oldest - ASC", () {
final sorted = mostOldest.sortFn(albums, false);
expect(
sorted,
[
AlbumStub.twoAsset,
AlbumStub.emptyAlbum,
AlbumStub.oneAsset,
AlbumStub.sharedWithUser,
],
);
final sortedList = [
AlbumStub.twoAsset,
AlbumStub.emptyAlbum,
AlbumStub.oneAsset,
AlbumStub.sharedWithUser,
];
expect(sorted, orderedEquals(sortedList));
});
test("Most Oldest - DESC", () {
final sorted = mostOldest.sortFn(albums, true);
expect(
sorted,
[
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
AlbumStub.emptyAlbum,
AlbumStub.twoAsset,
],
);
final sortedList = [
AlbumStub.sharedWithUser,
AlbumStub.oneAsset,
AlbumStub.emptyAlbum,
AlbumStub.twoAsset,
];
expect(sorted, orderedEquals(sortedList));
});
});
});
@ -186,7 +205,9 @@ void main() {
setUp(() async {
settingsMock = AppSettingsServiceMock();
container = TestUtils.createContainer(
overrides: [getAppSettingsServiceMock(settingsMock)],
overrides: [
appSettingsServiceProvider.overrideWith((ref) => settingsMock),
],
);
});
@ -196,7 +217,7 @@ void main() {
() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortOrder),
).thenReturn(0);
expect(AlbumSortMode.created, container.read(albumSortByOptionsProvider));
expect(container.read(albumSortByOptionsProvider), AlbumSortMode.created);
});
test('Returns the correct sort mode with index from Store', () {
@ -206,8 +227,8 @@ void main() {
).thenReturn(3);
expect(
AlbumSortMode.lastModified,
container.read(albumSortByOptionsProvider),
AlbumSortMode.lastModified,
);
});
@ -230,7 +251,6 @@ void main() {
).thenReturn(0);
final listener = ListenerMock<AlbumSortMode>();
container.listen(
albumSortByOptionsProvider,
listener,
@ -265,7 +285,9 @@ void main() {
setUp(() async {
settingsMock = AppSettingsServiceMock();
container = TestUtils.createContainer(
overrides: [getAppSettingsServiceMock(settingsMock)],
overrides: [
appSettingsServiceProvider.overrideWith((ref) => settingsMock),
],
);
});
@ -274,7 +296,7 @@ void main() {
() => settingsMock.getSetting(AppSettingsEnum.selectedAlbumSortReverse),
).thenReturn(false);
expect(false, container.read(albumSortOrderProvider));
expect(container.read(albumSortOrderProvider), isFalse);
});
test('Properly saves the correct order', () {
@ -294,7 +316,6 @@ void main() {
).thenReturn(false);
final listener = ListenerMock<bool>();
container.listen(
albumSortOrderProvider,
listener,

View File

@ -0,0 +1,15 @@
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:mocktail/mocktail.dart';
class MockCurrentAssetProvider extends CurrentAssetInternal
with Mock
implements CurrentAsset {
Asset? initAsset;
MockCurrentAssetProvider([this.initAsset]);
@override
Asset? build() {
return initAsset;
}
}

View File

@ -49,8 +49,8 @@ void main() {
final a = makeAsset(id: '1', createdAt: createdAt);
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
expect(dt, createdAt);
expect(tz, createdAt.timeZoneOffset);
expect(createdAt, dt);
expect(createdAt.timeZoneOffset, tz);
});
test('returns createdAt in local if in utc', () {
@ -59,8 +59,8 @@ void main() {
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
final localCreatedAt = createdAt.toLocal();
expect(dt, localCreatedAt);
expect(tz, localCreatedAt.timeZoneOffset);
expect(localCreatedAt, dt);
expect(localCreatedAt.timeZoneOffset, tz);
});
});
@ -73,8 +73,8 @@ void main() {
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
final dateTimeInUTC = dateTimeOriginal.toUtc();
expect(dt, dateTimeInUTC);
expect(tz, dateTimeInUTC.timeZoneOffset);
expect(dateTimeInUTC, dt);
expect(dateTimeInUTC.timeZoneOffset, tz);
});
test('Returns dateTimeOriginal in UTC from exifInfo with invalid timezone',
@ -89,8 +89,8 @@ void main() {
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
final dateTimeInUTC = dateTimeOriginal.toUtc();
expect(dt, dateTimeInUTC);
expect(tz, dateTimeInUTC.timeZoneOffset);
expect(dateTimeInUTC, dt);
expect(dateTimeInUTC.timeZoneOffset, tz);
});
});
@ -106,8 +106,8 @@ void main() {
final adjustedTime =
TZDateTime.from(dateTimeOriginal.toUtc(), getLocation(location));
expect(dt, adjustedTime);
expect(tz, adjustedTime.timeZoneOffset);
expect(adjustedTime, dt);
expect(adjustedTime.timeZoneOffset, tz);
});
test('With timezone as offset', () {
@ -124,8 +124,8 @@ void main() {
final adjustedTime = dateTimeOriginal.toUtc().add(offsetFromLocation);
// Adds the offset to the actual time and returns the offset separately
expect(dt, adjustedTime);
expect(tz, offsetFromLocation);
expect(adjustedTime, dt);
expect(offsetFromLocation, tz);
});
});
}

View File

@ -11,9 +11,9 @@ void main() {
);
});
test('malformed', () {
expect("".toDuration(), null);
expect("1:2".toDuration(), null);
expect("a:b:c".toDuration(), null);
expect("".toDuration(), isNull);
expect("1:2".toDuration(), isNull);
expect("a:b:c".toDuration(), isNull);
});
});
group('Test uniqueConsecutive', () {
@ -29,17 +29,17 @@ void main() {
test('noDuplicates', () {
final a = [1, 2, 3];
expect(a.uniqueConsecutive(), [1, 2, 3]);
expect(a.uniqueConsecutive(), orderedEquals([1, 2, 3]));
});
test('unsortedDuplicates', () {
final a = [1, 2, 1, 3];
expect(a.uniqueConsecutive(), [1, 2, 1, 3]);
expect(a.uniqueConsecutive(), orderedEquals([1, 2, 1, 3]));
});
test('sortedDuplicates', () {
final a = [6, 6, 2, 3, 3, 3, 4, 5, 1, 1];
expect(a.uniqueConsecutive(), [6, 2, 3, 4, 5, 1]);
expect(a.uniqueConsecutive(), orderedEquals([6, 2, 3, 4, 5, 1]));
});
test('withKey', () {
@ -48,7 +48,7 @@ void main() {
a.uniqueConsecutive(
compare: (s1, s2) => s1.length.compareTo(s2.length),
),
["a", "bb", "ddd"],
orderedEquals(["a", "bb", "ddd"]),
);
});
});

Some files were not shown because too many files have changed in this diff Show More