refactor(mobile): Activities (#5990)

* refactor: autoroutex pushroute

* refactor: autoroutex popRoute

* refactor: autoroutex navigate and replace

* chore: add doc comments for extension methods

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

* refactor: Activity page

* chore: activity user from user constructor

* fix: update current asset after build method

* refactor: tests with similar structure as lib

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

* test: fix proper expect order

* test: activity_statistics_provider_test

* test: activity_provider_test

* test: use proper matchers

* test: activity_text_field_test & dismissible_activity_test added

* test: add http mock to return transparent image

* test: download isar core libs during test

* test: add widget tags to widget test cases

* test: activity_tile_test

* build: currentAlbumProvider to generator

* movie add / remove like to activity input tile

* test: activities_page_test.dart

* chore: better error logs

* chore: dismissibleactivity as statelesswidget

---------

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

View File

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

3
mobile/dart_test.yaml Normal file
View File

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

View File

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

View File

@ -7,6 +7,8 @@ import 'package:logging/logging.dart';
extension LogOnError<T> on AsyncValue<T> { extension LogOnError<T> on AsyncValue<T> {
static final Logger _asyncErrorLogger = Logger("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({ Widget widgetWhen({
bool skipLoadingOnRefresh = true, bool skipLoadingOnRefresh = true,
Widget Function()? onLoading, Widget Function()? onLoading,
@ -28,8 +30,9 @@ extension LogOnError<T> on AsyncValue<T> {
} }
if (hasError && !hasValue) { if (hasError && !hasValue) {
_asyncErrorLogger.severe("Error occured", error, stackTrace); _asyncErrorLogger.severe("$error", error, stackTrace);
return onError?.call(error, stackTrace) ?? const ScaffoldErrorBody(); return onError?.call(error, stackTrace) ??
ScaffoldErrorBody(errorMsg: error?.toString());
} }
return onData(requireValue); return onData(requireValue);

View File

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

View File

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

View File

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

View File

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

View File

@ -73,14 +73,14 @@ Future<void> initApp() async {
FlutterError.onError = (details) { FlutterError.onError = (details) {
FlutterError.presentError(details); FlutterError.presentError(details);
log.severe( 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,
details.stack, details.stack,
); );
}; };
PlatformDispatcher.instance.onError = (error, 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; return true;
}; };

View File

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

View File

@ -46,18 +46,7 @@ class Activity {
type = dto.type == ActivityResponseDtoTypeEnum.comment type = dto.type == ActivityResponseDtoTypeEnum.comment
? ActivityType.comment ? ActivityType.comment
: ActivityType.like, : ActivityType.like,
user = User( user = User.fromSimpleUserDto(dto.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,
);
@override @override
String toString() { String toString() {
@ -65,11 +54,10 @@ class Activity {
} }
@override @override
bool operator ==(Object other) { bool operator ==(covariant Activity other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other is Activity && return other.id == id &&
other.id == id &&
other.assetId == assetId && other.assetId == assetId &&
other.comment == comment && other.comment == comment &&
other.createdAt == createdAt && other.createdAt == createdAt &&

View File

@ -1,134 +1,67 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart'; import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/activities/services/activity.service.dart'; import 'package:immich_mobile/modules/activities/providers/activity_service.provider.dart';
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class ActivityNotifier extends StateNotifier<AsyncValue<List<Activity>>> { part 'activity.provider.g.dart';
final Ref _ref;
final ActivityService _activityService;
final String albumId;
final String? assetId;
ActivityNotifier( /// Maintains the current list of all activities for <share-album-id, asset>
this._ref, @riverpod
this._activityService, class AlbumActivity extends _$AlbumActivity {
this.albumId, @override
this.assetId, Future<List<Activity>> build(String albumId, [String? assetId]) async {
) : super( return ref
const AsyncData([]), .watch(activityServiceProvider)
) { .getAllActivities(albumId, assetId: assetId);
fetchActivity();
}
Future<void> fetchActivity() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => _activityService.getAllActivities(albumId, assetId),
);
} }
Future<void> removeActivity(String id) async { Future<void> removeActivity(String id) async {
final activities = state.asData?.value ?? []; if (await ref.watch(activityServiceProvider).removeActivity(id)) {
if (await _activityService.removeActivity(id)) { final activities = state.valueOrNull ?? [];
final removedActivity = activities.firstWhere((a) => a.id == id); final removedActivity = activities.firstWhere((a) => a.id == id);
activities.remove(removedActivity); activities.remove(removedActivity);
state = AsyncData(activities); state = AsyncData(activities);
// Decrement activity count only for comments
if (removedActivity.type == ActivityType.comment) { if (removedActivity.type == ActivityType.comment) {
_ref ref
.read( .watch(activityStatisticsProvider(albumId, assetId).notifier)
activityStatisticsStateProvider(
(albumId: albumId, assetId: assetId),
).notifier,
)
.removeActivity(); .removeActivity();
} }
} }
} }
Future<void> addComment(String comment) async { Future<void> addLike() async {
final activity = await _activityService.addActivity( final activity = await ref
albumId, .watch(activityServiceProvider)
ActivityType.comment, .addActivity(albumId, ActivityType.like, assetId: assetId);
assetId: assetId, if (activity.hasValue) {
comment: comment,
);
if (activity != null) {
final activities = state.asData?.value ?? []; final activities = state.asData?.value ?? [];
state = AsyncData([...activities, activity]); state = AsyncData([...activities, activity.requireValue]);
_ref }
.read( }
activityStatisticsStateProvider(
(albumId: albumId, assetId: assetId), Future<void> addComment(String comment) async {
).notifier, 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(); .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) { if (assetId != null) {
// Add a count to the current album's provider as well ref.watch(activityStatisticsProvider(albumId).notifier).addActivity();
_ref
.read(
activityStatisticsStateProvider(
(albumId: albumId, assetId: null),
).notifier,
)
.addActivity();
} }
} }
} }
Future<void> addLike() async {
final activity = await _activityService
.addActivity(albumId, ActivityType.like, assetId: assetId);
if (activity != null) {
final activities = state.asData?.value ?? [];
state = AsyncData([...activities, activity]);
}
}
} }
class ActivityStatisticsNotifier extends StateNotifier<int> { /// Mock class for testing
final String albumId; abstract class AlbumActivityInternal extends _$AlbumActivity {}
final String? assetId;
final ActivityService _activityService;
ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId)
: super(0) {
fetchStatistics();
}
Future<void> fetchStatistics() async {
final count =
await _activityService.getStatistics(albumId, assetId: assetId);
if (mounted) {
state = count;
}
}
Future<void> addActivity() async {
state = state + 1;
}
Future<void> removeActivity() async {
state = state - 1;
}
}
typedef ActivityParams = ({String albumId, String? assetId});
final activityStateProvider = StateNotifierProvider.autoDispose
.family<ActivityNotifier, AsyncValue<List<Activity>>, ActivityParams>(
(ref, args) {
return ActivityNotifier(
ref,
ref.watch(activityServiceProvider),
args.albumId,
args.assetId,
);
});
final activityStatisticsStateProvider = StateNotifierProvider.autoDispose
.family<ActivityStatisticsNotifier, int, ActivityParams>((ref, args) {
return ActivityStatisticsNotifier(
ref.watch(activityServiceProvider),
args.albumId,
args.assetId,
);
});

View File

@ -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<List<Activity>> {
late final String albumId;
late final String? assetId;
Future<List<Activity>> build(
String albumId, [
String? assetId,
]);
}
/// Maintains the current list of all activities for <share-album-id, asset>
///
/// Copied from [AlbumActivity].
@ProviderFor(AlbumActivity)
const albumActivityProvider = AlbumActivityFamily();
/// Maintains the current list of all activities for <share-album-id, asset>
///
/// Copied from [AlbumActivity].
class AlbumActivityFamily extends Family<AsyncValue<List<Activity>>> {
/// Maintains the current list of all activities for <share-album-id, asset>
///
/// Copied from [AlbumActivity].
const AlbumActivityFamily();
/// Maintains the current list of all activities for <share-album-id, asset>
///
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'albumActivityProvider';
}
/// Maintains the current list of all activities for <share-album-id, asset>
///
/// Copied from [AlbumActivity].
class AlbumActivityProvider extends AutoDisposeAsyncNotifierProviderImpl<
AlbumActivity, List<Activity>> {
/// Maintains the current list of all activities for <share-album-id, asset>
///
/// 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<List<Activity>> 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<AlbumActivity, List<Activity>>
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<List<Activity>> {
/// The parameter `albumId` of this provider.
String get albumId;
/// The parameter `assetId` of this provider.
String? get assetId;
}
class _AlbumActivityProviderElement
extends AutoDisposeAsyncNotifierProviderElement<AlbumActivity,
List<Activity>> 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

View File

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

View File

@ -0,0 +1,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<ActivityService>.internal(
activityService,
name: r'activityServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$activityServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef ActivityServiceRef = AutoDisposeProviderRef<ActivityService>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

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

View File

@ -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<int> {
late final String albumId;
late final String? assetId;
int build(
String albumId, [
String? assetId,
]);
}
/// Maintains the current number of comments by <shared-album, asset>
///
/// Copied from [ActivityStatistics].
@ProviderFor(ActivityStatistics)
const activityStatisticsProvider = ActivityStatisticsFamily();
/// Maintains the current number of comments by <shared-album, asset>
///
/// Copied from [ActivityStatistics].
class ActivityStatisticsFamily extends Family<int> {
/// Maintains the current number of comments by <shared-album, asset>
///
/// Copied from [ActivityStatistics].
const ActivityStatisticsFamily();
/// Maintains the current number of comments by <shared-album, asset>
///
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'activityStatisticsProvider';
}
/// Maintains the current number of comments by <shared-album, asset>
///
/// Copied from [ActivityStatistics].
class ActivityStatisticsProvider
extends AutoDisposeNotifierProviderImpl<ActivityStatistics, int> {
/// Maintains the current number of comments by <shared-album, asset>
///
/// 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<ActivityStatistics, int> 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<int> {
/// The parameter `albumId` of this provider.
String get albumId;
/// The parameter `assetId` of this provider.
String? get assetId;
}
class _ActivityStatisticsProviderElement
extends AutoDisposeNotifierProviderElement<ActivityStatistics, int>
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

View File

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

View File

@ -1,6 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.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.provider.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/utils/image_url_builder.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 { class ActivitiesPage extends HookConsumerWidget {
final String albumId; const ActivitiesPage({
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,
super.key, super.key,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final provider = // Album has to be set in the provider before reaching this page
activityStateProvider((albumId: albumId, assetId: assetId)); final album = ref.watch(currentAlbumProvider)!;
final activities = ref.watch(provider); final asset = ref.watch(currentAssetProvider);
final inputController = useTextEditingController(); final user = ref.watch(currentUserProvider);
final inputFocusNode = useFocusNode();
final activityNotifier = ref
.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final activities =
ref.watch(albumActivityProvider(album.remoteId!, asset?.remoteId));
final listViewScrollController = useScrollController(); final listViewScrollController = useScrollController();
final currentUser = Store.tryGet(StoreKey.currentUser);
useEffect( Future<void> onAddComment(String comment) async {
() { await activityNotifier.addComment(comment);
inputFocusNode.requestFocus(); // Scroll to the end of the list to show the newly added activity
return null; listViewScrollController.animateTo(
}, listViewScrollController.position.maxScrollExtent + 200,
[], duration: const Duration(milliseconds: 600),
); curve: Curves.fastOutSlowIn,
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,
); );
} }
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(appBarTitle)), appBar: AppBar(title: asset == null ? Text(album.name) : null),
body: activities.widgetWhen( body: activities.widgetWhen(
onData: (data) { onData: (data) {
final liked = data.firstWhereOrNull( final liked = data.firstWhereOrNull(
(a) => (a) =>
a.type == ActivityType.like && a.type == ActivityType.like &&
a.user.id == currentUser?.id && a.user.id == user?.id &&
a.assetId == assetId, a.assetId == asset?.remoteId,
); );
return SafeArea( return SafeArea(
@ -245,9 +58,10 @@ class ActivitiesPage extends HookConsumerWidget {
children: [ children: [
ListView.builder( ListView.builder(
controller: listViewScrollController, controller: listViewScrollController,
// +1 to display an additional over-scroll space after the last element
itemCount: data.length + 1, itemCount: data.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
// Vertical gap after the last element // Additional vertical gap after the last element
if (index == data.length) { if (index == data.length) {
return const SizedBox( return const SizedBox(
height: 80, height: 80,
@ -255,45 +69,19 @@ class ActivitiesPage extends HookConsumerWidget {
} }
final activity = data[index]; final activity = data[index];
final canDelete = final canDelete = activity.user.id == user?.id ||
activity.user.id == currentUser?.id || isOwner; album.ownerId == user?.id;
return Padding( return Padding(
padding: const EdgeInsets.all(5), padding: const EdgeInsets.all(5),
child: activity.type == ActivityType.comment child: DismissibleActivity(
? getDismissibleWidget( activity.id,
ListTile( ActivityTile(activity),
minVerticalPadding: 15, onDismiss: canDelete
leading: UserCircleAvatar(user: activity.user), ? (activityId) async => await activityNotifier
title: buildTitleWithTimestamp( .removeActivity(activity.id)
activity, : null,
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,
),
); );
}, },
), ),
@ -301,7 +89,11 @@ class ActivitiesPage extends HookConsumerWidget {
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: Container( child: Container(
color: context.scaffoldBackgroundColor, color: context.scaffoldBackgroundColor,
child: buildTextField(liked?.id), child: ActivityTextField(
isEnabled: album.activityEnabled,
likeId: liked?.id,
onSubmit: onAddComment,
),
), ),
), ),
], ],

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ part of 'album_sort_by_options.provider.dart';
// ************************************************************************** // **************************************************************************
String _$albumSortByOptionsHash() => String _$albumSortByOptionsHash() =>
r'8d22fa8b7cbca2d3d7ed20a83bf00211dc948004'; r'dd8da5e730af555de1b86c3b157b6c93183523ac';
/// See also [AlbumSortByOptions]. /// See also [AlbumSortByOptions].
@ProviderFor(AlbumSortByOptions) @ProviderFor(AlbumSortByOptions)

View File

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

View File

@ -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<CurrentAlbum, Album?>.internal(
CurrentAlbum.new,
name: r'currentAlbumProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$currentAlbumHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$CurrentAlbum = AutoDisposeNotifier<Album?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<CurrentAsset, Asset?>.internal(
CurrentAsset.new,
name: r'currentAssetProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$currentAssetHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$CurrentAsset = AutoDisposeNotifier<Asset?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

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

View File

@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.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/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/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_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'; 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; 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( useEffect(
() { () {
isLoadPreview.value = isLoadPreview.value =
@ -214,7 +228,7 @@ class GalleryViewerPage extends HookConsumerWidget {
if (isDeleted && isParent) { if (isDeleted && isParent) {
if (totalAssets == 1) { if (totalAssets == 1) {
// Handle only one asset // Handle only one asset
context.autoPop(); context.popRoute();
} else { } else {
// Go to next page otherwise // Go to next page otherwise
controller.nextPage( controller.nextPage(
@ -298,7 +312,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final ratio = d.dy / max(d.dx.abs(), 1); final ratio = d.dy / max(d.dx.abs(), 1);
if (d.dy > sensitivity && ratio > ratioThreshold) { if (d.dy > sensitivity && ratio > ratioThreshold) {
context.autoPop(); context.popRoute();
} else if (d.dy < -sensitivity && ratio < -ratioThreshold) { } else if (d.dy < -sensitivity && ratio < -ratioThreshold) {
showInfo(); showInfo();
} }
@ -311,7 +325,7 @@ class GalleryViewerPage extends HookConsumerWidget {
handleArchive(Asset asset) { handleArchive(Asset asset) {
ref.watch(assetProvider.notifier).toggleArchive([asset]); ref.watch(assetProvider.notifier).toggleArchive([asset]);
if (isParent) { if (isParent) {
context.autoPop(); context.popRoute();
return; return;
} }
removeAssetFromStack(); removeAssetFromStack();
@ -334,14 +348,7 @@ class GalleryViewerPage extends HookConsumerWidget {
handleActivities() { handleActivities() {
if (album != null && album.shared && album.remoteId != null) { if (album != null && album.shared && album.remoteId != null) {
context.autoPush( context.pushRoute(const ActivitiesRoute());
ActivitiesRoute(
albumId: album.remoteId!,
assetId: asset().remoteId,
withAssetThumbs: false,
isOwner: isOwner,
),
);
} }
} }
@ -517,7 +524,7 @@ class GalleryViewerPage extends HookConsumerWidget {
stackElements.elementAt(stackIndex.value), stackElements.elementAt(stackIndex.value),
); );
ctx.pop(); ctx.pop();
context.autoPop(); context.popRoute();
}, },
title: const Text( title: const Text(
"viewer_stack_use_as_main_asset", "viewer_stack_use_as_main_asset",
@ -544,7 +551,7 @@ class GalleryViewerPage extends HookConsumerWidget {
childrenToRemove: [currentAsset], childrenToRemove: [currentAsset],
); );
ctx.pop(); ctx.pop();
context.autoPop(); context.popRoute();
} else { } else {
await ref.read(assetStackServiceProvider).updateStack( await ref.read(assetStackServiceProvider).updateStack(
currentAsset, currentAsset,
@ -572,7 +579,7 @@ class GalleryViewerPage extends HookConsumerWidget {
childrenToRemove: stack, childrenToRemove: stack,
); );
ctx.pop(); ctx.pop();
context.autoPop(); context.popRoute();
}, },
title: const Text( title: const Text(
"viewer_unstack", "viewer_unstack",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -51,6 +51,21 @@ class User {
avatarColor = dto.avatarColor.toAvatarColor(), avatarColor = dto.avatarColor.toAvatarColor(),
inTimeline = dto.inTimeline ?? false; 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) @Index(unique: true, replace: false, type: IndexType.hash)
String id; String id;
DateTime updatedAt; DateTime updatedAt;

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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; final badgeBackground = isDarkTheme ? Colors.blueGrey[800] : Colors.white;
return InkWell( return InkWell(
onTap: () => context.autoPush(const BackupControllerRoute()), onTap: () => context.pushRoute(const BackupControllerRoute()),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Badge( child: Badge(
label: Container( label: Container(

View File

@ -162,6 +162,19 @@ class ImmichImage extends StatelessWidget {
headers: authHeader, 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 /// Precaches this asset for instant load the next time it is shown
static Future<void> precacheAsset( static Future<void> precacheAsset(
Asset asset, Asset asset,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,8 @@ final class UserStub {
updatedAt: DateTime(2021), updatedAt: DateTime(2021),
email: "admin@test.com", email: "admin@test.com",
name: "admin", name: "admin",
avatarColor: AvatarColorEnum.green,
profileImagePath: '',
isAdmin: true, isAdmin: true,
); );
@ -16,6 +18,18 @@ final class UserStub {
updatedAt: DateTime(2022), updatedAt: DateTime(2022),
email: "user1@test.com", email: "user1@test.com",
name: "user1", 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, isAdmin: false,
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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