diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 230e2a0d77..05ce70ffe3 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -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 diff --git a/mobile/dart_test.yaml b/mobile/dart_test.yaml new file mode 100644 index 0000000000..fa54954090 --- /dev/null +++ b/mobile/dart_test.yaml @@ -0,0 +1,3 @@ +# Used to filter out tags from test runs +tags: + widget: diff --git a/mobile/lib/constants/errors.dart b/mobile/lib/constants/errors.dart new file mode 100644 index 0000000000..3d1f775033 --- /dev/null +++ b/mobile/lib/constants/errors.dart @@ -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"; +} diff --git a/mobile/lib/extensions/asyncvalue_extensions.dart b/mobile/lib/extensions/asyncvalue_extensions.dart index 036881f3c2..c616835a81 100644 --- a/mobile/lib/extensions/asyncvalue_extensions.dart +++ b/mobile/lib/extensions/asyncvalue_extensions.dart @@ -7,6 +7,8 @@ import 'package:logging/logging.dart'; extension LogOnError on AsyncValue { 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 on AsyncValue { } 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); diff --git a/mobile/lib/extensions/build_context_extensions.dart b/mobile/lib/extensions/build_context_extensions.dart index 6151bd1a5c..3b99718d79 100644 --- a/mobile/lib/extensions/build_context_extensions.dart +++ b/mobile/lib/extensions/build_context_extensions.dart @@ -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? result]) => Navigator.of(this).pop(result); - - // Auto-Push new route from the current context - Future autoPush(PageRouteInfo route) => - AutoRouter.of(this).push(route); - - // Auto-Push navigate route from the current context - Future autoNavigate( - PageRouteInfo route, - ) => - AutoRouter.of(this).navigate(route); - - // Auto-Push replace route from the current context - Future autoReplace(PageRouteInfo route) => - AutoRouter.of(this).replace(route); - - // Auto-Pop from the current context - Future autoPop([T? result]) => AutoRouter.of(this).pop(result); } diff --git a/mobile/lib/extensions/datetime_extensions.dart b/mobile/lib/extensions/datetime_extensions.dart index d918377711..14d89e2755 100644 --- a/mobile/lib/extensions/datetime_extensions.dart +++ b/mobile/lib/extensions/datetime_extensions.dart @@ -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'; diff --git a/mobile/lib/extensions/duration_extensions.dart b/mobile/lib/extensions/duration_extensions.dart index 68fb1b0689..ca5ba8310c 100644 --- a/mobile/lib/extensions/duration_extensions.dart +++ b/mobile/lib/extensions/duration_extensions.dart @@ -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')}"; } diff --git a/mobile/lib/extensions/string_extensions.dart b/mobile/lib/extensions/string_extensions.dart index a25ab4f508..67411013ee 100644 --- a/mobile/lib/extensions/string_extensions.dart +++ b/mobile/lib/extensions/string_extensions.dart @@ -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(':') diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index a12c43b6ca..b46dee8f41 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -73,14 +73,14 @@ Future 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; }; diff --git a/mobile/lib/mixins/error_logger.mixin.dart b/mobile/lib/mixins/error_logger.mixin.dart new file mode 100644 index 0000000000..38837a716f --- /dev/null +++ b/mobile/lib/mixins/error_logger.mixin.dart @@ -0,0 +1,38 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; + +typedef AsyncFuture = Future>; + +mixin ErrorLoggerMixin { + abstract final Logger logger; + + /// Returns an AsyncValue if the future is successfully executed + /// Else, logs the error to the overrided logger and returns an AsyncError<> + AsyncFuture guardError( + Future 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 logError( + Future 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; + } +} diff --git a/mobile/lib/module_template/ui/store_ui_here.txt b/mobile/lib/module_template/widgets/store_ui_here.txt similarity index 100% rename from mobile/lib/module_template/ui/store_ui_here.txt rename to mobile/lib/module_template/widgets/store_ui_here.txt diff --git a/mobile/lib/modules/activities/models/activity.model.dart b/mobile/lib/modules/activities/models/activity.model.dart index 2db626f54f..8ac23975af 100644 --- a/mobile/lib/modules/activities/models/activity.model.dart +++ b/mobile/lib/modules/activities/models/activity.model.dart @@ -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 && diff --git a/mobile/lib/modules/activities/providers/activity.provider.dart b/mobile/lib/modules/activities/providers/activity.provider.dart index 9d8a3429b1..0eb174969a 100644 --- a/mobile/lib/modules/activities/providers/activity.provider.dart +++ b/mobile/lib/modules/activities/providers/activity.provider.dart @@ -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>> { - 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 fetchActivity() async { - state = const AsyncLoading(); - state = await AsyncValue.guard( - () => _activityService.getAllActivities(albumId, assetId), - ); +/// Maintains the current list of all activities for +@riverpod +class AlbumActivity extends _$AlbumActivity { + @override + Future> build(String albumId, [String? assetId]) async { + return ref + .watch(activityServiceProvider) + .getAllActivities(albumId, assetId: assetId); } Future 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 addComment(String comment) async { - final activity = await _activityService.addActivity( - albumId, - ActivityType.comment, - assetId: assetId, - comment: comment, - ); - - if (activity != null) { + Future 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 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 addLike() async { - final activity = await _activityService - .addActivity(albumId, ActivityType.like, assetId: assetId); - if (activity != null) { - final activities = state.asData?.value ?? []; - state = AsyncData([...activities, activity]); - } - } } -class ActivityStatisticsNotifier extends StateNotifier { - final String albumId; - final String? assetId; - final ActivityService _activityService; - ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId) - : super(0) { - fetchStatistics(); - } - - Future fetchStatistics() async { - final count = - await _activityService.getStatistics(albumId, assetId: assetId); - if (mounted) { - state = count; - } - } - - Future addActivity() async { - state = state + 1; - } - - Future removeActivity() async { - state = state - 1; - } -} - -typedef ActivityParams = ({String albumId, String? assetId}); - -final activityStateProvider = StateNotifierProvider.autoDispose - .family>, ActivityParams>( - (ref, args) { - return ActivityNotifier( - ref, - ref.watch(activityServiceProvider), - args.albumId, - args.assetId, - ); -}); - -final activityStatisticsStateProvider = StateNotifierProvider.autoDispose - .family((ref, args) { - return ActivityStatisticsNotifier( - ref.watch(activityServiceProvider), - args.albumId, - args.assetId, - ); -}); +/// Mock class for testing +abstract class AlbumActivityInternal extends _$AlbumActivity {} diff --git a/mobile/lib/modules/activities/providers/activity.provider.g.dart b/mobile/lib/modules/activities/providers/activity.provider.g.dart new file mode 100644 index 0000000000..e25c0c46bf --- /dev/null +++ b/mobile/lib/modules/activities/providers/activity.provider.g.dart @@ -0,0 +1,209 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$albumActivityHash() => r'3b0d7acee4d41c84b3f220784c3b904c83f836e6'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$AlbumActivity + extends BuildlessAutoDisposeAsyncNotifier> { + late final String albumId; + late final String? assetId; + + Future> build( + String albumId, [ + String? assetId, + ]); +} + +/// Maintains the current list of all activities for +/// +/// Copied from [AlbumActivity]. +@ProviderFor(AlbumActivity) +const albumActivityProvider = AlbumActivityFamily(); + +/// Maintains the current list of all activities for +/// +/// Copied from [AlbumActivity]. +class AlbumActivityFamily extends Family>> { + /// Maintains the current list of all activities for + /// + /// Copied from [AlbumActivity]. + const AlbumActivityFamily(); + + /// Maintains the current list of all activities for + /// + /// Copied from [AlbumActivity]. + AlbumActivityProvider call( + String albumId, [ + String? assetId, + ]) { + return AlbumActivityProvider( + albumId, + assetId, + ); + } + + @override + AlbumActivityProvider getProviderOverride( + covariant AlbumActivityProvider provider, + ) { + return call( + provider.albumId, + provider.assetId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'albumActivityProvider'; +} + +/// Maintains the current list of all activities for +/// +/// Copied from [AlbumActivity]. +class AlbumActivityProvider extends AutoDisposeAsyncNotifierProviderImpl< + AlbumActivity, List> { + /// Maintains the current list of all activities for + /// + /// Copied from [AlbumActivity]. + AlbumActivityProvider( + String albumId, [ + String? assetId, + ]) : this._internal( + () => AlbumActivity() + ..albumId = albumId + ..assetId = assetId, + from: albumActivityProvider, + name: r'albumActivityProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$albumActivityHash, + dependencies: AlbumActivityFamily._dependencies, + allTransitiveDependencies: + AlbumActivityFamily._allTransitiveDependencies, + albumId: albumId, + assetId: assetId, + ); + + AlbumActivityProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.albumId, + required this.assetId, + }) : super.internal(); + + final String albumId; + final String? assetId; + + @override + Future> runNotifierBuild( + covariant AlbumActivity notifier, + ) { + return notifier.build( + albumId, + assetId, + ); + } + + @override + Override overrideWith(AlbumActivity Function() create) { + return ProviderOverride( + origin: this, + override: AlbumActivityProvider._internal( + () => create() + ..albumId = albumId + ..assetId = assetId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + albumId: albumId, + assetId: assetId, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement> + createElement() { + return _AlbumActivityProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is AlbumActivityProvider && + other.albumId == albumId && + other.assetId == assetId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, albumId.hashCode); + hash = _SystemHash.combine(hash, assetId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin AlbumActivityRef on AutoDisposeAsyncNotifierProviderRef> { + /// The parameter `albumId` of this provider. + String get albumId; + + /// The parameter `assetId` of this provider. + String? get assetId; +} + +class _AlbumActivityProviderElement + extends AutoDisposeAsyncNotifierProviderElement> with AlbumActivityRef { + _AlbumActivityProviderElement(super.provider); + + @override + String get albumId => (origin as AlbumActivityProvider).albumId; + @override + String? get assetId => (origin as AlbumActivityProvider).assetId; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/modules/activities/providers/activity_service.provider.dart b/mobile/lib/modules/activities/providers/activity_service.provider.dart new file mode 100644 index 0000000000..53f83cbf36 --- /dev/null +++ b/mobile/lib/modules/activities/providers/activity_service.provider.dart @@ -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)); diff --git a/mobile/lib/modules/activities/providers/activity_service.provider.g.dart b/mobile/lib/modules/activities/providers/activity_service.provider.g.dart new file mode 100644 index 0000000000..8e5ef43260 --- /dev/null +++ b/mobile/lib/modules/activities/providers/activity_service.provider.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity_service.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$activityServiceHash() => r'5dd4955d14f5bf01c00d7f8750d07e7ace7cc4b0'; + +/// See also [activityService]. +@ProviderFor(activityService) +final activityServiceProvider = AutoDisposeProvider.internal( + activityService, + name: r'activityServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$activityServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef ActivityServiceRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/modules/activities/providers/activity_statistics.provider.dart b/mobile/lib/modules/activities/providers/activity_statistics.provider.dart new file mode 100644 index 0000000000..fc2a291a1d --- /dev/null +++ b/mobile/lib/modules/activities/providers/activity_statistics.provider.dart @@ -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 +@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 {} diff --git a/mobile/lib/modules/activities/providers/activity_statistics.provider.g.dart b/mobile/lib/modules/activities/providers/activity_statistics.provider.g.dart new file mode 100644 index 0000000000..79856c525b --- /dev/null +++ b/mobile/lib/modules/activities/providers/activity_statistics.provider.g.dart @@ -0,0 +1,208 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity_statistics.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$activityStatisticsHash() => + r'a5f7bbee1891c33b72919a34e632ca9ef9cd8dbf'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$ActivityStatistics extends BuildlessAutoDisposeNotifier { + late final String albumId; + late final String? assetId; + + int build( + String albumId, [ + String? assetId, + ]); +} + +/// Maintains the current number of comments by +/// +/// Copied from [ActivityStatistics]. +@ProviderFor(ActivityStatistics) +const activityStatisticsProvider = ActivityStatisticsFamily(); + +/// Maintains the current number of comments by +/// +/// Copied from [ActivityStatistics]. +class ActivityStatisticsFamily extends Family { + /// Maintains the current number of comments by + /// + /// Copied from [ActivityStatistics]. + const ActivityStatisticsFamily(); + + /// Maintains the current number of comments by + /// + /// Copied from [ActivityStatistics]. + ActivityStatisticsProvider call( + String albumId, [ + String? assetId, + ]) { + return ActivityStatisticsProvider( + albumId, + assetId, + ); + } + + @override + ActivityStatisticsProvider getProviderOverride( + covariant ActivityStatisticsProvider provider, + ) { + return call( + provider.albumId, + provider.assetId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'activityStatisticsProvider'; +} + +/// Maintains the current number of comments by +/// +/// Copied from [ActivityStatistics]. +class ActivityStatisticsProvider + extends AutoDisposeNotifierProviderImpl { + /// Maintains the current number of comments by + /// + /// Copied from [ActivityStatistics]. + ActivityStatisticsProvider( + String albumId, [ + String? assetId, + ]) : this._internal( + () => ActivityStatistics() + ..albumId = albumId + ..assetId = assetId, + from: activityStatisticsProvider, + name: r'activityStatisticsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$activityStatisticsHash, + dependencies: ActivityStatisticsFamily._dependencies, + allTransitiveDependencies: + ActivityStatisticsFamily._allTransitiveDependencies, + albumId: albumId, + assetId: assetId, + ); + + ActivityStatisticsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.albumId, + required this.assetId, + }) : super.internal(); + + final String albumId; + final String? assetId; + + @override + int runNotifierBuild( + covariant ActivityStatistics notifier, + ) { + return notifier.build( + albumId, + assetId, + ); + } + + @override + Override overrideWith(ActivityStatistics Function() create) { + return ProviderOverride( + origin: this, + override: ActivityStatisticsProvider._internal( + () => create() + ..albumId = albumId + ..assetId = assetId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + albumId: albumId, + assetId: assetId, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement createElement() { + return _ActivityStatisticsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ActivityStatisticsProvider && + other.albumId == albumId && + other.assetId == assetId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, albumId.hashCode); + hash = _SystemHash.combine(hash, assetId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin ActivityStatisticsRef on AutoDisposeNotifierProviderRef { + /// The parameter `albumId` of this provider. + String get albumId; + + /// The parameter `assetId` of this provider. + String? get assetId; +} + +class _ActivityStatisticsProviderElement + extends AutoDisposeNotifierProviderElement + with ActivityStatisticsRef { + _ActivityStatisticsProviderElement(super.provider); + + @override + String get albumId => (origin as ActivityStatisticsProvider).albumId; + @override + String? get assetId => (origin as ActivityStatisticsProvider).assetId; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/modules/activities/services/activity.service.dart b/mobile/lib/modules/activities/services/activity.service.dart index fce77a1963..db35c17aee 100644 --- a/mobile/lib/modules/activities/services/activity.service.dart +++ b/mobile/lib/modules/activities/services/activity.service.dart @@ -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> 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 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 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 addActivity( + AsyncFuture 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(); + }); } } diff --git a/mobile/lib/modules/activities/views/activities_page.dart b/mobile/lib/modules/activities/views/activities_page.dart index f0c68a3491..d908d83c12 100644 --- a/mobile/lib/modules/activities/views/activities_page.dart +++ b/mobile/lib/modules/activities/views/activities_page.dart @@ -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 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, + ), ), ), ], diff --git a/mobile/lib/modules/activities/widgets/activity_text_field.dart b/mobile/lib/modules/activities/widgets/activity_text_field.dart new file mode 100644 index 0000000000..1d50fafaa5 --- /dev/null +++ b/mobile/lib/modules/activities/widgets/activity_text_field.dart @@ -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 addLike() async { + await activityNotifier.addLike(); + } + + Future 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(), + ), + ); + } +} diff --git a/mobile/lib/modules/activities/widgets/activity_tile.dart b/mobile/lib/modules/activities/widgets/activity_tile.dart new file mode 100644 index 0000000000..da5dacd58a --- /dev/null +++ b/mobile/lib/modules/activities/widgets/activity_tile.dart @@ -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(), + ); + } +} diff --git a/mobile/lib/modules/activities/widgets/dismissible_activity.dart b/mobile/lib/modules/activities/widgets/dismissible_activity.dart new file mode 100644 index 0000000000..15e85f7144 --- /dev/null +++ b/mobile/lib/modules/activities/widgets/dismissible_activity.dart @@ -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, + ); + } +} diff --git a/mobile/lib/modules/album/providers/album_sort_by_options.provider.g.dart b/mobile/lib/modules/album/providers/album_sort_by_options.provider.g.dart index d97057ebdd..9a05bb6c7d 100644 --- a/mobile/lib/modules/album/providers/album_sort_by_options.provider.g.dart +++ b/mobile/lib/modules/album/providers/album_sort_by_options.provider.g.dart @@ -7,7 +7,7 @@ part of 'album_sort_by_options.provider.dart'; // ************************************************************************** String _$albumSortByOptionsHash() => - r'8d22fa8b7cbca2d3d7ed20a83bf00211dc948004'; + r'dd8da5e730af555de1b86c3b157b6c93183523ac'; /// See also [AlbumSortByOptions]. @ProviderFor(AlbumSortByOptions) diff --git a/mobile/lib/modules/album/providers/current_album.provider.dart b/mobile/lib/modules/album/providers/current_album.provider.dart index 9c72b5e3d2..30e75cda5c 100644 --- a/mobile/lib/modules/album/providers/current_album.provider.dart +++ b/mobile/lib/modules/album/providers/current_album.provider.dart @@ -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((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 {} diff --git a/mobile/lib/modules/album/providers/current_album.provider.g.dart b/mobile/lib/modules/album/providers/current_album.provider.g.dart new file mode 100644 index 0000000000..50e8854637 --- /dev/null +++ b/mobile/lib/modules/album/providers/current_album.provider.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'current_album.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$currentAlbumHash() => r'61f00273d6b69da45add1532cc3d3a076ee55110'; + +/// See also [CurrentAlbum]. +@ProviderFor(CurrentAlbum) +final currentAlbumProvider = + AutoDisposeNotifierProvider.internal( + CurrentAlbum.new, + name: r'currentAlbumProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$currentAlbumHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$CurrentAlbum = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart index 2f1e6b1aae..01eef96261 100644 --- a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart +++ b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart @@ -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, diff --git a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart index adf8633605..f2219604b0 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart @@ -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), diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index 4025e4a210..1753d3f193 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -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, ); diff --git a/mobile/lib/modules/album/views/album_options_part.dart b/mobile/lib/modules/album/views/album_options_part.dart index 6ef7733392..4e07d3c0e5 100644 --- a/mobile/lib/modules/album/views/album_options_part.dart +++ b/mobile/lib/modules/album/views/album_options_part.dart @@ -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()), diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index e09649bcc3..af6077cebc 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -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( + await context.pushRoute( AssetSelectionRoute( existingAssets: albumInfo.assets, canDeselect: false, @@ -84,7 +90,7 @@ class AlbumViewerPage extends HookConsumerWidget { } void onAddUsersPressed(Album album) async { - List? sharedUserIds = await context.autoPush?>( + List? sharedUserIds = await context.pushRoute?>( 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(), ); } } diff --git a/mobile/lib/modules/album/views/create_album_page.dart b/mobile/lib/modules/album/views/create_album_page.dart index 7e5fb81682..d339421dce 100644 --- a/mobile/lib/modules/album/views/create_album_page.dart +++ b/mobile/lib/modules/album/views/create_album_page.dart @@ -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( + final bool? ok = await context.pushRoute( SelectUserForSharingRoute(assets: selectedAssets.value), ); if (ok == true) { @@ -58,7 +59,7 @@ class CreateAlbumPage extends HookConsumerWidget { onSelectPhotosButtonPressed() async { AssetSelectionPageResult? selectedAsset = - await context.autoPush( + await context.pushRoute( 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), ), diff --git a/mobile/lib/modules/album/views/library_page.dart b/mobile/lib/modules/album/views/library_page.dart index 605ffdc8ef..68b3414218 100644 --- a/mobile/lib/modules/album/views/library_page.dart +++ b/mobile/lib/modules/album/views/library_page.dart @@ -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, ), diff --git a/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart b/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart index 2aad67ef56..7f7b1cb0ec 100644 --- a/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart +++ b/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart @@ -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>({}); 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: [ diff --git a/mobile/lib/modules/album/views/select_user_for_sharing_page.dart b/mobile/lib/modules/album/views/select_user_for_sharing_page.dart index 3d6dcf6787..1089e910c0 100644 --- a/mobile/lib/modules/album/views/select_user_for_sharing_page.dart +++ b/mobile/lib/modules/album/views/select_user_for_sharing_page.dart @@ -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: [ diff --git a/mobile/lib/modules/album/views/sharing_page.dart b/mobile/lib/modules/album/views/sharing_page.dart index 9defb19bd5..119caf58f2 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -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, diff --git a/mobile/lib/modules/archive/views/archive_page.dart b/mobile/lib/modules/archive/views/archive_page.dart index 481edcfe12..e3dc77cf9b 100644 --- a/mobile/lib/modules/archive/views/archive_page.dart +++ b/mobile/lib/modules/archive/views/archive_page.dart @@ -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, diff --git a/mobile/lib/modules/asset_viewer/providers/current_asset.provider.dart b/mobile/lib/modules/asset_viewer/providers/current_asset.provider.dart new file mode 100644 index 0000000000..1f6166826c --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/current_asset.provider.dart @@ -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 {} diff --git a/mobile/lib/modules/asset_viewer/providers/current_asset.provider.g.dart b/mobile/lib/modules/asset_viewer/providers/current_asset.provider.g.dart new file mode 100644 index 0000000000..53daa74a12 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/current_asset.provider.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'current_asset.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$currentAssetHash() => r'018d9f936991c48f06c11bf7e72130bba25806e2'; + +/// See also [CurrentAsset]. +@ProviderFor(CurrentAsset) +final currentAssetProvider = + AutoDisposeNotifierProvider.internal( + CurrentAsset.new, + name: r'currentAssetProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$currentAssetHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$CurrentAsset = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 52c15e03db..14f8645787 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -1,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, diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 792f3623c9..b0bd2d2ac2 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -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", diff --git a/mobile/lib/modules/backup/ui/album_info_card.dart b/mobile/lib/modules/backup/ui/album_info_card.dart index 3e579a84c7..91c9f7515c 100644 --- a/mobile/lib/modules/backup/ui/album_info_card.dart +++ b/mobile/lib/modules/backup/ui/album_info_card.dart @@ -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), ); }, diff --git a/mobile/lib/modules/backup/ui/album_info_list_tile.dart b/mobile/lib/modules/backup/ui/album_info_list_tile.dart index 0c27ca1bad..64b742be70 100644 --- a/mobile/lib/modules/backup/ui/album_info_list_tile.dart +++ b/mobile/lib/modules/backup/ui/album_info_list_tile.dart @@ -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), ); }, diff --git a/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart b/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart index f4d9e531d4..417fd3be59 100644 --- a/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart +++ b/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart @@ -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()), ); } diff --git a/mobile/lib/modules/backup/views/album_preview_page.dart b/mobile/lib/modules/backup/views/album_preview_page.dart index cdb0204ecd..3e308e9651 100644 --- a/mobile/lib/modules/backup/views/album_preview_page.dart +++ b/mobile/lib/modules/backup/views/album_preview_page.dart @@ -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), ), ), diff --git a/mobile/lib/modules/backup/views/backup_album_selection_page.dart b/mobile/lib/modules/backup/views/backup_album_selection_page.dart index 4c2708ab09..5673055f0b 100644 --- a/mobile/lib/modules/backup/views/backup_album_selection_page.dart +++ b/mobile/lib/modules/backup/views/backup_album_selection_page.dart @@ -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( diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index 86a35af2a7..8dd973f40c 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -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, diff --git a/mobile/lib/modules/backup/views/backup_options_page.dart b/mobile/lib/modules/backup/views/backup_options_page.dart index e43e246cc1..d8aab96764 100644 --- a/mobile/lib/modules/backup/views/backup_options_page.dart +++ b/mobile/lib/modules/backup/views/backup_options_page.dart @@ -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, diff --git a/mobile/lib/modules/backup/views/failed_backup_status_page.dart b/mobile/lib/modules/backup/views/failed_backup_status_page.dart index 433ed34204..8266e01f43 100644 --- a/mobile/lib/modules/backup/views/failed_backup_status_page.dart +++ b/mobile/lib/modules/backup/views/failed_backup_status_page.dart @@ -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( diff --git a/mobile/lib/modules/favorite/views/favorites_page.dart b/mobile/lib/modules/favorite/views/favorites_page.dart index 8a7ccfc58a..e7c73d8fe5 100644 --- a/mobile/lib/modules/favorite/views/favorites_page.dart +++ b/mobile/lib/modules/favorite/views/favorites_page.dart @@ -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, diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index ca9a3b9322..6bf19a0d27 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -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, diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 1c5a6db0fb..921eeb281f 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -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(), ), diff --git a/mobile/lib/modules/login/views/login_page.dart b/mobile/lib/modules/login/views/login_page.dart index 4e1b9a6dff..04e4f1fe39 100644 --- a/mobile/lib/modules/login/views/login_page.dart +++ b/mobile/lib/modules/login/views/login_page.dart @@ -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()); }, ), ], diff --git a/mobile/lib/modules/map/ui/map_location_picker.dart b/mobile/lib/modules/map/ui/map_location_picker.dart index c3a2043aec..24873c6372 100644 --- a/mobile/lib/modules/map/ui/map_location_picker.dart +++ b/mobile/lib/modules/map/ui/map_location_picker.dart @@ -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, ), diff --git a/mobile/lib/modules/map/ui/map_page_app_bar.dart b/mobile/lib/modules/map/ui/map_page_app_bar.dart index ce426cf037..bfb29ba3d0 100644 --- a/mobile/lib/modules/map/ui/map_page_app_bar.dart +++ b/mobile/lib/modules/map/ui/map_page_app_bar.dart @@ -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), diff --git a/mobile/lib/modules/map/views/map_page.dart b/mobile/lib/modules/map/views/map_page.dart index 697ea41e06..e61bb236e0 100644 --- a/mobile/lib/modules/map/views/map_page.dart +++ b/mobile/lib/modules/map/views/map_page.dart @@ -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 { } void openAssetInViewer(Asset asset) { - context.autoPush( + context.pushRoute( GalleryViewerRoute( initialIndex: 0, loadAsset: (index) => asset, diff --git a/mobile/lib/modules/memories/ui/memory_lane.dart b/mobile/lib/modules/memories/ui/memory_lane.dart index 0c709919b2..4e6d4f81a6 100644 --- a/mobile/lib/modules/memories/ui/memory_lane.dart +++ b/mobile/lib/modules/memories/ui/memory_lane.dart @@ -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, diff --git a/mobile/lib/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart index 9c135961e4..dc33151c4d 100644 --- a/mobile/lib/modules/memories/views/memory_page.dart +++ b/mobile/lib/modules/memories/views/memory_page.dart @@ -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, diff --git a/mobile/lib/modules/onboarding/views/permission_onboarding_page.dart b/mobile/lib/modules/onboarding/views/permission_onboarding_page.dart index 771deefa32..e801e5415e 100644 --- a/mobile/lib/modules/onboarding/views/permission_onboarding_page.dart +++ b/mobile/lib/modules/onboarding/views/permission_onboarding_page.dart @@ -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(), ), ], ), diff --git a/mobile/lib/modules/partner/ui/partner_list.dart b/mobile/lib/modules/partner/ui/partner_list.dart index 6cf330509c..9e733b1263 100644 --- a/mobile/lib/modules/partner/ui/partner_list.dart +++ b/mobile/lib/modules/partner/ui/partner_list.dart @@ -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))), ); } } diff --git a/mobile/lib/modules/search/ui/curated_places_row.dart b/mobile/lib/modules/search/ui/curated_places_row.dart index 133c0e1c89..5840819f95 100644 --- a/mobile/lib/modules/search/ui/curated_places_row.dart +++ b/mobile/lib/modules/search/ui/curated_places_row.dart @@ -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( diff --git a/mobile/lib/modules/search/ui/explore_grid.dart b/mobile/lib/modules/search/ui/explore_grid.dart index 984f65a401..fd49fff7c2 100644 --- a/mobile/lib/modules/search/ui/explore_grid.dart +++ b/mobile/lib/modules/search/ui/explore_grid.dart @@ -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}'), ); }, diff --git a/mobile/lib/modules/search/views/all_motion_videos_page.dart b/mobile/lib/modules/search/views/all_motion_videos_page.dart index 8290f0dd6e..1fcadb36fc 100644 --- a/mobile/lib/modules/search/views/all_motion_videos_page.dart +++ b/mobile/lib/modules/search/views/all_motion_videos_page.dart @@ -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), ), ), diff --git a/mobile/lib/modules/search/views/all_people_page.dart b/mobile/lib/modules/search/views/all_people_page.dart index 7a81831482..9cd6639757 100644 --- a/mobile/lib/modules/search/views/all_people_page.dart +++ b/mobile/lib/modules/search/views/all_people_page.dart @@ -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), ), ), diff --git a/mobile/lib/modules/search/views/all_videos_page.dart b/mobile/lib/modules/search/views/all_videos_page.dart index 6835398801..9db3358777 100644 --- a/mobile/lib/modules/search/views/all_videos_page.dart +++ b/mobile/lib/modules/search/views/all_videos_page.dart @@ -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), ), ), diff --git a/mobile/lib/modules/search/views/curated_location_page.dart b/mobile/lib/modules/search/views/curated_location_page.dart index 6675e0826f..1f144f657d 100644 --- a/mobile/lib/modules/search/views/curated_location_page.dart +++ b/mobile/lib/modules/search/views/curated_location_page.dart @@ -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), ), ), diff --git a/mobile/lib/modules/search/views/person_result_page.dart b/mobile/lib/modules/search/views/person_result_page.dart index 40a2d1b14b..a1f62ae01a 100644 --- a/mobile/lib/modules/search/views/person_result_page.dart +++ b/mobile/lib/modules/search/views/person_result_page.dart @@ -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: [ diff --git a/mobile/lib/modules/search/views/recently_added_page.dart b/mobile/lib/modules/search/views/recently_added_page.dart index 538dea3d71..a2959babfd 100644 --- a/mobile/lib/modules/search/views/recently_added_page.dart +++ b/mobile/lib/modules/search/views/recently_added_page.dart @@ -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), ), ), diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index fb4bd49794..c367674225 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -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()), ), ], ), diff --git a/mobile/lib/modules/search/views/search_result_page.dart b/mobile/lib/modules/search/views/search_result_page.dart index fd16c2c06b..585b55d713 100644 --- a/mobile/lib/modules/search/views/search_result_page.dart +++ b/mobile/lib/modules/search/views/search_result_page.dart @@ -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), diff --git a/mobile/lib/modules/shared_link/ui/shared_link_item.dart b/mobile/lib/modules/shared_link/ui/shared_link_item.dart index b147ea3c54..e42a69b6f0 100644 --- a/mobile/lib/modules/shared_link/ui/shared_link_item.dart +++ b/mobile/lib/modules/shared_link/ui/shared_link_item.dart @@ -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, diff --git a/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart index e96fff56ab..7602a13233 100644 --- a/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart +++ b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart @@ -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( diff --git a/mobile/lib/modules/trash/views/trash_page.dart b/mobile/lib/modules/trash/views/trash_page.dart index 88fd32d013..852b6e075c 100644 --- a/mobile/lib/modules/trash/views/trash_page.dart +++ b/mobile/lib/modules/trash/views/trash_page.dart @@ -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 = {}; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 79684fd02e..3fa3f18a26 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -340,18 +340,9 @@ class _$AppRouter extends RootStackRouter { ); }, ActivitiesRoute.name: (routeData) { - final args = routeData.argsAs(); return CustomPage( routeData: routeData, - child: ActivitiesPage( - args.albumId, - appBarTitle: args.appBarTitle, - assetId: args.assetId, - withAssetThumbs: args.withAssetThumbs, - isOwner: args.isOwner, - 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 { - ActivitiesRoute({ - required String albumId, - String appBarTitle = "", - String? assetId, - bool withAssetThumbs = true, - bool isOwner = false, - bool isReadOnly = false, - Key? key, - }) : super( +class ActivitiesRoute extends PageRouteInfo { + 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 { diff --git a/mobile/lib/shared/models/user.dart b/mobile/lib/shared/models/user.dart index 094509c897..aec63e7bea 100644 --- a/mobile/lib/shared/models/user.dart +++ b/mobile/lib/shared/models/user.dart @@ -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; diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart index 053770a92c..856d74f168 100644 --- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart @@ -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()); }, ); }, diff --git a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart index 2a8923657c..e3db2ab166 100644 --- a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart +++ b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart @@ -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; diff --git a/mobile/lib/shared/ui/immich_app_bar.dart b/mobile/lib/shared/ui/immich_app_bar.dart index 49f5453a7b..0c70e52353 100644 --- a/mobile/lib/shared/ui/immich_app_bar.dart +++ b/mobile/lib/shared/ui/immich_app_bar.dart @@ -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( diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 985219d6eb..427e5d11e4 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -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 precacheAsset( Asset asset, diff --git a/mobile/lib/shared/ui/location_picker.dart b/mobile/lib/shared/ui/location_picker.dart index 9649c36adf..9ce5d96a38 100644 --- a/mobile/lib/shared/ui/location_picker.dart +++ b/mobile/lib/shared/ui/location_picker.dart @@ -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( + final newLatLng = await context.pushRoute( MapLocationPickerRoute(initialLatLng: latlng), ); if (newLatLng != null) { diff --git a/mobile/lib/shared/ui/scaffold_error_body.dart b/mobile/lib/shared/ui/scaffold_error_body.dart index ef0d9d5990..bca2934c23 100644 --- a/mobile/lib/shared/ui/scaffold_error_body.dart +++ b/mobile/lib/shared/ui/scaffold_error_body.dart @@ -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, + ), + ), ], ); } diff --git a/mobile/lib/shared/views/app_log_page.dart b/mobile/lib/shared/views/app_log_page.dart index 9d1dca19fa..a5b63d0d28 100644 --- a/mobile/lib/shared/views/app_log_page.dart +++ b/mobile/lib/shared/views/app_log_page.dart @@ -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, ), diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart index 1298e0efe7..e51debdbac 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -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; }, diff --git a/mobile/test/fixtures/album.stub.dart b/mobile/test/fixtures/album.stub.dart index a7c6302630..663faca39d 100644 --- a/mobile/test/fixtures/album.stub.dart +++ b/mobile/test/fixtures/album.stub.dart @@ -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; } diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index 3dba434b2f..2c5106bb4c 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -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), diff --git a/mobile/test/fixtures/user.stub.dart b/mobile/test/fixtures/user.stub.dart index b0dcab094d..4e92bffa72 100644 --- a/mobile/test/fixtures/user.stub.dart +++ b/mobile/test/fixtures/user.stub.dart @@ -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, ); } diff --git a/mobile/test/mock_http_override.dart b/mobile/test/mock_http_override.dart new file mode 100644 index 0000000000..f247e377a2 --- /dev/null +++ b/mobile/test/mock_http_override.dart @@ -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.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); + + 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>.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 {} diff --git a/mobile/test/mocks/app_settings_provider.mock.dart b/mobile/test/mocks/app_settings_provider.mock.dart deleted file mode 100644 index fbdf67a411..0000000000 --- a/mobile/test/mocks/app_settings_provider.mock.dart +++ /dev/null @@ -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); diff --git a/mobile/test/modules/activity/activities_page_test.dart b/mobile/test/modules/activity/activities_page_test.dart new file mode 100644 index 0000000000..14ca430139 --- /dev/null +++ b/mobile/test/modules/activity/activities_page_test.dart @@ -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 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(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(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, + ), + ); + }, + ); + }); +} diff --git a/mobile/test/modules/activity/activity_mocks.dart b/mobile/test/modules/activity/activity_mocks.dart new file mode 100644 index 0000000000..0a3e37216d --- /dev/null +++ b/mobile/test/modules/activity/activity_mocks.dart @@ -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? initActivities; + MockAlbumActivity([this.initActivities]); + + @override + Future> build(String albumId, [String? assetId]) async { + return initActivities ?? []; + } +} + +class ActivityStatisticsMock extends ActivityStatisticsInternal + with Mock + implements ActivityStatistics {} diff --git a/mobile/test/modules/activity/activity_provider_test.dart b/mobile/test/modules/activity/activity_provider_test.dart new file mode 100644 index 0000000000..9e8559839e --- /dev/null +++ b/mobile/test/modules/activity/activity_provider_test.dart @@ -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>> listener; + + setUpAll(() { + registerFallbackValue(AsyncData>([..._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>>(), + predicate( + (AsyncData> 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()); + }); + }); +} diff --git a/mobile/test/modules/activity/activity_statistics_provider_test.dart b/mobile/test/modules/activity/activity_statistics_provider_test.dart new file mode 100644 index 0000000000..be147d201d --- /dev/null +++ b/mobile/test/modules/activity/activity_statistics_provider_test.dart @@ -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 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); + }); +} diff --git a/mobile/test/modules/activity/activity_text_field_test.dart b/mobile/test/modules/activity/activity_text_field_test.dart new file mode 100644 index 0000000000..c05971b256 --- /dev/null +++ b/mobile/test/modules/activity/activity_text_field_test.dart @@ -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 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())); + }); +} diff --git a/mobile/test/modules/activity/activity_tile_test.dart b/mobile/test/modules/activity/activity_tile_test.dart new file mode 100644 index 0000000000..f2980dce6f --- /dev/null +++ b/mobile/test/modules/activity/activity_tile_test.dart @@ -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 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(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(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(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(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(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(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(find.byType(ListTile)); + expect(listTile.leading, isNotNull); + + // Make sure that the leading widget is the UserCircleAvatar + final userAvatar = tester.widget(userAvatarFinder); + expect(listTile.leading, userAvatar); + }); + + testWidgets('Comment title is top aligned', (tester) async { + await tester.pumpConsumerWidget( + ActivityTile(activity), + overrides: overrides, + ); + + final listTile = tester.widget(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(find.byType(ListTile)); + + expect(listTile.subtitle, isNotNull); + expect( + find.descendant( + of: find.byType(ListTile), + matching: find.text(activity.comment!), + ), + findsOneWidget, + ); + }); + }); +} diff --git a/mobile/test/modules/activity/dismissible_activity_test.dart b/mobile/test/modules/activity/dismissible_activity_test.dart new file mode 100644 index 0000000000..0ce204a648 --- /dev/null +++ b/mobile/test/modules/activity/dismissible_activity_test.dart @@ -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 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); + }); +} diff --git a/mobile/test/modules/album/album_mocks.dart b/mobile/test/modules/album/album_mocks.dart new file mode 100644 index 0000000000..c8218e50df --- /dev/null +++ b/mobile/test/modules/album/album_mocks.dart @@ -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; + } +} diff --git a/mobile/test/album_sort_by_options_provider_test.dart b/mobile/test/modules/album/album_sort_by_options_provider_test.dart similarity index 67% rename from mobile/test/album_sort_by_options_provider_test.dart rename to mobile/test/modules/album/album_sort_by_options_provider_test.dart index 30b235166b..b39c495ae5 100644 --- a/mobile/test/album_sort_by_options_provider_test.dart +++ b/mobile/test/modules/album/album_sort_by_options_provider_test.dart @@ -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(); - 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(); - container.listen( albumSortOrderProvider, listener, diff --git a/mobile/test/modules/asset_viewer/asset_viewer_mocks.dart b/mobile/test/modules/asset_viewer/asset_viewer_mocks.dart new file mode 100644 index 0000000000..5a4bbd8be1 --- /dev/null +++ b/mobile/test/modules/asset_viewer/asset_viewer_mocks.dart @@ -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; + } +} diff --git a/mobile/test/asset_extensions_test.dart b/mobile/test/modules/extensions/asset_extensions_test.dart similarity index 89% rename from mobile/test/asset_extensions_test.dart rename to mobile/test/modules/extensions/asset_extensions_test.dart index 1e429b5ac1..15aab38fdb 100644 --- a/mobile/test/asset_extensions_test.dart +++ b/mobile/test/modules/extensions/asset_extensions_test.dart @@ -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); }); }); } diff --git a/mobile/test/builtin_extensions_test.dart b/mobile/test/modules/extensions/builtin_extensions_test.dart similarity index 74% rename from mobile/test/builtin_extensions_test.dart rename to mobile/test/modules/extensions/builtin_extensions_test.dart index 9fc729774a..2de450a952 100644 --- a/mobile/test/builtin_extensions_test.dart +++ b/mobile/test/modules/extensions/builtin_extensions_test.dart @@ -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"]), ); }); }); diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/modules/home/asset_grid_data_structure_test.dart similarity index 96% rename from mobile/test/asset_grid_data_structure_test.dart rename to mobile/test/modules/home/asset_grid_data_structure_test.dart index 6b8f080638..86433768ac 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/modules/home/asset_grid_data_structure_test.dart @@ -75,7 +75,7 @@ void main() { // 5 Assets => 2 Rows // Day 1 // 5 Assets => 2 Rows - expect(renderList.elements.length, 4); + expect(renderList.elements, hasLength(4)); expect( renderList.elements[0].type, RenderAssetGridElementType.monthTitle, @@ -122,7 +122,7 @@ void main() { RenderAssetGridElementType.monthTitle, ]; - expect(renderList.elements.length, types.length); + expect(renderList.elements, hasLength(types.length)); for (int i = 0; i < renderList.elements.length; i++) { expect(renderList.elements[i].type, types[i]); diff --git a/mobile/test/modules/settings/settings_mocks.dart b/mobile/test/modules/settings/settings_mocks.dart new file mode 100644 index 0000000000..0fd6948702 --- /dev/null +++ b/mobile/test/modules/settings/settings_mocks.dart @@ -0,0 +1,4 @@ +import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class AppSettingsServiceMock extends Mock implements AppSettingsService {} diff --git a/mobile/test/modules/shared/shared_mocks.dart b/mobile/test/modules/shared/shared_mocks.dart new file mode 100644 index 0000000000..af88a93eaa --- /dev/null +++ b/mobile/test/modules/shared/shared_mocks.dart @@ -0,0 +1,16 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/user.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; +import 'package:immich_mobile/shared/services/hash.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockHashService extends Mock implements HashService {} + +class MockCurrentUserProvider extends StateNotifier + with Mock + implements CurrentUserProvider { + MockCurrentUserProvider() : super(null); + + @override + set state(User? user) => super.state = user; +} diff --git a/mobile/test/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart similarity index 84% rename from mobile/test/sync_service_test.dart rename to mobile/test/modules/shared/sync_service_test.dart index 1d00875541..f5caedfd06 100644 --- a/mobile/test/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,17 +1,14 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/etag.dart'; -import 'package:immich_mobile/shared/models/exif_info.dart'; -import 'package:immich_mobile/shared/models/logger_message.model.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/user.dart'; -import 'package:immich_mobile/shared/services/hash.service.dart'; import 'package:immich_mobile/shared/services/immich_logger.service.dart'; import 'package:immich_mobile/shared/services/sync.service.dart'; import 'package:isar/isar.dart'; -import 'package:mocktail/mocktail.dart'; + +import '../../test_utils.dart'; +import 'shared_mocks.dart'; void main() { Asset makeAsset({ @@ -39,22 +36,6 @@ void main() { ); } - Isar loadDb() { - return Isar.openSync( - [ - ExifInfoSchema, - AssetSchema, - AlbumSchema, - UserSchema, - StoreValueSchema, - LoggerMessageSchema, - ETagSchema, - ], - maxSizeMiB: 256, - directory: ".", - ); - } - group('Test SyncService grouped', () { late final Isar db; final MockHashService hs = MockHashService(); @@ -67,8 +48,7 @@ void main() { ); setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); - await Isar.initializeIsarCore(download: true); - db = loadDb(); + db = await TestUtils.initIsar(); ImmichLogger(); db.writeTxnSync(() => db.clearSync()); Store.init(db); @@ -97,7 +77,7 @@ void main() { expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets); - expect(c1, false); + expect(c1, isFalse); expect(db.assets.countSync(), 5); }); @@ -114,7 +94,7 @@ void main() { expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets); - expect(c1, true); + expect(c1, isTrue); expect(db.assets.countSync(), 7); }); @@ -131,22 +111,22 @@ void main() { expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets); - expect(c1, true); + expect(c1, isTrue); expect(db.assets.countSync(), 8); final bool c2 = await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets); - expect(c2, false); + expect(c2, isFalse); expect(db.assets.countSync(), 8); remoteAssets.removeAt(4); final bool c3 = await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets); - expect(c3, true); + expect(c3, isTrue); expect(db.assets.countSync(), 7); remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e")); remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2")); final bool c4 = await s.syncRemoteAssetsToDb(owner, _failDiff, (u) => remoteAssets); - expect(c4, true); + expect(c4, isTrue); expect(db.assets.countSync(), 9); }); @@ -164,7 +144,7 @@ void main() { (user, since) async => (toUpsert, toDelete), (user) => throw Exception(), ); - expect(c, true); + expect(c, isTrue); expect(db.assets.countSync(), 6); }); }); @@ -172,5 +152,3 @@ void main() { Future<(List?, List?)> _failDiff(User user, DateTime time) => Future.value((null, null)); - -class MockHashService extends Mock implements HashService {} diff --git a/mobile/test/async_mutex_test.dart b/mobile/test/modules/utils/async_mutex_test.dart similarity index 100% rename from mobile/test/async_mutex_test.dart rename to mobile/test/modules/utils/async_mutex_test.dart diff --git a/mobile/test/diff_test.dart b/mobile/test/modules/utils/diff_test.dart similarity index 100% rename from mobile/test/diff_test.dart rename to mobile/test/modules/utils/diff_test.dart diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 5052f59107..bd359d0400 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; @@ -14,9 +17,10 @@ import 'package:immich_mobile/shared/models/user.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; +import 'mock_http_override.dart'; + // Listener Mock to test when a provider notifies its listeners class ListenerMock extends Mock { - // ignore: avoid-declaring-call-method void call(T? previous, T next); } @@ -26,6 +30,12 @@ final class TestUtils { /// Downloads Isar binaries (if required) and initializes a new Isar db static Future initIsar() async { await Isar.initializeIsarCore(download: true); + + final instance = Isar.getInstance(); + if (instance != null) { + return instance; + } + final db = await Isar.open( [ StoreValueSchema, @@ -41,8 +51,9 @@ final class TestUtils { IOSDeviceAssetSchema, ], maxSizeMiB: 256, - directory: ".", + directory: "test/", ); + // Clear and close db on test end addTearDown(() async { await db.writeTxn(() => db.clear()); @@ -68,4 +79,11 @@ final class TestUtils { return container; } + + static void init() { + // Turn off easy localization logging + EasyLocalization.logger.enableBuildModes = []; + WidgetController.hitTestWarningShouldBeFatal = true; + HttpOverrides.global = MockHttpOverrides(); + } } diff --git a/mobile/test/widget_tester_extensions.dart b/mobile/test/widget_tester_extensions.dart new file mode 100644 index 0000000000..c054a32501 --- /dev/null +++ b/mobile/test/widget_tester_extensions.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +extension PumpConsumerWidget on WidgetTester { + /// Wraps the provided [widget] with Material app such that it becomes: + /// + /// ProviderScope + /// |-MaterialApp + /// |-Material + /// |-[widget] + Future pumpConsumerWidget( + Widget widget, { + Duration? duration, + EnginePhase phase = EnginePhase.sendSemanticsUpdate, + List overrides = const [], + }) async { + return pumpWidget( + ProviderScope( + overrides: overrides, + child: MaterialApp( + debugShowCheckedModeBanner: false, + home: Material(child: widget), + ), + ), + duration, + phase, + ); + } +}