You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-08-09 23:17:29 +02:00
refactor(mobile): services and providers (#9232)
* refactor(mobile): services and provider * providers
This commit is contained in:
67
mobile/lib/providers/activity.provider.dart
Normal file
67
mobile/lib/providers/activity.provider.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/providers/activity_service.provider.dart';
|
||||
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'activity.provider.g.dart';
|
||||
|
||||
/// Maintains the current list of all activities for <share-album-id, asset>
|
||||
@riverpod
|
||||
class AlbumActivity extends _$AlbumActivity {
|
||||
@override
|
||||
Future<List<Activity>> build(String albumId, [String? assetId]) async {
|
||||
return ref
|
||||
.watch(activityServiceProvider)
|
||||
.getAllActivities(albumId, assetId: assetId);
|
||||
}
|
||||
|
||||
Future<void> removeActivity(String id) async {
|
||||
if (await ref.watch(activityServiceProvider).removeActivity(id)) {
|
||||
final activities = state.valueOrNull ?? [];
|
||||
final removedActivity = activities.firstWhere((a) => a.id == id);
|
||||
activities.remove(removedActivity);
|
||||
state = AsyncData(activities);
|
||||
// Decrement activity count only for comments
|
||||
if (removedActivity.type == ActivityType.comment) {
|
||||
ref
|
||||
.watch(activityStatisticsProvider(albumId, assetId).notifier)
|
||||
.removeActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addLike() async {
|
||||
final activity = await ref
|
||||
.watch(activityServiceProvider)
|
||||
.addActivity(albumId, ActivityType.like, assetId: assetId);
|
||||
if (activity.hasValue) {
|
||||
final activities = state.asData?.value ?? [];
|
||||
state = AsyncData([...activities, activity.requireValue]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addComment(String comment) async {
|
||||
final activity = await ref.watch(activityServiceProvider).addActivity(
|
||||
albumId,
|
||||
ActivityType.comment,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
);
|
||||
|
||||
if (activity.hasValue) {
|
||||
final activities = state.valueOrNull ?? [];
|
||||
state = AsyncData([...activities, activity.requireValue]);
|
||||
ref
|
||||
.watch(activityStatisticsProvider(albumId, assetId).notifier)
|
||||
.addActivity();
|
||||
// The previous addActivity call would increase the count of an asset if assetId != null
|
||||
// To also increase the activity count of the album, calling it once again with assetId set to null
|
||||
if (assetId != null) {
|
||||
ref.watch(activityStatisticsProvider(albumId).notifier).addActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock class for testing
|
||||
abstract class AlbumActivityInternal extends _$AlbumActivity {}
|
209
mobile/lib/providers/activity.provider.g.dart
generated
Normal file
209
mobile/lib/providers/activity.provider.g.dart
generated
Normal 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;
|
||||
|
||||
FutureOr<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
|
||||
FutureOr<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
|
9
mobile/lib/providers/activity_service.provider.dart
Normal file
9
mobile/lib/providers/activity_service.provider.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'package:immich_mobile/services/activity.service.dart';
|
||||
import 'package:immich_mobile/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));
|
25
mobile/lib/providers/activity_service.provider.g.dart
generated
Normal file
25
mobile/lib/providers/activity_service.provider.g.dart
generated
Normal 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
|
24
mobile/lib/providers/activity_statistics.provider.dart
Normal file
24
mobile/lib/providers/activity_statistics.provider.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:immich_mobile/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 {}
|
208
mobile/lib/providers/activity_statistics.provider.g.dart
generated
Normal file
208
mobile/lib/providers/activity_statistics.provider.g.dart
generated
Normal 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
|
78
mobile/lib/providers/album/album.provider.dart
Normal file
78
mobile/lib/providers/album/album.provider.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class AlbumNotifier extends StateNotifier<List<Album>> {
|
||||
AlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
final query = db.albums
|
||||
.filter()
|
||||
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
|
||||
query.findAll().then((value) {
|
||||
if (mounted) {
|
||||
state = value;
|
||||
}
|
||||
});
|
||||
_streamSub = query.watch().listen((data) => state = data);
|
||||
}
|
||||
final AlbumService _albumService;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
|
||||
Future<void> getAllAlbums() => Future.wait([
|
||||
_albumService.refreshDeviceAlbums(),
|
||||
_albumService.refreshRemoteAlbums(isShared: false),
|
||||
]);
|
||||
|
||||
Future<void> getDeviceAlbums() => _albumService.refreshDeviceAlbums();
|
||||
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
String albumTitle,
|
||||
Set<Asset> assets,
|
||||
) =>
|
||||
_albumService.createAlbum(albumTitle, assets, []);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final albumProvider =
|
||||
StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) {
|
||||
return AlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final albumWatcher =
|
||||
StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
|
||||
final db = ref.watch(dbProvider);
|
||||
final a = await db.albums.get(albumId);
|
||||
if (a != null) yield a;
|
||||
await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) {
|
||||
if (a != null) yield a;
|
||||
}
|
||||
});
|
||||
|
||||
final albumRenderlistProvider =
|
||||
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
|
||||
final album = ref.watch(albumWatcher(albumId)).value;
|
||||
if (album != null) {
|
||||
final query =
|
||||
album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
|
||||
return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
|
||||
}
|
||||
return const Stream.empty();
|
||||
});
|
131
mobile/lib/providers/album/album_sort_by_options.provider.dart
Normal file
131
mobile/lib/providers/album/album_sort_by_options.provider.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'album_sort_by_options.provider.g.dart';
|
||||
|
||||
typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse);
|
||||
|
||||
class _AlbumSortHandlers {
|
||||
const _AlbumSortHandlers._();
|
||||
|
||||
static const AlbumSortFn created = _sortByCreated;
|
||||
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sortedBy((album) => album.createdAt);
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
static const AlbumSortFn title = _sortByTitle;
|
||||
static List<Album> _sortByTitle(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sortedBy((album) => album.name);
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
static const AlbumSortFn lastModified = _sortByLastModified;
|
||||
static List<Album> _sortByLastModified(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sortedBy((album) => album.modifiedAt);
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
static const AlbumSortFn assetCount = _sortByAssetCount;
|
||||
static List<Album> _sortByAssetCount(List<Album> albums, bool isReverse) {
|
||||
final sorted =
|
||||
albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount));
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
static const AlbumSortFn mostRecent = _sortByMostRecent;
|
||||
static List<Album> _sortByMostRecent(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sorted((a, b) {
|
||||
if (a.endDate != null && b.endDate != null) {
|
||||
return a.endDate!.compareTo(b.endDate!);
|
||||
}
|
||||
if (a.endDate == null) return 1;
|
||||
if (b.endDate == null) return -1;
|
||||
return 0;
|
||||
});
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
|
||||
static const AlbumSortFn mostOldest = _sortByMostOldest;
|
||||
static List<Album> _sortByMostOldest(List<Album> albums, bool isReverse) {
|
||||
final sorted = albums.sorted((a, b) {
|
||||
if (a.startDate != null && b.startDate != null) {
|
||||
return a.startDate!.compareTo(b.startDate!);
|
||||
}
|
||||
if (a.startDate == null) return 1;
|
||||
if (b.startDate == null) return -1;
|
||||
return 0;
|
||||
});
|
||||
return (isReverse ? sorted.reversed : sorted).toList();
|
||||
}
|
||||
}
|
||||
|
||||
// Store index allows us to re-arrange the values without affecting the saved prefs
|
||||
enum AlbumSortMode {
|
||||
title(1, "library_page_sort_title", _AlbumSortHandlers.title),
|
||||
assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount),
|
||||
lastModified(
|
||||
3,
|
||||
"library_page_sort_last_modified",
|
||||
_AlbumSortHandlers.lastModified,
|
||||
),
|
||||
created(0, "library_page_sort_created", _AlbumSortHandlers.created),
|
||||
mostRecent(
|
||||
2,
|
||||
"library_page_sort_most_recent_photo",
|
||||
_AlbumSortHandlers.mostRecent,
|
||||
),
|
||||
mostOldest(
|
||||
5,
|
||||
"library_page_sort_most_oldest_photo",
|
||||
_AlbumSortHandlers.mostOldest,
|
||||
);
|
||||
|
||||
final int storeIndex;
|
||||
final String label;
|
||||
final AlbumSortFn sortFn;
|
||||
|
||||
const AlbumSortMode(this.storeIndex, this.label, this.sortFn);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class AlbumSortByOptions extends _$AlbumSortByOptions {
|
||||
@override
|
||||
AlbumSortMode build() {
|
||||
final sortOpt = ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
|
||||
return AlbumSortMode.values.firstWhere(
|
||||
(e) => e.storeIndex == sortOpt,
|
||||
orElse: () => AlbumSortMode.title,
|
||||
);
|
||||
}
|
||||
|
||||
void changeSortMode(AlbumSortMode sortOption) {
|
||||
state = sortOption;
|
||||
ref.watch(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.selectedAlbumSortOrder,
|
||||
sortOption.storeIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class AlbumSortOrder extends _$AlbumSortOrder {
|
||||
@override
|
||||
bool build() {
|
||||
return ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
|
||||
}
|
||||
|
||||
void changeSortDirection(bool isReverse) {
|
||||
state = isReverse;
|
||||
ref
|
||||
.watch(appSettingsServiceProvider)
|
||||
.setSetting(AppSettingsEnum.selectedAlbumSortReverse, isReverse);
|
||||
}
|
||||
}
|
43
mobile/lib/providers/album/album_sort_by_options.provider.g.dart
generated
Normal file
43
mobile/lib/providers/album/album_sort_by_options.provider.g.dart
generated
Normal file
@@ -0,0 +1,43 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'album_sort_by_options.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$albumSortByOptionsHash() =>
|
||||
r'dd8da5e730af555de1b86c3b157b6c93183523ac';
|
||||
|
||||
/// See also [AlbumSortByOptions].
|
||||
@ProviderFor(AlbumSortByOptions)
|
||||
final albumSortByOptionsProvider =
|
||||
AutoDisposeNotifierProvider<AlbumSortByOptions, AlbumSortMode>.internal(
|
||||
AlbumSortByOptions.new,
|
||||
name: r'albumSortByOptionsProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$albumSortByOptionsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AlbumSortByOptions = AutoDisposeNotifier<AlbumSortMode>;
|
||||
String _$albumSortOrderHash() => r'573dea45b4519e69386fc7104c72522e35713440';
|
||||
|
||||
/// See also [AlbumSortOrder].
|
||||
@ProviderFor(AlbumSortOrder)
|
||||
final albumSortOrderProvider =
|
||||
AutoDisposeNotifierProvider<AlbumSortOrder, bool>.internal(
|
||||
AlbumSortOrder.new,
|
||||
name: r'albumSortOrderProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$albumSortOrderHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AlbumSortOrder = AutoDisposeNotifier<bool>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
17
mobile/lib/providers/album/album_title.provider.dart
Normal file
17
mobile/lib/providers/album/album_title.provider.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class AlbumTitleNotifier extends StateNotifier<String> {
|
||||
AlbumTitleNotifier() : super("");
|
||||
|
||||
setAlbumTitle(String title) {
|
||||
state = title;
|
||||
}
|
||||
|
||||
clearAlbumTitle() {
|
||||
state = "";
|
||||
}
|
||||
}
|
||||
|
||||
final albumTitleProvider = StateNotifierProvider<AlbumTitleNotifier, String>(
|
||||
(ref) => AlbumTitleNotifier(),
|
||||
);
|
56
mobile/lib/providers/album/album_viewer.provider.dart
Normal file
56
mobile/lib/providers/album/album_viewer.provider.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/albums/album_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
|
||||
class AlbumViewerNotifier extends StateNotifier<AlbumViewerPageState> {
|
||||
AlbumViewerNotifier(this.ref)
|
||||
: super(AlbumViewerPageState(editTitleText: "", isEditAlbum: false));
|
||||
|
||||
final Ref ref;
|
||||
|
||||
void enableEditAlbum() {
|
||||
state = state.copyWith(isEditAlbum: true);
|
||||
}
|
||||
|
||||
void disableEditAlbum() {
|
||||
state = state.copyWith(isEditAlbum: false);
|
||||
}
|
||||
|
||||
void setEditTitleText(String newTitle) {
|
||||
state = state.copyWith(editTitleText: newTitle);
|
||||
}
|
||||
|
||||
void remoteEditTitleText() {
|
||||
state = state.copyWith(editTitleText: "");
|
||||
}
|
||||
|
||||
void resetState() {
|
||||
state = state.copyWith(editTitleText: "", isEditAlbum: false);
|
||||
}
|
||||
|
||||
Future<bool> changeAlbumTitle(
|
||||
Album album,
|
||||
String newAlbumTitle,
|
||||
) async {
|
||||
AlbumService service = ref.watch(albumServiceProvider);
|
||||
|
||||
bool isSuccess = await service.changeTitleAlbum(album, newAlbumTitle);
|
||||
|
||||
if (isSuccess) {
|
||||
state = state.copyWith(editTitleText: "", isEditAlbum: false);
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
state = state.copyWith(editTitleText: "", isEditAlbum: false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final albumViewerProvider =
|
||||
StateNotifierProvider<AlbumViewerNotifier, AlbumViewerPageState>((ref) {
|
||||
return AlbumViewerNotifier(ref);
|
||||
});
|
15
mobile/lib/providers/album/current_album.provider.dart
Normal file
15
mobile/lib/providers/album/current_album.provider.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'current_album.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class CurrentAlbum extends _$CurrentAlbum {
|
||||
@override
|
||||
Album? build() => null;
|
||||
|
||||
void set(Album? a) => state = a;
|
||||
}
|
||||
|
||||
/// Mock class for testing
|
||||
abstract class CurrentAlbumInternal extends _$CurrentAlbum {}
|
25
mobile/lib/providers/album/current_album.provider.g.dart
generated
Normal file
25
mobile/lib/providers/album/current_album.provider.g.dart
generated
Normal 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
|
90
mobile/lib/providers/album/shared_album.provider.dart
Normal file
90
mobile/lib/providers/album/shared_album.provider.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
|
||||
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
|
||||
query.findAll().then((value) {
|
||||
if (mounted) {
|
||||
state = value;
|
||||
}
|
||||
});
|
||||
_streamSub = query.watch().listen((data) => state = data);
|
||||
}
|
||||
|
||||
final AlbumService _albumService;
|
||||
late final StreamSubscription<List<Album>> _streamSub;
|
||||
|
||||
Future<Album?> createSharedAlbum(
|
||||
String albumName,
|
||||
Iterable<Asset> assets,
|
||||
Iterable<User> sharedUsers,
|
||||
) async {
|
||||
try {
|
||||
return await _albumService.createAlbum(
|
||||
albumName,
|
||||
assets,
|
||||
sharedUsers,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> getAllSharedAlbums() =>
|
||||
_albumService.refreshRemoteAlbums(isShared: true);
|
||||
|
||||
Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album);
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
var res = await _albumService.leaveAlbum(album);
|
||||
|
||||
if (res) {
|
||||
await deleteAlbum(album);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
|
||||
return _albumService.removeAssetFromAlbum(album, assets);
|
||||
}
|
||||
|
||||
Future<bool> removeUserFromAlbum(Album album, User user) async {
|
||||
final result = await _albumService.removeUserFromAlbum(album, user);
|
||||
|
||||
if (result && album.sharedUsers.isEmpty) {
|
||||
state = state.where((element) => element.id != album.id).toList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool activityEnabled) {
|
||||
return _albumService.setActivityEnabled(album, activityEnabled);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final sharedAlbumProvider =
|
||||
StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) {
|
||||
return SharedAlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
@@ -0,0 +1,9 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/services/user.service.dart';
|
||||
|
||||
final otherUsersProvider = FutureProvider.autoDispose<List<User>>((ref) {
|
||||
UserService userService = ref.watch(userServiceProvider);
|
||||
|
||||
return userService.getUsersInDb();
|
||||
});
|
7
mobile/lib/providers/api.provider.dart
Normal file
7
mobile/lib/providers/api.provider.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'api.provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ApiService apiService(ApiServiceRef ref) => ApiService();
|
24
mobile/lib/providers/api.provider.g.dart
generated
Normal file
24
mobile/lib/providers/api.provider.g.dart
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'api.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$apiServiceHash() => r'5b8beddb448316bdae5e3963ff77601653715729';
|
||||
|
||||
/// See also [apiService].
|
||||
@ProviderFor(apiService)
|
||||
final apiServiceProvider = Provider<ApiService>.internal(
|
||||
apiService,
|
||||
name: r'apiServiceProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$apiServiceHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef ApiServiceRef = ProviderRef<ApiService>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
114
mobile/lib/providers/app_life_cycle.provider.dart
Normal file
114
mobile/lib/providers/app_life_cycle.provider.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/services/immich_logger.service.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
enum AppLifeCycleEnum {
|
||||
active,
|
||||
inactive,
|
||||
paused,
|
||||
resumed,
|
||||
detached,
|
||||
hidden,
|
||||
}
|
||||
|
||||
class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
final Ref _ref;
|
||||
bool _wasPaused = false;
|
||||
|
||||
AppLifeCycleNotifier(this._ref) : super(AppLifeCycleEnum.active);
|
||||
|
||||
AppLifeCycleEnum getAppState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
void handleAppResume() {
|
||||
state = AppLifeCycleEnum.resumed;
|
||||
|
||||
// no need to resume because app was never really paused
|
||||
if (!_wasPaused) return;
|
||||
_wasPaused = false;
|
||||
|
||||
final isAuthenticated = _ref.read(authenticationProvider).isAuthenticated;
|
||||
|
||||
// Needs to be logged in
|
||||
if (isAuthenticated) {
|
||||
final permission = _ref.watch(galleryPermissionNotifier);
|
||||
if (permission.isGranted || permission.isLimited) {
|
||||
_ref.read(backupProvider.notifier).resumeBackup();
|
||||
_ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
|
||||
}
|
||||
_ref.read(serverInfoProvider.notifier).getServerVersion();
|
||||
switch (_ref.read(tabProvider)) {
|
||||
case TabEnum.home:
|
||||
_ref.read(assetProvider.notifier).getAllAsset();
|
||||
_ref.read(assetProvider.notifier).getPartnerAssets();
|
||||
case TabEnum.search:
|
||||
// nothing to do
|
||||
case TabEnum.sharing:
|
||||
_ref.read(assetProvider.notifier).getPartnerAssets();
|
||||
_ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
case TabEnum.library:
|
||||
_ref.read(albumProvider.notifier).getAllAlbums();
|
||||
}
|
||||
}
|
||||
|
||||
_ref.read(websocketProvider.notifier).connect();
|
||||
|
||||
_ref
|
||||
.read(notificationPermissionProvider.notifier)
|
||||
.getNotificationPermission();
|
||||
_ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus();
|
||||
|
||||
_ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
|
||||
|
||||
_ref.invalidate(memoryFutureProvider);
|
||||
}
|
||||
|
||||
void handleAppInactivity() {
|
||||
state = AppLifeCycleEnum.inactive;
|
||||
// do not stop/clean up anything on inactivity: issued on every orientation change
|
||||
}
|
||||
|
||||
void handleAppPause() {
|
||||
state = AppLifeCycleEnum.paused;
|
||||
_wasPaused = true;
|
||||
// Do not cancel backup if manual upload is in progress
|
||||
if (_ref.read(backupProvider.notifier).backupProgress !=
|
||||
BackUpProgressEnum.manualInProgress) {
|
||||
_ref.read(backupProvider.notifier).cancelBackup();
|
||||
}
|
||||
_ref.read(websocketProvider.notifier).disconnect();
|
||||
ImmichLogger().flush();
|
||||
}
|
||||
|
||||
void handleAppDetached() {
|
||||
state = AppLifeCycleEnum.detached;
|
||||
// no guarantee this is called at all
|
||||
_ref.read(manualUploadProvider.notifier).cancelBackup();
|
||||
}
|
||||
|
||||
void handleAppHidden() {
|
||||
state = AppLifeCycleEnum.hidden;
|
||||
// do not stop/clean up anything on inactivity: issued on every orientation change
|
||||
}
|
||||
}
|
||||
|
||||
final appStateProvider =
|
||||
StateNotifierProvider<AppLifeCycleNotifier, AppLifeCycleEnum>((ref) {
|
||||
return AppLifeCycleNotifier(ref);
|
||||
});
|
8
mobile/lib/providers/app_settings.provider.dart
Normal file
8
mobile/lib/providers/app_settings.provider.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'app_settings.provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
AppSettingsService appSettingsService(AppSettingsServiceRef ref) =>
|
||||
AppSettingsService();
|
26
mobile/lib/providers/app_settings.provider.g.dart
generated
Normal file
26
mobile/lib/providers/app_settings.provider.g.dart
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_settings.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$appSettingsServiceHash() =>
|
||||
r'45ea609a91d250290431a7a08a14d16b37c7515d';
|
||||
|
||||
/// See also [appSettingsService].
|
||||
@ProviderFor(appSettingsService)
|
||||
final appSettingsServiceProvider = Provider<AppSettingsService>.internal(
|
||||
appSettingsService,
|
||||
name: r'appSettingsServiceProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$appSettingsServiceHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef AppSettingsServiceRef = ProviderRef<AppSettingsService>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
22
mobile/lib/providers/archive.provider.dart
Normal file
22
mobile/lib/providers/archive.provider.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
final archiveProvider = StreamProvider<RenderList>((ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) return const Stream.empty();
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(user.isarId)
|
||||
.filter()
|
||||
.isArchivedEqualTo(true)
|
||||
.isTrashedEqualTo(false)
|
||||
.sortByFileCreatedAtDesc();
|
||||
return renderListGenerator(query, ref);
|
||||
});
|
392
mobile/lib/providers/asset.provider.dart
Normal file
392
mobile/lib/providers/asset.provider.dart
Normal file
@@ -0,0 +1,392 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/asset.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:immich_mobile/services/user.service.dart';
|
||||
import 'package:immich_mobile/utils/db.dart';
|
||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class AssetNotifier extends StateNotifier<bool> {
|
||||
final AssetService _assetService;
|
||||
final AlbumService _albumService;
|
||||
final UserService _userService;
|
||||
final SyncService _syncService;
|
||||
final Isar _db;
|
||||
final log = Logger('AssetNotifier');
|
||||
bool _getAllAssetInProgress = false;
|
||||
bool _deleteInProgress = false;
|
||||
bool _getPartnerAssetsInProgress = false;
|
||||
|
||||
AssetNotifier(
|
||||
this._assetService,
|
||||
this._albumService,
|
||||
this._userService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
) : super(false);
|
||||
|
||||
Future<void> getAllAsset({bool clear = false}) async {
|
||||
if (_getAllAssetInProgress || _deleteInProgress) {
|
||||
// guard against multiple calls to this method while it's still working
|
||||
return;
|
||||
}
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
_getAllAssetInProgress = true;
|
||||
state = true;
|
||||
if (clear) {
|
||||
await clearAssetsAndAlbums(_db);
|
||||
log.info("Manual refresh requested, cleared assets and albums from db");
|
||||
}
|
||||
final bool newRemote = await _assetService.refreshRemoteAssets();
|
||||
final bool newLocal = await _albumService.refreshDeviceAlbums();
|
||||
debugPrint("newRemote: $newRemote, newLocal: $newLocal");
|
||||
|
||||
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
|
||||
} finally {
|
||||
_getAllAssetInProgress = false;
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> getPartnerAssets([User? partner]) async {
|
||||
if (_getPartnerAssetsInProgress) return;
|
||||
try {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
_getPartnerAssetsInProgress = true;
|
||||
if (partner == null) {
|
||||
await _userService.refreshUsers();
|
||||
final List<User> partners =
|
||||
await _db.users.filter().isPartnerSharedWithEqualTo(true).findAll();
|
||||
for (User u in partners) {
|
||||
await _assetService.refreshRemoteAssets(u);
|
||||
}
|
||||
} else {
|
||||
await _assetService.refreshRemoteAssets(partner);
|
||||
}
|
||||
log.info("Load partner assets: ${stopwatch.elapsedMilliseconds}ms");
|
||||
} finally {
|
||||
_getPartnerAssetsInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAllAsset() {
|
||||
return clearAssetsAndAlbums(_db);
|
||||
}
|
||||
|
||||
Future<void> onNewAssetUploaded(Asset newAsset) async {
|
||||
// eTag on device is not valid after partially modifying the assets
|
||||
Store.delete(StoreKey.assetETag);
|
||||
await _syncService.syncNewAssetToDb(newAsset);
|
||||
}
|
||||
|
||||
Future<bool> deleteLocalOnlyAssets(
|
||||
Iterable<Asset> deleteAssets, {
|
||||
bool onlyBackedUp = false,
|
||||
}) async {
|
||||
_deleteInProgress = true;
|
||||
state = true;
|
||||
try {
|
||||
final assets = onlyBackedUp
|
||||
? deleteAssets.where((e) => e.storage == AssetState.merged)
|
||||
: deleteAssets;
|
||||
final localDeleted = await _deleteLocalAssets(assets);
|
||||
if (localDeleted.isNotEmpty) {
|
||||
final localOnlyIds = deleteAssets
|
||||
.where((e) => e.storage == AssetState.local)
|
||||
.map((e) => e.id)
|
||||
.toList();
|
||||
// Update merged assets to remote only
|
||||
final mergedAssets =
|
||||
deleteAssets.where((e) => e.storage == AssetState.merged).map((e) {
|
||||
e.localId = null;
|
||||
return e;
|
||||
}).toList();
|
||||
await _db.writeTxn(() async {
|
||||
if (mergedAssets.isNotEmpty) {
|
||||
await _db.assets.putAll(mergedAssets);
|
||||
}
|
||||
await _db.exifInfos.deleteAll(localOnlyIds);
|
||||
await _db.assets.deleteAll(localOnlyIds);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} finally {
|
||||
_deleteInProgress = false;
|
||||
state = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteRemoteOnlyAssets(
|
||||
Iterable<Asset> deleteAssets, {
|
||||
bool force = false,
|
||||
}) async {
|
||||
_deleteInProgress = true;
|
||||
state = true;
|
||||
try {
|
||||
final remoteDeleted = await _deleteRemoteAssets(deleteAssets, force);
|
||||
if (remoteDeleted.isNotEmpty) {
|
||||
final assetsToUpdate = force
|
||||
|
||||
/// If force, only update merged only assets and remove remote assets
|
||||
? remoteDeleted
|
||||
.where((e) => e.storage == AssetState.merged)
|
||||
.map((e) {
|
||||
e.remoteId = null;
|
||||
return e;
|
||||
})
|
||||
// If not force, trash everything
|
||||
: remoteDeleted.where((e) => e.isRemote).map((e) {
|
||||
e.isTrashed = true;
|
||||
return e;
|
||||
});
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
if (assetsToUpdate.isNotEmpty) {
|
||||
await _db.assets.putAll(assetsToUpdate.toList());
|
||||
}
|
||||
if (force) {
|
||||
final remoteOnly = remoteDeleted
|
||||
.where((e) => e.storage == AssetState.remote)
|
||||
.map((e) => e.id)
|
||||
.toList();
|
||||
await _db.exifInfos.deleteAll(remoteOnly);
|
||||
await _db.assets.deleteAll(remoteOnly);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} finally {
|
||||
_deleteInProgress = false;
|
||||
state = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAssets(
|
||||
Iterable<Asset> deleteAssets, {
|
||||
bool force = false,
|
||||
}) async {
|
||||
_deleteInProgress = true;
|
||||
state = true;
|
||||
try {
|
||||
final hasLocal = deleteAssets.any((a) => a.storage != AssetState.remote);
|
||||
final localDeleted = await _deleteLocalAssets(deleteAssets);
|
||||
final remoteDeleted = (hasLocal && localDeleted.isNotEmpty) || !hasLocal
|
||||
? await _deleteRemoteAssets(deleteAssets, force)
|
||||
: [];
|
||||
if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
|
||||
final dbIds = <int>[];
|
||||
final dbUpdates = <Asset>[];
|
||||
|
||||
// Local assets are removed
|
||||
if (localDeleted.isNotEmpty) {
|
||||
// Permanently remove local only assets from isar
|
||||
dbIds.addAll(
|
||||
deleteAssets
|
||||
.where((a) => a.storage == AssetState.local)
|
||||
.map((e) => e.id),
|
||||
);
|
||||
|
||||
if (remoteDeleted.any((e) => e.isLocal)) {
|
||||
// Force delete: Add all local assets including merged assets
|
||||
if (force) {
|
||||
dbIds.addAll(remoteDeleted.map((e) => e.id));
|
||||
// Soft delete: Remove local Id from asset and trash it
|
||||
} else {
|
||||
dbUpdates.addAll(
|
||||
remoteDeleted.map((e) {
|
||||
e.localId = null;
|
||||
e.isTrashed = true;
|
||||
return e;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remote deletion
|
||||
if (remoteDeleted.isNotEmpty) {
|
||||
if (force) {
|
||||
// Remove remote only assets
|
||||
dbIds.addAll(
|
||||
deleteAssets
|
||||
.where((a) => a.storage == AssetState.remote)
|
||||
.map((e) => e.id),
|
||||
);
|
||||
// Local assets are not removed and there are merged assets
|
||||
final hasLocal = remoteDeleted.any((e) => e.isLocal);
|
||||
if (localDeleted.isEmpty && hasLocal) {
|
||||
// Remove remote Id from local assets
|
||||
dbUpdates.addAll(
|
||||
remoteDeleted.map((e) {
|
||||
e.remoteId = null;
|
||||
// Remove from trashed if remote asset is removed
|
||||
e.isTrashed = false;
|
||||
return e;
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
dbUpdates.addAll(
|
||||
remoteDeleted.map((e) {
|
||||
e.isTrashed = true;
|
||||
return e;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(dbUpdates);
|
||||
await _db.exifInfos.deleteAll(dbIds);
|
||||
await _db.assets.deleteAll(dbIds);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} finally {
|
||||
_deleteInProgress = false;
|
||||
state = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<List<String>> _deleteLocalAssets(
|
||||
Iterable<Asset> assetsToDelete,
|
||||
) async {
|
||||
final List<String> local =
|
||||
assetsToDelete.where((a) => a.isLocal).map((a) => a.localId!).toList();
|
||||
// Delete asset from device
|
||||
if (local.isNotEmpty) {
|
||||
try {
|
||||
return await PhotoManager.editor.deleteWithIds(local);
|
||||
} catch (e, stack) {
|
||||
log.severe("Failed to delete asset from device", e, stack);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<Asset>> _deleteRemoteAssets(
|
||||
Iterable<Asset> assetsToDelete,
|
||||
bool? force,
|
||||
) async {
|
||||
final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
|
||||
|
||||
final isSuccess = await _assetService.deleteAssets(remote, force: force);
|
||||
return isSuccess ? remote.toList() : [];
|
||||
}
|
||||
|
||||
Future<void> toggleFavorite(List<Asset> assets, [bool? status]) async {
|
||||
status ??= !assets.every((a) => a.isFavorite);
|
||||
final newAssets = await _assetService.changeFavoriteStatus(assets, status);
|
||||
for (Asset? newAsset in newAssets) {
|
||||
if (newAsset == null) {
|
||||
log.severe("Change favorite status failed for asset");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleArchive(List<Asset> assets, [bool? status]) async {
|
||||
status ??= !assets.every((a) => a.isArchived);
|
||||
final newAssets = await _assetService.changeArchiveStatus(assets, status);
|
||||
int i = 0;
|
||||
for (Asset oldAsset in assets) {
|
||||
final newAsset = newAssets[i++];
|
||||
if (newAsset == null) {
|
||||
log.severe("Change archive status failed for asset ${oldAsset.id}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final assetProvider = StateNotifierProvider<AssetNotifier, bool>((ref) {
|
||||
return AssetNotifier(
|
||||
ref.watch(assetServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final assetDetailProvider =
|
||||
StreamProvider.autoDispose.family<Asset, Asset>((ref, asset) async* {
|
||||
yield await ref.watch(assetServiceProvider).loadExif(asset);
|
||||
final db = ref.watch(dbProvider);
|
||||
await for (final a in db.assets.watchObject(asset.id)) {
|
||||
if (a != null) {
|
||||
yield await ref.watch(assetServiceProvider).loadExif(a);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final assetWatcher =
|
||||
StreamProvider.autoDispose.family<Asset?, Asset>((ref, asset) {
|
||||
final db = ref.watch(dbProvider);
|
||||
return db.assets.watchObject(asset.id, fireImmediately: true);
|
||||
});
|
||||
|
||||
final assetsProvider = StreamProvider.family<RenderList, int?>((ref, userId) {
|
||||
if (userId == null) return const Stream.empty();
|
||||
final query = _commonFilterAndSort(
|
||||
_assets(ref).where().ownerIdEqualToAnyChecksum(userId),
|
||||
);
|
||||
return renderListGenerator(query, ref);
|
||||
});
|
||||
|
||||
final multiUserAssetsProvider =
|
||||
StreamProvider.family<RenderList, List<int>>((ref, userIds) {
|
||||
if (userIds.isEmpty) return const Stream.empty();
|
||||
final query = _commonFilterAndSort(
|
||||
_assets(ref)
|
||||
.where()
|
||||
.anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)),
|
||||
);
|
||||
return renderListGenerator(query, ref);
|
||||
});
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
|
||||
final userId = ref.watch(currentUserProvider)?.isarId;
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
return ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.where()
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(userId)
|
||||
.isTrashedEqualTo(false)
|
||||
.stackParentIdIsNull()
|
||||
.sortByFileCreatedAtDesc();
|
||||
}
|
||||
|
||||
IsarCollection<Asset> _assets(StreamProviderRef<RenderList> ref) =>
|
||||
ref.watch(dbProvider).assets;
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> _commonFilterAndSort(
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> query,
|
||||
) {
|
||||
return query
|
||||
.filter()
|
||||
.isArchivedEqualTo(false)
|
||||
.isTrashedEqualTo(false)
|
||||
.stackParentIdIsNull()
|
||||
.sortByFileCreatedAtDesc();
|
||||
}
|
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/asset_description.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class AssetDescriptionNotifier extends StateNotifier<String> {
|
||||
final Isar _db;
|
||||
final AssetDescriptionService _service;
|
||||
final Asset _asset;
|
||||
|
||||
AssetDescriptionNotifier(
|
||||
this._db,
|
||||
this._service,
|
||||
this._asset,
|
||||
) : super('') {
|
||||
_fetchLocalDescription();
|
||||
_fetchRemoteDescription();
|
||||
}
|
||||
|
||||
String get description => state;
|
||||
|
||||
/// Fetches the local database value for description
|
||||
/// and writes it to [state]
|
||||
void _fetchLocalDescription() async {
|
||||
final localExifId = _asset.exifInfo?.id;
|
||||
|
||||
// Guard [localExifId] null
|
||||
if (localExifId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to local changes
|
||||
final exifInfo = await _db.exifInfos.get(localExifId);
|
||||
|
||||
// Guard
|
||||
if (exifInfo?.description == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = exifInfo!.description!;
|
||||
}
|
||||
|
||||
/// Fetches the remote value and sets the state
|
||||
void _fetchRemoteDescription() async {
|
||||
final remoteAssetId = _asset.remoteId;
|
||||
final localExifId = _asset.exifInfo?.id;
|
||||
|
||||
// Guard [remoteAssetId] and [localExifId] null
|
||||
if (remoteAssetId == null || localExifId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reads the latest from the remote and writes it to DB in the service
|
||||
final latest = await _service.readLatest(remoteAssetId, localExifId);
|
||||
|
||||
state = latest;
|
||||
}
|
||||
|
||||
/// Sets the description to [description]
|
||||
/// Uses the service to set the asset value
|
||||
Future<void> setDescription(String description) async {
|
||||
state = description;
|
||||
|
||||
final remoteAssetId = _asset.remoteId;
|
||||
final localExifId = _asset.exifInfo?.id;
|
||||
|
||||
// Guard [remoteAssetId] and [localExifId] null
|
||||
if (remoteAssetId == null || localExifId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return _service.setDescription(description, remoteAssetId, localExifId);
|
||||
}
|
||||
}
|
||||
|
||||
final assetDescriptionProvider = StateNotifierProvider.autoDispose
|
||||
.family<AssetDescriptionNotifier, String, Asset>(
|
||||
(ref, asset) => AssetDescriptionNotifier(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(assetDescriptionServiceProvider),
|
||||
asset,
|
||||
),
|
||||
);
|
51
mobile/lib/providers/asset_viewer/asset_people.provider.dart
Normal file
51
mobile/lib/providers/asset_viewer/asset_people.provider.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/services/asset.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'asset_people.provider.g.dart';
|
||||
|
||||
/// Maintains the list of people for an asset.
|
||||
@riverpod
|
||||
class AssetPeopleNotifier extends _$AssetPeopleNotifier {
|
||||
final log = Logger('AssetPeopleNotifier');
|
||||
|
||||
@override
|
||||
Future<List<PersonWithFacesResponseDto>> build(Asset asset) async {
|
||||
if (!asset.isRemote) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final list = await ref
|
||||
.watch(assetServiceProvider)
|
||||
.getRemotePeopleOfAsset(asset.remoteId!);
|
||||
if (list == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// explicitly a sorted slice to make it deterministic
|
||||
// named people will be at the beginning, and names are sorted
|
||||
// ascendingly
|
||||
list.sort((a, b) {
|
||||
final aNotEmpty = a.name.isNotEmpty;
|
||||
final bNotEmpty = b.name.isNotEmpty;
|
||||
if (aNotEmpty && !bNotEmpty) {
|
||||
return -1;
|
||||
} else if (!aNotEmpty && bNotEmpty) {
|
||||
return 1;
|
||||
} else if (!aNotEmpty && !bNotEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return a.name.compareTo(b.name);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
// invalidate the state – this way we don't have to
|
||||
// duplicate the code from build.
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
}
|
189
mobile/lib/providers/asset_viewer/asset_people.provider.g.dart
generated
Normal file
189
mobile/lib/providers/asset_viewer/asset_people.provider.g.dart
generated
Normal file
@@ -0,0 +1,189 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'asset_people.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$assetPeopleNotifierHash() =>
|
||||
r'9835b180984a750c91e923e7b64dbda94f6d7574';
|
||||
|
||||
/// 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 _$AssetPeopleNotifier extends BuildlessAutoDisposeAsyncNotifier<
|
||||
List<PersonWithFacesResponseDto>> {
|
||||
late final Asset asset;
|
||||
|
||||
FutureOr<List<PersonWithFacesResponseDto>> build(
|
||||
Asset asset,
|
||||
);
|
||||
}
|
||||
|
||||
/// Maintains the list of people for an asset.
|
||||
///
|
||||
/// Copied from [AssetPeopleNotifier].
|
||||
@ProviderFor(AssetPeopleNotifier)
|
||||
const assetPeopleNotifierProvider = AssetPeopleNotifierFamily();
|
||||
|
||||
/// Maintains the list of people for an asset.
|
||||
///
|
||||
/// Copied from [AssetPeopleNotifier].
|
||||
class AssetPeopleNotifierFamily
|
||||
extends Family<AsyncValue<List<PersonWithFacesResponseDto>>> {
|
||||
/// Maintains the list of people for an asset.
|
||||
///
|
||||
/// Copied from [AssetPeopleNotifier].
|
||||
const AssetPeopleNotifierFamily();
|
||||
|
||||
/// Maintains the list of people for an asset.
|
||||
///
|
||||
/// Copied from [AssetPeopleNotifier].
|
||||
AssetPeopleNotifierProvider call(
|
||||
Asset asset,
|
||||
) {
|
||||
return AssetPeopleNotifierProvider(
|
||||
asset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AssetPeopleNotifierProvider getProviderOverride(
|
||||
covariant AssetPeopleNotifierProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.asset,
|
||||
);
|
||||
}
|
||||
|
||||
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'assetPeopleNotifierProvider';
|
||||
}
|
||||
|
||||
/// Maintains the list of people for an asset.
|
||||
///
|
||||
/// Copied from [AssetPeopleNotifier].
|
||||
class AssetPeopleNotifierProvider extends AutoDisposeAsyncNotifierProviderImpl<
|
||||
AssetPeopleNotifier, List<PersonWithFacesResponseDto>> {
|
||||
/// Maintains the list of people for an asset.
|
||||
///
|
||||
/// Copied from [AssetPeopleNotifier].
|
||||
AssetPeopleNotifierProvider(
|
||||
Asset asset,
|
||||
) : this._internal(
|
||||
() => AssetPeopleNotifier()..asset = asset,
|
||||
from: assetPeopleNotifierProvider,
|
||||
name: r'assetPeopleNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$assetPeopleNotifierHash,
|
||||
dependencies: AssetPeopleNotifierFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
AssetPeopleNotifierFamily._allTransitiveDependencies,
|
||||
asset: asset,
|
||||
);
|
||||
|
||||
AssetPeopleNotifierProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.asset,
|
||||
}) : super.internal();
|
||||
|
||||
final Asset asset;
|
||||
|
||||
@override
|
||||
FutureOr<List<PersonWithFacesResponseDto>> runNotifierBuild(
|
||||
covariant AssetPeopleNotifier notifier,
|
||||
) {
|
||||
return notifier.build(
|
||||
asset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(AssetPeopleNotifier Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: AssetPeopleNotifierProvider._internal(
|
||||
() => create()..asset = asset,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
asset: asset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeAsyncNotifierProviderElement<AssetPeopleNotifier,
|
||||
List<PersonWithFacesResponseDto>> createElement() {
|
||||
return _AssetPeopleNotifierProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AssetPeopleNotifierProvider && other.asset == asset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, asset.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
mixin AssetPeopleNotifierRef
|
||||
on AutoDisposeAsyncNotifierProviderRef<List<PersonWithFacesResponseDto>> {
|
||||
/// The parameter `asset` of this provider.
|
||||
Asset get asset;
|
||||
}
|
||||
|
||||
class _AssetPeopleNotifierProviderElement
|
||||
extends AutoDisposeAsyncNotifierProviderElement<AssetPeopleNotifier,
|
||||
List<PersonWithFacesResponseDto>> with AssetPeopleNotifierRef {
|
||||
_AssetPeopleNotifierProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
Asset get asset => (origin as AssetPeopleNotifierProvider).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
|
59
mobile/lib/providers/asset_viewer/asset_stack.provider.dart
Normal file
59
mobile/lib/providers/asset_viewer/asset_stack.provider.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'asset_stack.provider.g.dart';
|
||||
|
||||
class AssetStackNotifier extends StateNotifier<List<Asset>> {
|
||||
final Asset _asset;
|
||||
final Ref _ref;
|
||||
|
||||
AssetStackNotifier(
|
||||
this._asset,
|
||||
this._ref,
|
||||
) : super([]) {
|
||||
fetchStackChildren();
|
||||
}
|
||||
|
||||
void fetchStackChildren() async {
|
||||
if (mounted) {
|
||||
state = await _ref.read(assetStackProvider(_asset).future);
|
||||
}
|
||||
}
|
||||
|
||||
void removeChild(int index) {
|
||||
if (index < state.length) {
|
||||
state.removeAt(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final assetStackStateProvider = StateNotifierProvider.autoDispose
|
||||
.family<AssetStackNotifier, List<Asset>, Asset>(
|
||||
(ref, asset) => AssetStackNotifier(asset, ref),
|
||||
);
|
||||
|
||||
final assetStackProvider =
|
||||
FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
|
||||
// Guard [local asset]
|
||||
if (asset.remoteId == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.isArchivedEqualTo(false)
|
||||
.isTrashedEqualTo(false)
|
||||
.stackParentIdEqualTo(asset.remoteId)
|
||||
.sortByFileCreatedAtDesc()
|
||||
.findAll();
|
||||
});
|
||||
|
||||
@riverpod
|
||||
int assetStackIndex(AssetStackIndexRef ref, Asset asset) {
|
||||
return -1;
|
||||
}
|
158
mobile/lib/providers/asset_viewer/asset_stack.provider.g.dart
generated
Normal file
158
mobile/lib/providers/asset_viewer/asset_stack.provider.g.dart
generated
Normal file
@@ -0,0 +1,158 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'asset_stack.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$assetStackIndexHash() => r'0f2df55e929767c8c698bd432b5e6e351d000a16';
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// See also [assetStackIndex].
|
||||
@ProviderFor(assetStackIndex)
|
||||
const assetStackIndexProvider = AssetStackIndexFamily();
|
||||
|
||||
/// See also [assetStackIndex].
|
||||
class AssetStackIndexFamily extends Family<int> {
|
||||
/// See also [assetStackIndex].
|
||||
const AssetStackIndexFamily();
|
||||
|
||||
/// See also [assetStackIndex].
|
||||
AssetStackIndexProvider call(
|
||||
Asset asset,
|
||||
) {
|
||||
return AssetStackIndexProvider(
|
||||
asset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AssetStackIndexProvider getProviderOverride(
|
||||
covariant AssetStackIndexProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.asset,
|
||||
);
|
||||
}
|
||||
|
||||
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'assetStackIndexProvider';
|
||||
}
|
||||
|
||||
/// See also [assetStackIndex].
|
||||
class AssetStackIndexProvider extends AutoDisposeProvider<int> {
|
||||
/// See also [assetStackIndex].
|
||||
AssetStackIndexProvider(
|
||||
Asset asset,
|
||||
) : this._internal(
|
||||
(ref) => assetStackIndex(
|
||||
ref as AssetStackIndexRef,
|
||||
asset,
|
||||
),
|
||||
from: assetStackIndexProvider,
|
||||
name: r'assetStackIndexProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$assetStackIndexHash,
|
||||
dependencies: AssetStackIndexFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
AssetStackIndexFamily._allTransitiveDependencies,
|
||||
asset: asset,
|
||||
);
|
||||
|
||||
AssetStackIndexProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.asset,
|
||||
}) : super.internal();
|
||||
|
||||
final Asset asset;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
int Function(AssetStackIndexRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: AssetStackIndexProvider._internal(
|
||||
(ref) => create(ref as AssetStackIndexRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
asset: asset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeProviderElement<int> createElement() {
|
||||
return _AssetStackIndexProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AssetStackIndexProvider && other.asset == asset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, asset.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
mixin AssetStackIndexRef on AutoDisposeProviderRef<int> {
|
||||
/// The parameter `asset` of this provider.
|
||||
Asset get asset;
|
||||
}
|
||||
|
||||
class _AssetStackIndexProviderElement extends AutoDisposeProviderElement<int>
|
||||
with AssetStackIndexRef {
|
||||
_AssetStackIndexProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
Asset get asset => (origin as AssetStackIndexProvider).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
|
@@ -0,0 +1,15 @@
|
||||
import 'package:immich_mobile/entities/asset.entity.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 {}
|
25
mobile/lib/providers/asset_viewer/current_asset.provider.g.dart
generated
Normal file
25
mobile/lib/providers/asset_viewer/current_asset.provider.g.dart
generated
Normal file
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'current_asset.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$currentAssetHash() => r'2def10ea594152c984ae2974d687ab6856d7bdd0';
|
||||
|
||||
/// 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
|
@@ -0,0 +1,95 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/services/image_viewer.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
|
||||
class ImageViewerStateNotifier extends StateNotifier<AssetViewerPageState> {
|
||||
final ImageViewerService _imageViewerService;
|
||||
final ShareService _shareService;
|
||||
final AlbumService _albumService;
|
||||
|
||||
ImageViewerStateNotifier(
|
||||
this._imageViewerService,
|
||||
this._shareService,
|
||||
this._albumService,
|
||||
) : super(
|
||||
AssetViewerPageState(
|
||||
downloadAssetStatus: DownloadAssetStatus.idle,
|
||||
),
|
||||
);
|
||||
|
||||
void downloadAsset(Asset asset, BuildContext context) async {
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'image_viewer_page_state_provider_download_started'.tr(),
|
||||
toastType: ToastType.info,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
|
||||
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);
|
||||
|
||||
if (isSuccess) {
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success);
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'image_viewer_page_state_provider_download_success'.tr(),
|
||||
toastType: ToastType.success,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
_albumService.refreshDeviceAlbums();
|
||||
} else {
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error);
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'image_viewer_page_state_provider_download_error'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
|
||||
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
|
||||
}
|
||||
|
||||
void shareAsset(Asset asset, BuildContext context) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
_shareService.shareAsset(asset).then(
|
||||
(bool status) {
|
||||
if (!status) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'image_viewer_page_state_provider_share_error'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
buildContext.pop();
|
||||
},
|
||||
);
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final imageViewerStateProvider =
|
||||
StateNotifierProvider<ImageViewerStateNotifier, AssetViewerPageState>(
|
||||
((ref) => ImageViewerStateNotifier(
|
||||
ref.watch(imageViewerServiceProvider),
|
||||
ref.watch(shareServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
)),
|
||||
);
|
32
mobile/lib/providers/asset_viewer/render_list.provider.dart
Normal file
32
mobile/lib/providers/asset_viewer/render_list.provider.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
final renderListProvider =
|
||||
FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
return RenderList.fromAssets(
|
||||
assets,
|
||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
|
||||
);
|
||||
});
|
||||
|
||||
final renderListProviderWithGrouping =
|
||||
FutureProvider.family<RenderList, (List<Asset>, GroupAssetsBy?)>(
|
||||
(ref, args) {
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final grouping = args.$2 ??
|
||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
|
||||
return RenderList.fromAssets(args.$1, grouping);
|
||||
});
|
||||
|
||||
final renderListQueryProvider = StreamProvider.family<RenderList,
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy>?>(
|
||||
(ref, query) =>
|
||||
query == null ? const Stream.empty() : renderListGenerator(query, ref),
|
||||
);
|
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final scrollToTopNotifierProvider = ScrollNotifier();
|
||||
|
||||
class ScrollNotifier with ChangeNotifier {
|
||||
void scrollToTop() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final scrollToDateNotifierProvider = ScrollToDateNotifier(null);
|
||||
|
||||
class ScrollToDateNotifier extends ValueNotifier<DateTime?> {
|
||||
ScrollToDateNotifier(super.value);
|
||||
|
||||
void scrollToDate(DateTime date) {
|
||||
value = date;
|
||||
|
||||
// Manually notify listeners to trigger the scroll, even if the value hasn't changed
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final showControlsProvider = StateNotifierProvider<ShowControls, bool>((ref) {
|
||||
return ShowControls(ref);
|
||||
});
|
||||
|
||||
class ShowControls extends StateNotifier<bool> {
|
||||
ShowControls(this.ref) : super(true);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
bool get show => state;
|
||||
|
||||
set show(bool value) {
|
||||
state = value;
|
||||
}
|
||||
|
||||
void toggle() {
|
||||
state = !state;
|
||||
}
|
||||
}
|
@@ -0,0 +1,44 @@
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
part 'video_player_controller_provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<VideoPlayerController> videoPlayerController(
|
||||
VideoPlayerControllerRef ref, {
|
||||
required Asset asset,
|
||||
}) async {
|
||||
late VideoPlayerController controller;
|
||||
if (asset.isLocal && asset.livePhotoVideoId == null) {
|
||||
// Use a local file for the video player controller
|
||||
final file = await asset.local!.file;
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
controller = VideoPlayerController.file(file);
|
||||
} else {
|
||||
// Use a network URL for the video player controller
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final String videoUrl = asset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/asset/file/${asset.livePhotoVideoId}'
|
||||
: '$serverEndpoint/asset/file/${asset.remoteId}';
|
||||
|
||||
final url = Uri.parse(videoUrl);
|
||||
final accessToken = Store.get(StoreKey.accessToken);
|
||||
|
||||
controller = VideoPlayerController.networkUrl(
|
||||
url,
|
||||
httpHeaders: {"x-immich-user-token": accessToken},
|
||||
);
|
||||
}
|
||||
|
||||
await controller.initialize();
|
||||
|
||||
ref.onDispose(() {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
return controller;
|
||||
}
|
164
mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart
generated
Normal file
164
mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart
generated
Normal file
@@ -0,0 +1,164 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'video_player_controller_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$videoPlayerControllerHash() =>
|
||||
r'40b31f7b1a73fab84c311b0f06bedf5322143cd9';
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// See also [videoPlayerController].
|
||||
@ProviderFor(videoPlayerController)
|
||||
const videoPlayerControllerProvider = VideoPlayerControllerFamily();
|
||||
|
||||
/// See also [videoPlayerController].
|
||||
class VideoPlayerControllerFamily
|
||||
extends Family<AsyncValue<VideoPlayerController>> {
|
||||
/// See also [videoPlayerController].
|
||||
const VideoPlayerControllerFamily();
|
||||
|
||||
/// See also [videoPlayerController].
|
||||
VideoPlayerControllerProvider call({
|
||||
required Asset asset,
|
||||
}) {
|
||||
return VideoPlayerControllerProvider(
|
||||
asset: asset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
VideoPlayerControllerProvider getProviderOverride(
|
||||
covariant VideoPlayerControllerProvider provider,
|
||||
) {
|
||||
return call(
|
||||
asset: provider.asset,
|
||||
);
|
||||
}
|
||||
|
||||
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'videoPlayerControllerProvider';
|
||||
}
|
||||
|
||||
/// See also [videoPlayerController].
|
||||
class VideoPlayerControllerProvider
|
||||
extends AutoDisposeFutureProvider<VideoPlayerController> {
|
||||
/// See also [videoPlayerController].
|
||||
VideoPlayerControllerProvider({
|
||||
required Asset asset,
|
||||
}) : this._internal(
|
||||
(ref) => videoPlayerController(
|
||||
ref as VideoPlayerControllerRef,
|
||||
asset: asset,
|
||||
),
|
||||
from: videoPlayerControllerProvider,
|
||||
name: r'videoPlayerControllerProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$videoPlayerControllerHash,
|
||||
dependencies: VideoPlayerControllerFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
VideoPlayerControllerFamily._allTransitiveDependencies,
|
||||
asset: asset,
|
||||
);
|
||||
|
||||
VideoPlayerControllerProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.asset,
|
||||
}) : super.internal();
|
||||
|
||||
final Asset asset;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<VideoPlayerController> Function(VideoPlayerControllerRef provider)
|
||||
create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: VideoPlayerControllerProvider._internal(
|
||||
(ref) => create(ref as VideoPlayerControllerRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
asset: asset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<VideoPlayerController> createElement() {
|
||||
return _VideoPlayerControllerProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is VideoPlayerControllerProvider && other.asset == asset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, asset.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
mixin VideoPlayerControllerRef
|
||||
on AutoDisposeFutureProviderRef<VideoPlayerController> {
|
||||
/// The parameter `asset` of this provider.
|
||||
Asset get asset;
|
||||
}
|
||||
|
||||
class _VideoPlayerControllerProviderElement
|
||||
extends AutoDisposeFutureProviderElement<VideoPlayerController>
|
||||
with VideoPlayerControllerRef {
|
||||
_VideoPlayerControllerProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
Asset get asset => (origin as VideoPlayerControllerProvider).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
|
@@ -0,0 +1,96 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class VideoPlaybackControls {
|
||||
VideoPlaybackControls({
|
||||
required this.position,
|
||||
required this.mute,
|
||||
required this.pause,
|
||||
});
|
||||
|
||||
final double position;
|
||||
final bool mute;
|
||||
final bool pause;
|
||||
}
|
||||
|
||||
final videoPlayerControlsProvider =
|
||||
StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
|
||||
return VideoPlayerControls(ref);
|
||||
});
|
||||
|
||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
VideoPlayerControls(this.ref)
|
||||
: super(
|
||||
VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
),
|
||||
);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
VideoPlaybackControls get value => state;
|
||||
|
||||
set value(VideoPlaybackControls value) {
|
||||
state = value;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
);
|
||||
}
|
||||
|
||||
double get position => state.position;
|
||||
bool get mute => state.mute;
|
||||
|
||||
set position(double value) {
|
||||
state = VideoPlaybackControls(
|
||||
position: value,
|
||||
mute: state.mute,
|
||||
pause: state.pause,
|
||||
);
|
||||
}
|
||||
|
||||
set mute(bool value) {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: value,
|
||||
pause: state.pause,
|
||||
);
|
||||
}
|
||||
|
||||
void toggleMute() {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: !state.mute,
|
||||
pause: state.pause,
|
||||
);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
pause: true,
|
||||
);
|
||||
}
|
||||
|
||||
void play() {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
pause: false,
|
||||
);
|
||||
}
|
||||
|
||||
void togglePlay() {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
pause: !state.pause,
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,92 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
enum VideoPlaybackState {
|
||||
initializing,
|
||||
paused,
|
||||
playing,
|
||||
buffering,
|
||||
completed,
|
||||
}
|
||||
|
||||
class VideoPlaybackValue {
|
||||
/// The current position of the video
|
||||
final Duration position;
|
||||
|
||||
/// The total duration of the video
|
||||
final Duration duration;
|
||||
|
||||
/// The current state of the video playback
|
||||
final VideoPlaybackState state;
|
||||
|
||||
/// The volume of the video
|
||||
final double volume;
|
||||
|
||||
VideoPlaybackValue({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
required this.state,
|
||||
required this.volume,
|
||||
});
|
||||
|
||||
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
|
||||
final video = controller?.value;
|
||||
late VideoPlaybackState s;
|
||||
if (video == null) {
|
||||
s = VideoPlaybackState.initializing;
|
||||
} else if (video.isCompleted) {
|
||||
s = VideoPlaybackState.completed;
|
||||
} else if (video.isPlaying) {
|
||||
s = VideoPlaybackState.playing;
|
||||
} else if (video.isBuffering) {
|
||||
s = VideoPlaybackState.buffering;
|
||||
} else {
|
||||
s = VideoPlaybackState.paused;
|
||||
}
|
||||
|
||||
return VideoPlaybackValue(
|
||||
position: video?.position ?? Duration.zero,
|
||||
duration: video?.duration ?? Duration.zero,
|
||||
state: s,
|
||||
volume: video?.volume ?? 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
factory VideoPlaybackValue.uninitialized() {
|
||||
return VideoPlaybackValue(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
state: VideoPlaybackState.initializing,
|
||||
volume: 0.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final videoPlaybackValueProvider =
|
||||
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||
return VideoPlaybackValueState(ref);
|
||||
});
|
||||
|
||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
VideoPlaybackValueState(this.ref)
|
||||
: super(
|
||||
VideoPlaybackValue.uninitialized(),
|
||||
);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
VideoPlaybackValue get value => state;
|
||||
|
||||
set value(VideoPlaybackValue value) {
|
||||
state = value;
|
||||
}
|
||||
|
||||
set position(Duration value) {
|
||||
state = VideoPlaybackValue(
|
||||
position: value,
|
||||
duration: state.duration,
|
||||
state: state.state,
|
||||
volume: state.volume,
|
||||
);
|
||||
}
|
||||
}
|
243
mobile/lib/providers/authentication.provider.dart
Normal file
243
mobile/lib/providers/authentication.provider.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/db.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
AuthenticationNotifier(
|
||||
this._apiService,
|
||||
this._db,
|
||||
this._ref,
|
||||
) : super(
|
||||
AuthenticationState(
|
||||
deviceId: "",
|
||||
userId: "",
|
||||
userEmail: "",
|
||||
name: '',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
isAuthenticated: false,
|
||||
),
|
||||
);
|
||||
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final StateNotifierProviderRef<AuthenticationNotifier, AuthenticationState>
|
||||
_ref;
|
||||
final _log = Logger("AuthenticationNotifier");
|
||||
|
||||
Future<bool> login(
|
||||
String email,
|
||||
String password,
|
||||
String serverUrl,
|
||||
) async {
|
||||
try {
|
||||
// Resolve API server endpoint from user provided serverUrl
|
||||
await _apiService.resolveAndSetEndpoint(serverUrl);
|
||||
await _apiService.serverInfoApi.pingServer();
|
||||
} catch (e) {
|
||||
debugPrint('Invalid Server Endpoint Url $e');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sign-in request
|
||||
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
|
||||
if (Platform.isIOS) {
|
||||
var iosInfo = await deviceInfoPlugin.iosInfo;
|
||||
_apiService.authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceModel', iosInfo.utsname.machine);
|
||||
_apiService.authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceType', 'iOS');
|
||||
} else {
|
||||
var androidInfo = await deviceInfoPlugin.androidInfo;
|
||||
_apiService.authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceModel', androidInfo.model);
|
||||
_apiService.authenticationApi.apiClient
|
||||
.addDefaultHeader('deviceType', 'Android');
|
||||
}
|
||||
|
||||
try {
|
||||
var loginResponse = await _apiService.authenticationApi.login(
|
||||
LoginCredentialDto(
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
|
||||
if (loginResponse == null) {
|
||||
debugPrint('Login Response is null');
|
||||
return false;
|
||||
}
|
||||
|
||||
return setSuccessLoginInfo(
|
||||
accessToken: loginResponse.accessToken,
|
||||
serverUrl: serverUrl,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("Error logging in $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
var log = Logger('AuthenticationNotifier');
|
||||
try {
|
||||
String? userEmail = Store.tryGet(StoreKey.currentUser)?.email;
|
||||
|
||||
_apiService.authenticationApi
|
||||
.logout()
|
||||
.then((_) => log.info("Logout was successful for $userEmail"))
|
||||
.onError(
|
||||
(error, stackTrace) =>
|
||||
log.severe("Logout failed for $userEmail", error, stackTrace),
|
||||
);
|
||||
|
||||
await Future.wait([
|
||||
clearAssetsAndAlbums(_db),
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
]);
|
||||
_ref.invalidate(albumProvider);
|
||||
_ref.invalidate(sharedAlbumProvider);
|
||||
|
||||
state = state.copyWith(
|
||||
deviceId: "",
|
||||
userId: "",
|
||||
userEmail: "",
|
||||
name: '',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
isAuthenticated: false,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
log.severe('Logout failed', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
updateUserProfileImagePath(String path) {
|
||||
state = state.copyWith(profileImagePath: path);
|
||||
}
|
||||
|
||||
Future<bool> changePassword(String newPassword) async {
|
||||
try {
|
||||
await _apiService.userApi.updateUser(
|
||||
UpdateUserDto(
|
||||
id: state.userId,
|
||||
password: newPassword,
|
||||
shouldChangePassword: false,
|
||||
),
|
||||
);
|
||||
|
||||
state = state.copyWith(shouldChangePassword: false);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error changing password $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> setSuccessLoginInfo({
|
||||
required String accessToken,
|
||||
required String serverUrl,
|
||||
bool offlineLogin = false,
|
||||
}) async {
|
||||
_apiService.setAccessToken(accessToken);
|
||||
|
||||
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||
String deviceId =
|
||||
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
|
||||
|
||||
bool shouldChangePassword = false;
|
||||
User? user;
|
||||
|
||||
bool retResult = false;
|
||||
User? offlineUser = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
// If the user is offline and there is a user saved on the device,
|
||||
// if not try an online login
|
||||
if (offlineLogin && offlineUser != null) {
|
||||
user = offlineUser;
|
||||
retResult = false;
|
||||
} else {
|
||||
UserResponseDto? userResponseDto;
|
||||
try {
|
||||
userResponseDto = await _apiService.userApi.getMyUserInfo();
|
||||
} on ApiException catch (error, stackTrace) {
|
||||
_log.severe(
|
||||
"Error getting user information from the server [API EXCEPTION]",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
if (error.innerException is SocketException) {
|
||||
state = state.copyWith(isAuthenticated: true);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_log.severe(
|
||||
"Error getting user information from the server [CATCH ALL]",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
if (userResponseDto != null) {
|
||||
Store.put(StoreKey.deviceId, deviceId);
|
||||
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
|
||||
Store.put(
|
||||
StoreKey.currentUser,
|
||||
User.fromUserDto(userResponseDto),
|
||||
);
|
||||
Store.put(StoreKey.serverUrl, serverUrl);
|
||||
Store.put(StoreKey.accessToken, accessToken);
|
||||
|
||||
shouldChangePassword = userResponseDto.shouldChangePassword;
|
||||
user = User.fromUserDto(userResponseDto);
|
||||
|
||||
retResult = true;
|
||||
} else {
|
||||
_log.severe("Unable to get user information from the server.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
isAuthenticated: true,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
name: user.name,
|
||||
profileImagePath: user.profileImagePath,
|
||||
isAdmin: user.isAdmin,
|
||||
shouldChangePassword: shouldChangePassword,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
|
||||
return retResult;
|
||||
}
|
||||
}
|
||||
|
||||
final authenticationProvider =
|
||||
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
|
||||
return AuthenticationNotifier(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
703
mobile/lib/providers/backup/backup.provider.dart
Normal file
703
mobile/lib/providers/backup/backup.provider.dart
Normal file
@@ -0,0 +1,703 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/server_info.service.dart';
|
||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
BackupNotifier(
|
||||
this._backupService,
|
||||
this._serverInfoService,
|
||||
this._authState,
|
||||
this._backgroundService,
|
||||
this._galleryPermissionNotifier,
|
||||
this._db,
|
||||
this.ref,
|
||||
) : super(
|
||||
BackUpState(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
allAssetsInDatabase: const [],
|
||||
progressInPercentage: 0,
|
||||
progressInFileSize: "0 B / 0 B",
|
||||
progressInFileSpeed: 0,
|
||||
progressInFileSpeeds: const [],
|
||||
progressInFileSpeedUpdateTime: DateTime.now(),
|
||||
progressInFileSpeedUpdateSentBytes: 0,
|
||||
cancelToken: CancellationToken(),
|
||||
autoBackup: Store.get(StoreKey.autoBackup, false),
|
||||
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
|
||||
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
|
||||
backupRequireCharging:
|
||||
Store.get(StoreKey.backupRequireCharging, false),
|
||||
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
|
||||
serverInfo: const ServerDiskInfo(
|
||||
diskAvailable: "0",
|
||||
diskSize: "0",
|
||||
diskUse: "0",
|
||||
diskUsagePercentage: 0,
|
||||
),
|
||||
availableAlbums: const [],
|
||||
selectedBackupAlbums: const {},
|
||||
excludedBackupAlbums: const {},
|
||||
allUniqueAssets: const {},
|
||||
selectedAlbumsBackupAssetsIds: const {},
|
||||
currentUploadAsset: CurrentUploadAsset(
|
||||
id: '...',
|
||||
fileCreatedAt: DateTime.parse('2020-10-04'),
|
||||
fileName: '...',
|
||||
fileType: '...',
|
||||
fileSize: 0,
|
||||
iCloudAsset: false,
|
||||
),
|
||||
iCloudDownloadProgress: 0.0,
|
||||
),
|
||||
);
|
||||
|
||||
final log = Logger('BackupNotifier');
|
||||
final BackupService _backupService;
|
||||
final ServerInfoService _serverInfoService;
|
||||
final AuthenticationState _authState;
|
||||
final BackgroundService _backgroundService;
|
||||
final GalleryPermissionNotifier _galleryPermissionNotifier;
|
||||
final Isar _db;
|
||||
final Ref ref;
|
||||
|
||||
///
|
||||
/// UI INTERACTION
|
||||
///
|
||||
/// Album selection
|
||||
/// Due to the overlapping assets across multiple albums on the device
|
||||
/// We have method to include and exclude albums
|
||||
/// The total unique assets will be used for backing mechanism
|
||||
///
|
||||
void addAlbumForBackup(AvailableAlbum album) {
|
||||
if (state.excludedBackupAlbums.contains(album)) {
|
||||
removeExcludedAlbumForBackup(album);
|
||||
}
|
||||
|
||||
state = state
|
||||
.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
|
||||
}
|
||||
|
||||
void addExcludedAlbumForBackup(AvailableAlbum album) {
|
||||
if (state.selectedBackupAlbums.contains(album)) {
|
||||
removeAlbumForBackup(album);
|
||||
}
|
||||
state = state
|
||||
.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
|
||||
}
|
||||
|
||||
void removeAlbumForBackup(AvailableAlbum album) {
|
||||
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
|
||||
|
||||
currentSelectedAlbums.removeWhere((a) => a == album);
|
||||
|
||||
state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
|
||||
}
|
||||
|
||||
void removeExcludedAlbumForBackup(AvailableAlbum album) {
|
||||
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
|
||||
|
||||
currentExcludedAlbums.removeWhere((a) => a == album);
|
||||
|
||||
state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
|
||||
}
|
||||
|
||||
Future<void> backupAlbumSelectionDone() {
|
||||
if (state.selectedBackupAlbums.isEmpty) {
|
||||
// disable any backup
|
||||
cancelBackup();
|
||||
setAutoBackup(false);
|
||||
configureBackgroundBackup(
|
||||
enabled: false,
|
||||
onError: (msg) {},
|
||||
onBatteryInfo: () {},
|
||||
);
|
||||
}
|
||||
return _updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void setAutoBackup(bool enabled) {
|
||||
Store.put(StoreKey.autoBackup, enabled);
|
||||
state = state.copyWith(autoBackup: enabled);
|
||||
}
|
||||
|
||||
void configureBackgroundBackup({
|
||||
bool? enabled,
|
||||
bool? requireWifi,
|
||||
bool? requireCharging,
|
||||
int? triggerDelay,
|
||||
required void Function(String msg) onError,
|
||||
required void Function() onBatteryInfo,
|
||||
}) async {
|
||||
assert(
|
||||
enabled != null ||
|
||||
requireWifi != null ||
|
||||
requireCharging != null ||
|
||||
triggerDelay != null,
|
||||
);
|
||||
final bool wasEnabled = state.backgroundBackup;
|
||||
final bool wasWifi = state.backupRequireWifi;
|
||||
final bool wasCharging = state.backupRequireCharging;
|
||||
final int oldTriggerDelay = state.backupTriggerDelay;
|
||||
state = state.copyWith(
|
||||
backgroundBackup: enabled,
|
||||
backupRequireWifi: requireWifi,
|
||||
backupRequireCharging: requireCharging,
|
||||
backupTriggerDelay: triggerDelay,
|
||||
);
|
||||
|
||||
if (state.backgroundBackup) {
|
||||
bool success = true;
|
||||
if (!wasEnabled) {
|
||||
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
|
||||
onBatteryInfo();
|
||||
}
|
||||
success &= await _backgroundService.enableService(immediate: true);
|
||||
}
|
||||
success &= success &&
|
||||
await _backgroundService.configureService(
|
||||
requireUnmetered: state.backupRequireWifi,
|
||||
requireCharging: state.backupRequireCharging,
|
||||
triggerUpdateDelay: state.backupTriggerDelay,
|
||||
triggerMaxDelay: state.backupTriggerDelay * 10,
|
||||
);
|
||||
if (success) {
|
||||
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
|
||||
await Store.put(
|
||||
StoreKey.backupRequireCharging,
|
||||
state.backupRequireCharging,
|
||||
);
|
||||
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
|
||||
await Store.put(StoreKey.backgroundBackup, state.backgroundBackup);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
backgroundBackup: wasEnabled,
|
||||
backupRequireWifi: wasWifi,
|
||||
backupRequireCharging: wasCharging,
|
||||
backupTriggerDelay: oldTriggerDelay,
|
||||
);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
} else {
|
||||
final bool success = await _backgroundService.disableService();
|
||||
if (!success) {
|
||||
state = state.copyWith(backgroundBackup: wasEnabled);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Get all album on the device
|
||||
/// Get all selected and excluded album from the user's persistent storage
|
||||
/// If this is the first time performing backup - set the default selected album to be
|
||||
/// the one that has all assets (`Recent` on Android, `Recents` on iOS)
|
||||
///
|
||||
Future<void> _getBackupAlbumsInfo() async {
|
||||
Stopwatch stopwatch = Stopwatch()..start();
|
||||
// Get all albums on the device
|
||||
List<AvailableAlbum> availableAlbums = [];
|
||||
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
type: RequestType.common,
|
||||
);
|
||||
|
||||
// Map of id -> album for quick album lookup later on.
|
||||
Map<String, AssetPathEntity> albumMap = {};
|
||||
|
||||
log.info('Found ${albums.length} local albums');
|
||||
|
||||
for (AssetPathEntity album in albums) {
|
||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||
|
||||
availableAlbums.add(availableAlbum);
|
||||
|
||||
albumMap[album.id] = album;
|
||||
}
|
||||
state = state.copyWith(availableAlbums: availableAlbums);
|
||||
|
||||
final List<BackupAlbum> excludedBackupAlbums =
|
||||
await _backupService.excludedAlbumsQuery().findAll();
|
||||
final List<BackupAlbum> selectedBackupAlbums =
|
||||
await _backupService.selectedAlbumsQuery().findAll();
|
||||
|
||||
// Generate AssetPathEntity from id to add to local state
|
||||
final Set<AvailableAlbum> selectedAlbums = {};
|
||||
for (final BackupAlbum ba in selectedBackupAlbums) {
|
||||
final albumAsset = albumMap[ba.id];
|
||||
|
||||
if (albumAsset != null) {
|
||||
selectedAlbums.add(
|
||||
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
|
||||
);
|
||||
} else {
|
||||
log.severe('Selected album not found');
|
||||
}
|
||||
}
|
||||
|
||||
final Set<AvailableAlbum> excludedAlbums = {};
|
||||
for (final BackupAlbum ba in excludedBackupAlbums) {
|
||||
final albumAsset = albumMap[ba.id];
|
||||
|
||||
if (albumAsset != null) {
|
||||
excludedAlbums.add(
|
||||
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
|
||||
);
|
||||
} else {
|
||||
log.severe('Excluded album not found');
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
);
|
||||
|
||||
log.info(
|
||||
"_getBackupAlbumsInfo: Found ${availableAlbums.length} available albums",
|
||||
);
|
||||
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
///
|
||||
/// From all the selected and albums assets
|
||||
/// Find the assets that are not overlapping between the two sets
|
||||
/// Those assets are unique and are used as the total assets
|
||||
///
|
||||
Future<void> _updateBackupAssetCount() async {
|
||||
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
|
||||
final Set<AssetEntity> assetsFromSelectedAlbums = {};
|
||||
final Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
|
||||
for (final album in state.selectedBackupAlbums) {
|
||||
final assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromSelectedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
for (final album in state.excludedBackupAlbums) {
|
||||
final assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromExcludedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
final Set<AssetEntity> allUniqueAssets =
|
||||
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
|
||||
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
|
||||
|
||||
if (allAssetsInDatabase == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find asset that were backup from selected albums
|
||||
final Set<String> selectedAlbumsBackupAssets =
|
||||
Set.from(allUniqueAssets.map((e) => e.id));
|
||||
|
||||
selectedAlbumsBackupAssets
|
||||
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
|
||||
|
||||
// Remove duplicated asset from all unique assets
|
||||
allUniqueAssets.removeWhere(
|
||||
(asset) => duplicatedAssetIds.contains(asset.id),
|
||||
);
|
||||
|
||||
if (allUniqueAssets.isEmpty) {
|
||||
log.info("No assets are selected for back up");
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
allAssetsInDatabase: allAssetsInDatabase,
|
||||
allUniqueAssets: {},
|
||||
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
allAssetsInDatabase: allAssetsInDatabase,
|
||||
allUniqueAssets: allUniqueAssets,
|
||||
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||
);
|
||||
}
|
||||
|
||||
// Save to persistent storage
|
||||
await _updatePersistentAlbumsSelection();
|
||||
}
|
||||
|
||||
/// Get all necessary information for calculating the available albums,
|
||||
/// which albums are selected or excluded
|
||||
/// and then update the UI according to those information
|
||||
Future<void> getBackupInfo() async {
|
||||
final isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
|
||||
state = state.copyWith(backgroundBackup: isEnabled);
|
||||
if (isEnabled != Store.get(StoreKey.backgroundBackup, !isEnabled)) {
|
||||
Store.put(StoreKey.backgroundBackup, isEnabled);
|
||||
}
|
||||
|
||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||
await _getBackupAlbumsInfo();
|
||||
await updateServerInfo();
|
||||
await _updateBackupAssetCount();
|
||||
} else {
|
||||
log.warning("cannot get backup info - background backup is in progress!");
|
||||
}
|
||||
}
|
||||
|
||||
/// Save user selection of selected albums and excluded albums to database
|
||||
Future<void> _updatePersistentAlbumsSelection() {
|
||||
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
final selected = state.selectedBackupAlbums.map(
|
||||
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
|
||||
);
|
||||
final excluded = state.excludedBackupAlbums.map(
|
||||
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
|
||||
);
|
||||
final backupAlbums = selected.followedBy(excluded).toList();
|
||||
backupAlbums.sortBy((e) => e.id);
|
||||
return _db.writeTxn(() async {
|
||||
final dbAlbums = await _db.backupAlbums.where().sortById().findAll();
|
||||
final List<int> toDelete = [];
|
||||
final List<BackupAlbum> toUpsert = [];
|
||||
// stores the most recent `lastBackup` per album but always keeps the `selection` the user just made
|
||||
diffSortedListsSync(
|
||||
dbAlbums,
|
||||
backupAlbums,
|
||||
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
||||
both: (BackupAlbum a, BackupAlbum b) {
|
||||
b.lastBackup =
|
||||
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
|
||||
toUpsert.add(b);
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
|
||||
onlySecond: (BackupAlbum b) => toUpsert.add(b),
|
||||
);
|
||||
await _db.backupAlbums.deleteAll(toDelete);
|
||||
await _db.backupAlbums.putAll(toUpsert);
|
||||
});
|
||||
}
|
||||
|
||||
/// Invoke backup process
|
||||
Future<void> startBackupProcess() async {
|
||||
debugPrint("Start backup process");
|
||||
assert(state.backupProgress == BackUpProgressEnum.idle);
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||
|
||||
await getBackupInfo();
|
||||
|
||||
final hasPermission = _galleryPermissionNotifier.hasPermission;
|
||||
if (hasPermission) {
|
||||
await PhotoManager.clearFileCache();
|
||||
|
||||
if (state.allUniqueAssets.isEmpty) {
|
||||
log.info("No Asset On Device - Abort Backup Process");
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||
return;
|
||||
}
|
||||
|
||||
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
|
||||
// Remove item that has already been backed up
|
||||
for (final assetId in state.allAssetsInDatabase) {
|
||||
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
|
||||
}
|
||||
|
||||
if (assetsWillBeBackup.isEmpty) {
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||
}
|
||||
|
||||
// Perform Backup
|
||||
state = state.copyWith(cancelToken: CancellationToken());
|
||||
|
||||
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
||||
|
||||
pmProgressHandler?.stream.listen((event) {
|
||||
final double progress = event.progress;
|
||||
state = state.copyWith(iCloudDownloadProgress: progress);
|
||||
});
|
||||
|
||||
await _backupService.backupAsset(
|
||||
assetsWillBeBackup,
|
||||
state.cancelToken,
|
||||
pmProgressHandler,
|
||||
_onAssetUploaded,
|
||||
_onUploadProgress,
|
||||
_onSetCurrentBackupAsset,
|
||||
_onBackupError,
|
||||
);
|
||||
await notifyBackgroundServiceCanRun();
|
||||
} else {
|
||||
openAppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
void setAvailableAlbums(availableAlbums) {
|
||||
state = state.copyWith(
|
||||
availableAlbums: availableAlbums,
|
||||
);
|
||||
}
|
||||
|
||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
|
||||
}
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||
state = state.copyWith(currentUploadAsset: currentUploadAsset);
|
||||
}
|
||||
|
||||
void cancelBackup() {
|
||||
if (state.backupProgress != BackUpProgressEnum.inProgress) {
|
||||
notifyBackgroundServiceCanRun();
|
||||
}
|
||||
state.cancelToken.cancel();
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
progressInPercentage: 0.0,
|
||||
progressInFileSize: "0 B / 0 B",
|
||||
progressInFileSpeed: 0,
|
||||
progressInFileSpeedUpdateTime: DateTime.now(),
|
||||
progressInFileSpeedUpdateSentBytes: 0,
|
||||
);
|
||||
}
|
||||
|
||||
void _onAssetUploaded(
|
||||
String deviceAssetId,
|
||||
String deviceId,
|
||||
bool isDuplicated,
|
||||
) {
|
||||
if (isDuplicated) {
|
||||
state = state.copyWith(
|
||||
allUniqueAssets: state.allUniqueAssets
|
||||
.where((asset) => asset.id != deviceAssetId)
|
||||
.toSet(),
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
selectedAlbumsBackupAssetsIds: {
|
||||
...state.selectedAlbumsBackupAssetsIds,
|
||||
deviceAssetId,
|
||||
},
|
||||
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
|
||||
);
|
||||
}
|
||||
|
||||
if (state.allUniqueAssets.length -
|
||||
state.selectedAlbumsBackupAssetsIds.length ==
|
||||
0) {
|
||||
final latestAssetBackup =
|
||||
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
|
||||
(v, e) => e.isAfter(v) ? e : v,
|
||||
);
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: state.selectedBackupAlbums
|
||||
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
||||
.toSet(),
|
||||
excludedBackupAlbums: state.excludedBackupAlbums
|
||||
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
||||
.toSet(),
|
||||
backupProgress: BackUpProgressEnum.done,
|
||||
progressInPercentage: 0.0,
|
||||
progressInFileSize: "0 B / 0 B",
|
||||
progressInFileSpeed: 0,
|
||||
progressInFileSpeedUpdateTime: DateTime.now(),
|
||||
progressInFileSpeedUpdateSentBytes: 0,
|
||||
);
|
||||
_updatePersistentAlbumsSelection();
|
||||
}
|
||||
|
||||
updateServerInfo();
|
||||
}
|
||||
|
||||
void _onUploadProgress(int sent, int total) {
|
||||
double lastUploadSpeed = state.progressInFileSpeed;
|
||||
List<double> lastUploadSpeeds = state.progressInFileSpeeds.toList();
|
||||
DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime;
|
||||
int lastSentBytes = state.progressInFileSpeedUpdateSentBytes;
|
||||
|
||||
final now = DateTime.now();
|
||||
final duration = now.difference(lastUpdateTime);
|
||||
|
||||
// Keep the upload speed average span limited, to keep it somewhat relevant
|
||||
if (lastUploadSpeeds.length > 10) {
|
||||
lastUploadSpeeds.removeAt(0);
|
||||
}
|
||||
|
||||
if (duration.inSeconds > 0) {
|
||||
lastUploadSpeeds.add(
|
||||
((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble(),
|
||||
);
|
||||
|
||||
lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble();
|
||||
lastUpdateTime = now;
|
||||
lastSentBytes = sent;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
|
||||
progressInFileSize: humanReadableFileBytesProgress(sent, total),
|
||||
progressInFileSpeed: lastUploadSpeed,
|
||||
progressInFileSpeeds: lastUploadSpeeds,
|
||||
progressInFileSpeedUpdateTime: lastUpdateTime,
|
||||
progressInFileSpeedUpdateSentBytes: lastSentBytes,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateServerInfo() async {
|
||||
final serverInfo = await _serverInfoService.getServerInfo();
|
||||
|
||||
// Update server info
|
||||
if (serverInfo != null) {
|
||||
state = state.copyWith(
|
||||
serverInfo: serverInfo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumeBackup() async {
|
||||
// Check if user is login
|
||||
final accessKey = Store.tryGet(StoreKey.accessToken);
|
||||
|
||||
// User has been logged out return
|
||||
if (accessKey == null || !_authState.isAuthenticated) {
|
||||
log.info("[_resumeBackup] not authenticated - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this device is enable backup by the user
|
||||
if (state.autoBackup) {
|
||||
// check if backup is already in process - then return
|
||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||
log.info("[_resumeBackup] Auto Backup is already in progress - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.backupProgress == BackUpProgressEnum.inBackground) {
|
||||
log.info("[_resumeBackup] Background backup is running - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.backupProgress == BackUpProgressEnum.manualInProgress) {
|
||||
log.info("[_resumeBackup] Manual upload is running - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Run backup
|
||||
log.info("[_resumeBackup] Start back up");
|
||||
await startBackupProcess();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> resumeBackup() async {
|
||||
final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums
|
||||
.filter()
|
||||
.selectionEqualTo(BackupSelection.select)
|
||||
.findAll();
|
||||
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
|
||||
.filter()
|
||||
.selectionEqualTo(BackupSelection.exclude)
|
||||
.findAll();
|
||||
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
|
||||
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
|
||||
if (selectedAlbums.isNotEmpty) {
|
||||
selectedAlbums = _updateAlbumsBackupTime(
|
||||
selectedAlbums,
|
||||
selectedBackupAlbums,
|
||||
);
|
||||
}
|
||||
|
||||
if (excludedAlbums.isNotEmpty) {
|
||||
excludedAlbums = _updateAlbumsBackupTime(
|
||||
excludedAlbums,
|
||||
excludedBackupAlbums,
|
||||
);
|
||||
}
|
||||
final BackUpProgressEnum previous = state.backupProgress;
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.inBackground,
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
);
|
||||
// assumes the background service is currently running
|
||||
// if true, waits until it has stopped to start the backup
|
||||
final bool hasLock = await _backgroundService.acquireLock();
|
||||
if (hasLock) {
|
||||
state = state.copyWith(backupProgress: previous);
|
||||
}
|
||||
return _resumeBackup();
|
||||
}
|
||||
|
||||
Set<AvailableAlbum> _updateAlbumsBackupTime(
|
||||
Set<AvailableAlbum> albums,
|
||||
List<BackupAlbum> backupAlbums,
|
||||
) {
|
||||
Set<AvailableAlbum> result = {};
|
||||
for (BackupAlbum ba in backupAlbums) {
|
||||
try {
|
||||
AvailableAlbum a = albums.firstWhere((e) => e.id == ba.id);
|
||||
result.add(a.copyWith(lastBackup: ba.lastBackup));
|
||||
} on StateError {
|
||||
log.severe(
|
||||
"[_updateAlbumBackupTime] failed to find album in state",
|
||||
"State Error",
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> notifyBackgroundServiceCanRun() async {
|
||||
const allowedStates = [
|
||||
AppLifeCycleEnum.inactive,
|
||||
AppLifeCycleEnum.paused,
|
||||
AppLifeCycleEnum.detached,
|
||||
];
|
||||
if (allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
|
||||
_backgroundService.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
BackUpProgressEnum get backupProgress => state.backupProgress;
|
||||
void updateBackupProgress(BackUpProgressEnum backupProgress) {
|
||||
state = state.copyWith(backupProgress: backupProgress);
|
||||
}
|
||||
}
|
||||
|
||||
final backupProvider =
|
||||
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||
return BackupNotifier(
|
||||
ref.watch(backupServiceProvider),
|
||||
ref.watch(serverInfoServiceProvider),
|
||||
ref.watch(authenticationProvider),
|
||||
ref.watch(backgroundServiceProvider),
|
||||
ref.watch(galleryPermissionNotifier.notifier),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
109
mobile/lib/providers/backup/backup_verification.provider.dart
Normal file
109
mobile/lib/providers/backup/backup_verification.provider.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/services/backup_verification.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
part 'backup_verification.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class BackupVerification extends _$BackupVerification {
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
void performBackupCheck(BuildContext context) async {
|
||||
try {
|
||||
state = true;
|
||||
final backupState = ref.read(backupProvider);
|
||||
|
||||
if (backupState.allUniqueAssets.length >
|
||||
backupState.selectedAlbumsBackupAssetsIds.length) {
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Backup all assets before starting this check!",
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final connection = await Connectivity().checkConnectivity();
|
||||
if (connection != ConnectivityResult.wifi) {
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Make sure to be connected to unmetered Wi-Fi",
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
WakelockPlus.enable();
|
||||
|
||||
const limit = 100;
|
||||
final toDelete = await ref
|
||||
.read(backupVerificationServiceProvider)
|
||||
.findWronglyBackedUpAssets(limit: limit);
|
||||
if (toDelete.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Did not find any corrupt asset backups!",
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => ConfirmDialog(
|
||||
onOk: () => _performDeletion(context, toDelete),
|
||||
title: "Corrupt backups!",
|
||||
ok: "Delete",
|
||||
content:
|
||||
"Found ${toDelete.length} (max $limit at once) corrupt asset backups. "
|
||||
"Run the check again to find more.\n"
|
||||
"Do you want to delete the corrupt asset backups now?",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
WakelockPlus.disable();
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performDeletion(
|
||||
BuildContext context,
|
||||
List<Asset> assets,
|
||||
) async {
|
||||
try {
|
||||
state = true;
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Deleting ${assets.length} assets on the server...",
|
||||
);
|
||||
}
|
||||
await ref.read(assetProvider.notifier).deleteAssets(assets, force: true);
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Deleted ${assets.length} assets on the server. "
|
||||
"You can now start a manual backup",
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
}
|
27
mobile/lib/providers/backup/backup_verification.provider.g.dart
generated
Normal file
27
mobile/lib/providers/backup/backup_verification.provider.g.dart
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'backup_verification.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$backupVerificationHash() =>
|
||||
r'b691e0cc27856eef189258d3c102cc73ce4812a4';
|
||||
|
||||
/// See also [BackupVerification].
|
||||
@ProviderFor(BackupVerification)
|
||||
final backupVerificationProvider =
|
||||
AutoDisposeNotifierProvider<BackupVerification, bool>.internal(
|
||||
BackupVerification.new,
|
||||
name: r'backupVerificationProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$backupVerificationHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$BackupVerification = AutoDisposeNotifier<bool>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
23
mobile/lib/providers/backup/error_backup_list.provider.dart
Normal file
23
mobile/lib/providers/backup/error_backup_list.provider.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
|
||||
class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> {
|
||||
ErrorBackupListNotifier() : super({});
|
||||
|
||||
add(ErrorUploadAsset errorAsset) {
|
||||
state = state.union({errorAsset});
|
||||
}
|
||||
|
||||
remove(ErrorUploadAsset errorAsset) {
|
||||
state = state.difference({errorAsset});
|
||||
}
|
||||
|
||||
empty() {
|
||||
state = {};
|
||||
}
|
||||
}
|
||||
|
||||
final errorBackupListProvider =
|
||||
StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>(
|
||||
(ref) => ErrorBackupListNotifier(),
|
||||
);
|
@@ -0,0 +1,59 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
|
||||
class IOSBackgroundSettings {
|
||||
final bool appRefreshEnabled;
|
||||
final int numberOfBackgroundTasksQueued;
|
||||
final DateTime? timeOfLastFetch;
|
||||
final DateTime? timeOfLastProcessing;
|
||||
|
||||
IOSBackgroundSettings({
|
||||
required this.appRefreshEnabled,
|
||||
required this.numberOfBackgroundTasksQueued,
|
||||
this.timeOfLastFetch,
|
||||
this.timeOfLastProcessing,
|
||||
});
|
||||
}
|
||||
|
||||
class IOSBackgroundSettingsNotifier
|
||||
extends StateNotifier<IOSBackgroundSettings?> {
|
||||
final BackgroundService _service;
|
||||
IOSBackgroundSettingsNotifier(this._service) : super(null);
|
||||
|
||||
IOSBackgroundSettings? get settings => state;
|
||||
|
||||
Future<IOSBackgroundSettings> refresh() async {
|
||||
final lastFetchTime =
|
||||
await _service.getIOSBackupLastRun(IosBackgroundTask.fetch);
|
||||
final lastProcessingTime =
|
||||
await _service.getIOSBackupLastRun(IosBackgroundTask.processing);
|
||||
int numberOfProcesses = await _service.getIOSBackupNumberOfProcesses();
|
||||
final appRefreshEnabled =
|
||||
await _service.getIOSBackgroundAppRefreshEnabled();
|
||||
|
||||
// If this is enabled and there are no background processes,
|
||||
// the user just enabled app refresh in Settings.
|
||||
// But we don't have any background services running, since it was disabled
|
||||
// before.
|
||||
if (await _service.isBackgroundBackupEnabled() && numberOfProcesses == 0) {
|
||||
// We need to restart the background service
|
||||
await _service.enableService();
|
||||
numberOfProcesses = await _service.getIOSBackupNumberOfProcesses();
|
||||
}
|
||||
|
||||
final settings = IOSBackgroundSettings(
|
||||
appRefreshEnabled: appRefreshEnabled,
|
||||
numberOfBackgroundTasksQueued: numberOfProcesses,
|
||||
timeOfLastFetch: lastFetchTime,
|
||||
timeOfLastProcessing: lastProcessingTime,
|
||||
);
|
||||
|
||||
state = settings;
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
final iOSBackgroundSettingsProvider = StateNotifierProvider<
|
||||
IOSBackgroundSettingsNotifier, IOSBackgroundSettings?>(
|
||||
(ref) => IOSBackgroundSettingsNotifier(ref.watch(backgroundServiceProvider)),
|
||||
);
|
395
mobile/lib/providers/backup/manual_upload.provider.dart
Normal file
395
mobile/lib/providers/backup/manual_upload.provider.dart
Normal file
@@ -0,0 +1,395 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/models/backup/manual_upload_state.model.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/services/local_notification.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final manualUploadProvider =
|
||||
StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
|
||||
return ManualUploadNotifier(
|
||||
ref.watch(localNotificationService),
|
||||
ref.watch(backupProvider.notifier),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
||||
class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
||||
final Logger _log = Logger("ManualUploadNotifier");
|
||||
final LocalNotificationService _localNotificationService;
|
||||
final BackupNotifier _backupProvider;
|
||||
final Ref ref;
|
||||
|
||||
ManualUploadNotifier(
|
||||
this._localNotificationService,
|
||||
this._backupProvider,
|
||||
this.ref,
|
||||
) : super(
|
||||
ManualUploadState(
|
||||
progressInPercentage: 0,
|
||||
progressInFileSize: "0 B / 0 B",
|
||||
progressInFileSpeed: 0,
|
||||
progressInFileSpeeds: const [],
|
||||
progressInFileSpeedUpdateTime: DateTime.now(),
|
||||
progressInFileSpeedUpdateSentBytes: 0,
|
||||
cancelToken: CancellationToken(),
|
||||
currentUploadAsset: CurrentUploadAsset(
|
||||
id: '...',
|
||||
fileCreatedAt: DateTime.parse('2020-10-04'),
|
||||
fileName: '...',
|
||||
fileType: '...',
|
||||
),
|
||||
totalAssetsToUpload: 0,
|
||||
successfulUploads: 0,
|
||||
currentAssetIndex: 0,
|
||||
showDetailedNotification: false,
|
||||
),
|
||||
);
|
||||
|
||||
String _lastPrintedDetailContent = '';
|
||||
String? _lastPrintedDetailTitle;
|
||||
|
||||
static const notifyInterval = Duration(milliseconds: 500);
|
||||
late final ThrottleProgressUpdate _throttledNotifiy =
|
||||
ThrottleProgressUpdate(_updateProgress, notifyInterval);
|
||||
late final ThrottleProgressUpdate _throttledDetailNotify =
|
||||
ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
|
||||
|
||||
void _updateProgress(String? title, int progress, int total) {
|
||||
// Guard against throttling calling this method after the upload is done
|
||||
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
|
||||
_localNotificationService.showOrUpdateManualUploadStatus(
|
||||
"backup_background_service_in_progress_notification".tr(),
|
||||
formatAssetBackupProgress(
|
||||
state.currentAssetIndex,
|
||||
state.totalAssetsToUpload,
|
||||
),
|
||||
maxProgress: state.totalAssetsToUpload,
|
||||
progress: state.currentAssetIndex,
|
||||
showActions: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateDetailProgress(String? title, int progress, int total) {
|
||||
// Guard against throttling calling this method after the upload is done
|
||||
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
|
||||
final String msg =
|
||||
total > 0 ? humanReadableBytesProgress(progress, total) : "";
|
||||
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
|
||||
if (msg != _lastPrintedDetailContent ||
|
||||
title != _lastPrintedDetailTitle) {
|
||||
_lastPrintedDetailContent = msg;
|
||||
_lastPrintedDetailTitle = title;
|
||||
_localNotificationService.showOrUpdateManualUploadStatus(
|
||||
title ?? 'Uploading',
|
||||
msg,
|
||||
progress: total > 0 ? (progress * 1000) ~/ total : 0,
|
||||
maxProgress: 1000,
|
||||
isDetailed: true,
|
||||
// Detailed noitifcation is displayed for Single asset uploads. Show actions for such case
|
||||
showActions: state.totalAssetsToUpload == 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onAssetUploaded(
|
||||
String deviceAssetId,
|
||||
String deviceId,
|
||||
bool isDuplicated,
|
||||
) {
|
||||
state = state.copyWith(successfulUploads: state.successfulUploads + 1);
|
||||
_backupProvider.updateServerInfo();
|
||||
}
|
||||
|
||||
void _onAssetUploadError(ErrorUploadAsset errorAssetInfo) {
|
||||
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
|
||||
}
|
||||
|
||||
void _onProgress(int sent, int total) {
|
||||
double lastUploadSpeed = state.progressInFileSpeed;
|
||||
List<double> lastUploadSpeeds = state.progressInFileSpeeds.toList();
|
||||
DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime;
|
||||
int lastSentBytes = state.progressInFileSpeedUpdateSentBytes;
|
||||
|
||||
final now = DateTime.now();
|
||||
final duration = now.difference(lastUpdateTime);
|
||||
|
||||
// Keep the upload speed average span limited, to keep it somewhat relevant
|
||||
if (lastUploadSpeeds.length > 10) {
|
||||
lastUploadSpeeds.removeAt(0);
|
||||
}
|
||||
|
||||
if (duration.inSeconds > 0) {
|
||||
lastUploadSpeeds.add(
|
||||
((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble(),
|
||||
);
|
||||
|
||||
lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble();
|
||||
lastUpdateTime = now;
|
||||
lastSentBytes = sent;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
|
||||
progressInFileSize: humanReadableFileBytesProgress(sent, total),
|
||||
progressInFileSpeed: lastUploadSpeed,
|
||||
progressInFileSpeeds: lastUploadSpeeds,
|
||||
progressInFileSpeedUpdateTime: lastUpdateTime,
|
||||
progressInFileSpeedUpdateSentBytes: lastSentBytes,
|
||||
);
|
||||
|
||||
if (state.showDetailedNotification) {
|
||||
final title = "backup_background_service_current_upload_notification"
|
||||
.tr(args: [state.currentUploadAsset.fileName]);
|
||||
_throttledDetailNotify(title: title, progress: sent, total: total);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||
state = state.copyWith(
|
||||
currentUploadAsset: currentUploadAsset,
|
||||
currentAssetIndex: state.currentAssetIndex + 1,
|
||||
);
|
||||
if (state.totalAssetsToUpload > 1) {
|
||||
_throttledNotifiy();
|
||||
}
|
||||
if (state.showDetailedNotification) {
|
||||
_throttledDetailNotify.title =
|
||||
"backup_background_service_current_upload_notification"
|
||||
.tr(args: [currentUploadAsset.fileName]);
|
||||
_throttledDetailNotify.progress = 0;
|
||||
_throttledDetailNotify.total = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _startUpload(Iterable<Asset> allManualUploads) async {
|
||||
bool hasErrors = false;
|
||||
try {
|
||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
|
||||
|
||||
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
|
||||
await PhotoManager.clearFileCache();
|
||||
|
||||
// We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
|
||||
// where platform specific fields such as `subtype` used to detect platform specific assets such as
|
||||
// LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
|
||||
List<AssetEntity?> allAssetsFromDevice = await Future.wait(
|
||||
allManualUploads
|
||||
// Filter local only assets
|
||||
.where((e) => e.isLocal && !e.isRemote)
|
||||
.map((e) => e.local!.obtainForNewProperties()),
|
||||
);
|
||||
|
||||
if (allAssetsFromDevice.length != allManualUploads.length) {
|
||||
_log.warning(
|
||||
'[_startUpload] Refreshed upload list -> ${allManualUploads.length - allAssetsFromDevice.length} asset will not be uploaded',
|
||||
);
|
||||
}
|
||||
|
||||
Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
|
||||
|
||||
if (allUploadAssets.isEmpty) {
|
||||
debugPrint("[_startUpload] No Assets to upload - Abort Process");
|
||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
|
||||
return false;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
progressInPercentage: 0,
|
||||
progressInFileSize: "0 B / 0 B",
|
||||
progressInFileSpeed: 0,
|
||||
totalAssetsToUpload: allUploadAssets.length,
|
||||
successfulUploads: 0,
|
||||
currentAssetIndex: 0,
|
||||
currentUploadAsset: CurrentUploadAsset(
|
||||
id: '...',
|
||||
fileCreatedAt: DateTime.parse('2020-10-04'),
|
||||
fileName: '...',
|
||||
fileType: '...',
|
||||
),
|
||||
cancelToken: CancellationToken(),
|
||||
);
|
||||
// Reset Error List
|
||||
ref.watch(errorBackupListProvider.notifier).empty();
|
||||
|
||||
if (state.totalAssetsToUpload > 1) {
|
||||
_throttledNotifiy();
|
||||
}
|
||||
|
||||
// Show detailed asset if enabled in settings or if a single asset is uploaded
|
||||
bool showDetailedNotification =
|
||||
ref.read(appSettingsServiceProvider).getSetting<bool>(
|
||||
AppSettingsEnum.backgroundBackupSingleProgress,
|
||||
) ||
|
||||
state.totalAssetsToUpload == 1;
|
||||
state =
|
||||
state.copyWith(showDetailedNotification: showDetailedNotification);
|
||||
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
||||
|
||||
final bool ok = await ref.read(backupServiceProvider).backupAsset(
|
||||
allUploadAssets,
|
||||
state.cancelToken,
|
||||
pmProgressHandler,
|
||||
_onAssetUploaded,
|
||||
_onProgress,
|
||||
_onSetCurrentBackupAsset,
|
||||
_onAssetUploadError,
|
||||
);
|
||||
|
||||
// Close detailed notification
|
||||
await _localNotificationService.closeNotification(
|
||||
LocalNotificationService.manualUploadDetailedNotificationID,
|
||||
);
|
||||
|
||||
_log.info(
|
||||
'[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},'
|
||||
' failed: ${state.totalAssetsToUpload - state.successfulUploads}',
|
||||
);
|
||||
|
||||
// User cancelled upload
|
||||
if (!ok && state.cancelToken.isCancelled) {
|
||||
await _localNotificationService.showOrUpdateManualUploadStatus(
|
||||
"backup_manual_title".tr(),
|
||||
"backup_manual_cancelled".tr(),
|
||||
presentBanner: true,
|
||||
);
|
||||
hasErrors = true;
|
||||
} else if (state.successfulUploads == 0 ||
|
||||
(!ok && !state.cancelToken.isCancelled)) {
|
||||
await _localNotificationService.showOrUpdateManualUploadStatus(
|
||||
"backup_manual_title".tr(),
|
||||
"backup_manual_failed".tr(),
|
||||
presentBanner: true,
|
||||
);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
await _localNotificationService.showOrUpdateManualUploadStatus(
|
||||
"backup_manual_title".tr(),
|
||||
"backup_manual_success".tr(),
|
||||
presentBanner: true,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
openAppSettings();
|
||||
debugPrint("[_startUpload] Do not have permission to the gallery");
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("ERROR _startUpload: ${e.toString()}");
|
||||
hasErrors = true;
|
||||
} finally {
|
||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
|
||||
_handleAppInActivity();
|
||||
await _localNotificationService.closeNotification(
|
||||
LocalNotificationService.manualUploadDetailedNotificationID,
|
||||
);
|
||||
await _backupProvider.notifyBackgroundServiceCanRun();
|
||||
}
|
||||
return !hasErrors;
|
||||
}
|
||||
|
||||
void _handleAppInActivity() {
|
||||
final appState = ref.read(appStateProvider.notifier).getAppState();
|
||||
// The app is currently in background. Perform the necessary cleanups which
|
||||
// are on-hold for upload completion
|
||||
if (appState != AppLifeCycleEnum.active &&
|
||||
appState != AppLifeCycleEnum.resumed) {
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
}
|
||||
}
|
||||
|
||||
void cancelBackup() {
|
||||
if (_backupProvider.backupProgress != BackUpProgressEnum.inProgress &&
|
||||
_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
|
||||
_backupProvider.notifyBackgroundServiceCanRun();
|
||||
}
|
||||
state.cancelToken.cancel();
|
||||
if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
|
||||
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
|
||||
}
|
||||
state = state.copyWith(
|
||||
progressInPercentage: 0,
|
||||
progressInFileSize: "0 B / 0 B",
|
||||
progressInFileSpeed: 0,
|
||||
progressInFileSpeedUpdateTime: DateTime.now(),
|
||||
progressInFileSpeedUpdateSentBytes: 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> uploadAssets(
|
||||
BuildContext context,
|
||||
Iterable<Asset> allManualUploads,
|
||||
) async {
|
||||
// assumes the background service is currently running and
|
||||
// waits until it has stopped to start the backup.
|
||||
final bool hasLock =
|
||||
await ref.read(backgroundServiceProvider).acquireLock();
|
||||
if (!hasLock) {
|
||||
debugPrint("[uploadAssets] could not acquire lock, exiting");
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "backup_manual_failed".tr(),
|
||||
toastType: ToastType.info,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
durationInSecond: 3,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool showInProgress = false;
|
||||
|
||||
// check if backup is already in process - then return
|
||||
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
|
||||
debugPrint("[uploadAssets] Manual upload is already running - abort");
|
||||
showInProgress = true;
|
||||
}
|
||||
|
||||
if (_backupProvider.backupProgress == BackUpProgressEnum.inProgress) {
|
||||
debugPrint("[uploadAssets] Auto Backup is already in progress - abort");
|
||||
showInProgress = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_backupProvider.backupProgress == BackUpProgressEnum.inBackground) {
|
||||
debugPrint("[uploadAssets] Background backup is running - abort");
|
||||
showInProgress = true;
|
||||
}
|
||||
|
||||
if (showInProgress) {
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "backup_manual_in_progress".tr(),
|
||||
toastType: ToastType.info,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
durationInSecond: 3,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return _startUpload(allManualUploads);
|
||||
}
|
||||
}
|
5
mobile/lib/providers/db.provider.dart
Normal file
5
mobile/lib/providers/db.provider.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
// overwritten in main.dart due to async loading
|
||||
final dbProvider = Provider<Isar>((_) => throw UnimplementedError());
|
22
mobile/lib/providers/favorite_provider.dart
Normal file
22
mobile/lib/providers/favorite_provider.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
final favoriteAssetsProvider = StreamProvider<RenderList>((ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) return const Stream.empty();
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(user.isarId)
|
||||
.filter()
|
||||
.isFavoriteEqualTo(true)
|
||||
.isTrashedEqualTo(false)
|
||||
.sortByFileCreatedAtDesc();
|
||||
return renderListGenerator(query, ref);
|
||||
});
|
111
mobile/lib/providers/gallery_permission.provider.dart
Normal file
111
mobile/lib/providers/gallery_permission.provider.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
GalleryPermissionNotifier()
|
||||
: super(PermissionStatus.denied) // Denied is the intitial state
|
||||
{
|
||||
// Sets the initial state
|
||||
getGalleryPermissionStatus();
|
||||
}
|
||||
|
||||
get hasPermission => state.isGranted || state.isLimited;
|
||||
|
||||
/// Requests the gallery permission
|
||||
Future<PermissionStatus> requestGalleryPermission() async {
|
||||
PermissionStatus result;
|
||||
// Android 32 and below uses Permission.storage
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
if (androidInfo.version.sdkInt <= 32) {
|
||||
// Android 32 and below need storage
|
||||
final permission = await Permission.storage.request();
|
||||
result = permission;
|
||||
} else {
|
||||
// Android 33 need photo & video
|
||||
final photos = await Permission.photos.request();
|
||||
if (!photos.isGranted) {
|
||||
// Don't ask twice for the same permission
|
||||
state = photos;
|
||||
return photos;
|
||||
}
|
||||
final videos = await Permission.videos.request();
|
||||
|
||||
// Return the joint result of those two permissions
|
||||
final PermissionStatus status;
|
||||
if (photos.isGranted && videos.isGranted) {
|
||||
status = PermissionStatus.granted;
|
||||
} else if (photos.isDenied || videos.isDenied) {
|
||||
status = PermissionStatus.denied;
|
||||
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
|
||||
status = PermissionStatus.permanentlyDenied;
|
||||
} else {
|
||||
status = PermissionStatus.denied;
|
||||
}
|
||||
|
||||
result = status;
|
||||
}
|
||||
if (result == PermissionStatus.granted &&
|
||||
androidInfo.version.sdkInt >= 29) {
|
||||
result = await Permission.accessMediaLocation.request();
|
||||
}
|
||||
} else {
|
||||
// iOS can use photos
|
||||
final photos = await Permission.photos.request();
|
||||
result = photos;
|
||||
}
|
||||
state = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Checks the current state of the gallery permissions without
|
||||
/// requesting them again
|
||||
Future<PermissionStatus> getGalleryPermissionStatus() async {
|
||||
PermissionStatus result;
|
||||
// Android 32 and below uses Permission.storage
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
if (androidInfo.version.sdkInt <= 32) {
|
||||
// Android 32 and below need storage
|
||||
final permission = await Permission.storage.status;
|
||||
result = permission;
|
||||
} else {
|
||||
// Android 33 needs photo & video
|
||||
final photos = await Permission.photos.status;
|
||||
final videos = await Permission.videos.status;
|
||||
|
||||
// Return the joint result of those two permissions
|
||||
final PermissionStatus status;
|
||||
if (photos.isGranted && videos.isGranted) {
|
||||
status = PermissionStatus.granted;
|
||||
} else if (photos.isDenied || videos.isDenied) {
|
||||
status = PermissionStatus.denied;
|
||||
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
|
||||
status = PermissionStatus.permanentlyDenied;
|
||||
} else {
|
||||
status = PermissionStatus.denied;
|
||||
}
|
||||
|
||||
result = status;
|
||||
}
|
||||
if (state == PermissionStatus.granted &&
|
||||
androidInfo.version.sdkInt >= 29) {
|
||||
result = await Permission.accessMediaLocation.status;
|
||||
}
|
||||
} else {
|
||||
// iOS can use photos
|
||||
final photos = await Permission.photos.status;
|
||||
result = photos;
|
||||
}
|
||||
state = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
final galleryPermissionNotifier =
|
||||
StateNotifierProvider<GalleryPermissionNotifier, PermissionStatus>(
|
||||
(ref) => GalleryPermissionNotifier(),
|
||||
);
|
56
mobile/lib/providers/haptic_feedback.provider.dart
Normal file
56
mobile/lib/providers/haptic_feedback.provider.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
|
||||
final hapticFeedbackProvider =
|
||||
StateNotifierProvider<HapticNotifier, void>((ref) {
|
||||
return HapticNotifier(ref);
|
||||
});
|
||||
|
||||
class HapticNotifier extends StateNotifier<void> {
|
||||
void build() {}
|
||||
final Ref _ref;
|
||||
|
||||
HapticNotifier(this._ref) : super(null);
|
||||
|
||||
selectionClick() {
|
||||
if (_ref
|
||||
.read(appSettingsServiceProvider)
|
||||
.getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
}
|
||||
|
||||
lightImpact() {
|
||||
if (_ref
|
||||
.read(appSettingsServiceProvider)
|
||||
.getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
|
||||
mediumImpact() {
|
||||
if (_ref
|
||||
.read(appSettingsServiceProvider)
|
||||
.getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
}
|
||||
|
||||
heavyImpact() {
|
||||
if (_ref
|
||||
.read(appSettingsServiceProvider)
|
||||
.getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
}
|
||||
|
||||
vibrate() {
|
||||
if (_ref
|
||||
.read(appSettingsServiceProvider)
|
||||
.getSetting(AppSettingsEnum.enableHapticFeedback)) {
|
||||
HapticFeedback.vibrate();
|
||||
}
|
||||
}
|
||||
}
|
50
mobile/lib/providers/image/cache/image_loader.dart
vendored
Normal file
50
mobile/lib/providers/image/cache/image_loader.dart
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
||||
/// Loads the codec from the URI and sends the events to the [chunkEvents] stream
|
||||
///
|
||||
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
|
||||
/// for this wonderful implementation of their image loader
|
||||
class ImageLoader {
|
||||
static Future<ui.Codec> loadImageFromCache(
|
||||
String uri, {
|
||||
required CacheManager cache,
|
||||
required ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent>? chunkEvents,
|
||||
}) async {
|
||||
final headers = {
|
||||
'x-immich-user-token': Store.get(StoreKey.accessToken),
|
||||
};
|
||||
|
||||
final stream = cache.getFileStream(
|
||||
uri,
|
||||
withProgress: chunkEvents != null,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
await for (final result in stream) {
|
||||
if (result is DownloadProgress) {
|
||||
// We are downloading the file, so update the [chunkEvents]
|
||||
chunkEvents?.add(
|
||||
ImageChunkEvent(
|
||||
cumulativeBytesLoaded: result.downloaded,
|
||||
expectedTotalBytes: result.totalSize,
|
||||
),
|
||||
);
|
||||
} else if (result is FileInfo) {
|
||||
// We have the file
|
||||
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
|
||||
final decoded = await decode(buffer);
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, the image failed to load from the cache stream
|
||||
throw ImageLoadingException('Could not load image from stream');
|
||||
}
|
||||
}
|
20
mobile/lib/providers/image/cache/remote_image_cache_manager.dart
vendored
Normal file
20
mobile/lib/providers/image/cache/remote_image_cache_manager.dart
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
|
||||
/// The cache manager for full size images [ImmichRemoteImageProvider]
|
||||
class RemoteImageCacheManager extends CacheManager {
|
||||
static const key = 'remoteImageCacheKey';
|
||||
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
|
||||
|
||||
factory RemoteImageCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
RemoteImageCacheManager._()
|
||||
: super(
|
||||
Config(
|
||||
key,
|
||||
maxNrOfCacheObjects: 500,
|
||||
stalePeriod: const Duration(days: 30),
|
||||
),
|
||||
);
|
||||
}
|
21
mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart
vendored
Normal file
21
mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
|
||||
/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider]
|
||||
class ThumbnailImageCacheManager extends CacheManager {
|
||||
static const key = 'thumbnailImageCacheKey';
|
||||
static final ThumbnailImageCacheManager _instance =
|
||||
ThumbnailImageCacheManager._();
|
||||
|
||||
factory ThumbnailImageCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
ThumbnailImageCacheManager._()
|
||||
: super(
|
||||
Config(
|
||||
key,
|
||||
maxNrOfCacheObjects: 5000,
|
||||
stalePeriod: const Duration(days: 30),
|
||||
),
|
||||
);
|
||||
}
|
@@ -0,0 +1,5 @@
|
||||
/// An exception for the [ImageLoader] and the Immich image providers
|
||||
class ImageLoadingException implements Exception {
|
||||
final String message;
|
||||
ImageLoadingException(this.message);
|
||||
}
|
106
mobile/lib/providers/image/immich_local_image_provider.dart
Normal file
106
mobile/lib/providers/image/immich_local_image_provider.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
/// The local image provider for an asset
|
||||
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
||||
final Asset asset;
|
||||
|
||||
ImmichLocalImageProvider({
|
||||
required this.asset,
|
||||
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImmichLocalImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ImmichLocalImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key.asset, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(asset.fileName);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
Asset key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a small thumbnail
|
||||
final thumbBytes = await asset.local?.thumbnailDataWithSize(
|
||||
const ThumbnailSize.square(256),
|
||||
quality: 80,
|
||||
);
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("Loading thumb for ${asset.fileName} failed");
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
/// Using 2K thumbnail for local iOS image to avoid double swiping issue
|
||||
if (Platform.isIOS) {
|
||||
final largeImageBytes = await asset.local
|
||||
?.thumbnailDataWithSize(const ThumbnailSize(3840, 2160));
|
||||
if (largeImageBytes == null) {
|
||||
throw StateError(
|
||||
"Loading thumb for local photo ${asset.fileName} failed",
|
||||
);
|
||||
}
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
// Use the original file for Android
|
||||
final File? file = await asset.local?.originFile;
|
||||
if (file == null) {
|
||||
throw StateError("Opening file for asset ${asset.fileName} failed");
|
||||
}
|
||||
try {
|
||||
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} catch (error) {
|
||||
throw StateError("Loading asset ${asset.fileName} failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunkEvents.close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImmichLocalImageProvider) {
|
||||
return asset == other.asset;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.hashCode;
|
||||
}
|
@@ -0,0 +1,92 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
/// The local image provider for an asset
|
||||
/// Only viable
|
||||
class ImmichLocalThumbnailProvider
|
||||
extends ImageProvider<ImmichLocalThumbnailProvider> {
|
||||
final Asset asset;
|
||||
final int height;
|
||||
final int width;
|
||||
|
||||
ImmichLocalThumbnailProvider({
|
||||
required this.asset,
|
||||
this.height = 256,
|
||||
this.width = 256,
|
||||
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImmichLocalThumbnailProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ImmichLocalThumbnailProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key.asset, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(asset.fileName);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
Asset key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a small thumbnail
|
||||
final thumbBytes = await asset.local?.thumbnailDataWithSize(
|
||||
const ThumbnailSize.square(32),
|
||||
quality: 75,
|
||||
);
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("Loading thumb for ${asset.fileName} failed");
|
||||
}
|
||||
|
||||
final normalThumbBytes =
|
||||
await asset.local?.thumbnailDataWithSize(ThumbnailSize(width, height));
|
||||
if (normalThumbBytes == null) {
|
||||
throw StateError(
|
||||
"Loading thumb for local photo ${asset.fileName} failed",
|
||||
);
|
||||
}
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
|
||||
chunkEvents.close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ImmichLocalThumbnailProvider) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return asset == other.asset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.hashCode;
|
||||
}
|
128
mobile/lib/providers/image/immich_remote_image_provider.dart
Normal file
128
mobile/lib/providers/image/immich_remote_image_provider.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||
import 'package:openapi/api.dart' as api;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
/// The remote image provider for full size remote images
|
||||
class ImmichRemoteImageProvider
|
||||
extends ImageProvider<ImmichRemoteImageProvider> {
|
||||
/// The [Asset.remoteId] of the asset to fetch
|
||||
final String assetId;
|
||||
|
||||
/// The image cache manager
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
ImmichRemoteImageProvider({
|
||||
required this.assetId,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImmichRemoteImageProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ImmichRemoteImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? RemoteImageCacheManager();
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether to show the original file or load a compressed version
|
||||
bool get _useOriginal => Store.get(
|
||||
AppSettingsEnum.loadOriginal.storeKey,
|
||||
AppSettingsEnum.loadOriginal.defaultValue,
|
||||
);
|
||||
|
||||
/// Whether to load the preview thumbnail first or not
|
||||
bool get _loadPreview => Store.get(
|
||||
AppSettingsEnum.loadPreview.storeKey,
|
||||
AppSettingsEnum.loadPreview.defaultValue,
|
||||
);
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
ImmichRemoteImageProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a preview to the chunk events
|
||||
if (_loadPreview) {
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
);
|
||||
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkEvents,
|
||||
);
|
||||
}
|
||||
|
||||
// Load the higher resolution version of the image
|
||||
final url = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
final codec = await ImageLoader.loadImageFromCache(
|
||||
url,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkEvents,
|
||||
);
|
||||
yield codec;
|
||||
|
||||
// Load the final remote image
|
||||
if (_useOriginal) {
|
||||
// Load the original image
|
||||
final url = getImageUrlFromId(key.assetId);
|
||||
final codec = await ImageLoader.loadImageFromCache(
|
||||
url,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkEvents,
|
||||
);
|
||||
yield codec;
|
||||
}
|
||||
await chunkEvents.close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImmichRemoteImageProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
@@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
||||
import 'package:openapi/api.dart' as api;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
/// The remote image provider
|
||||
class ImmichRemoteThumbnailProvider
|
||||
extends ImageProvider<ImmichRemoteThumbnailProvider> {
|
||||
/// The [Asset.remoteId] of the asset to fetch
|
||||
final String assetId;
|
||||
|
||||
final int? height;
|
||||
final int? width;
|
||||
|
||||
/// The image cache manager
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
ImmichRemoteThumbnailProvider({
|
||||
required this.assetId,
|
||||
this.height,
|
||||
this.width,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImmichRemoteThumbnailProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ImmichRemoteThumbnailProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode),
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
ImmichRemoteThumbnailProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
) async* {
|
||||
// Load a preview to the chunk events
|
||||
final preview = getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
);
|
||||
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImmichRemoteThumbnailProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
13
mobile/lib/providers/immich_logo_provider.dart
Normal file
13
mobile/lib/providers/immich_logo_provider.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'immich_logo_provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<Uint8List> immichLogo(ImmichLogoRef ref) async {
|
||||
final json = await rootBundle.loadString('assets/immich-logo.json');
|
||||
final j = jsonDecode(json);
|
||||
return base64Decode(j['content']);
|
||||
}
|
24
mobile/lib/providers/immich_logo_provider.g.dart
generated
Normal file
24
mobile/lib/providers/immich_logo_provider.g.dart
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'immich_logo_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$immichLogoHash() => r'040cc44fae3339e0f40a091fb3b2f2abe9f83acd';
|
||||
|
||||
/// See also [immichLogo].
|
||||
@ProviderFor(immichLogo)
|
||||
final immichLogoProvider = AutoDisposeFutureProvider<Uint8List>.internal(
|
||||
immichLogo,
|
||||
name: r'immichLogoProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$immichLogoHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef ImmichLogoRef = AutoDisposeFutureProviderRef<Uint8List>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
42
mobile/lib/providers/map/map_marker.provider.dart
Normal file
42
mobile/lib/providers/map/map_marker.provider.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
||||
import 'package:immich_mobile/providers/map/map_service.provider.dart';
|
||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'map_marker.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<MapMarker>> mapMarkers(MapMarkersRef ref) async {
|
||||
final service = ref.read(mapServiceProvider);
|
||||
final mapState = ref.read(mapStateNotifierProvider);
|
||||
DateTime? fileCreatedAfter;
|
||||
bool? isFavorite;
|
||||
bool? isIncludeArchived;
|
||||
bool? isWithPartners;
|
||||
|
||||
if (mapState.relativeTime != 0) {
|
||||
fileCreatedAfter =
|
||||
DateTime.now().subtract(Duration(days: mapState.relativeTime));
|
||||
}
|
||||
|
||||
if (mapState.showFavoriteOnly) {
|
||||
isFavorite = true;
|
||||
}
|
||||
|
||||
if (!mapState.includeArchived) {
|
||||
isIncludeArchived = false;
|
||||
}
|
||||
|
||||
if (mapState.withPartners) {
|
||||
isWithPartners = true;
|
||||
}
|
||||
|
||||
final markers = await service.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
withArchived: isIncludeArchived,
|
||||
withPartners: isWithPartners,
|
||||
fileCreatedAfter: fileCreatedAfter,
|
||||
);
|
||||
|
||||
return markers.toList();
|
||||
}
|
24
mobile/lib/providers/map/map_marker.provider.g.dart
generated
Normal file
24
mobile/lib/providers/map/map_marker.provider.g.dart
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'map_marker.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mapMarkersHash() => r'737d52f3d02e6a458b11d730f2fe522c39ee1ebf';
|
||||
|
||||
/// See also [mapMarkers].
|
||||
@ProviderFor(mapMarkers)
|
||||
final mapMarkersProvider = AutoDisposeFutureProvider<List<MapMarker>>.internal(
|
||||
mapMarkers,
|
||||
name: r'mapMarkersProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$mapMarkersHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef MapMarkersRef = AutoDisposeFutureProviderRef<List<MapMarker>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
9
mobile/lib/providers/map/map_service.provider.dart
Normal file
9
mobile/lib/providers/map/map_service.provider.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'package:immich_mobile/services/map.service.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'map_service.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
MapSerivce mapService(MapServiceRef ref) =>
|
||||
MapSerivce(ref.watch(apiServiceProvider));
|
24
mobile/lib/providers/map/map_service.provider.g.dart
generated
Normal file
24
mobile/lib/providers/map/map_service.provider.g.dart
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'map_service.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mapServiceHash() => r'2f68c07ac6cd5c74ec8be3bd2df91f4db673b79e';
|
||||
|
||||
/// See also [mapService].
|
||||
@ProviderFor(mapService)
|
||||
final mapServiceProvider = AutoDisposeProvider<MapSerivce>.internal(
|
||||
mapService,
|
||||
name: r'mapServiceProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$mapServiceHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef MapServiceRef = AutoDisposeProviderRef<MapSerivce>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
151
mobile/lib/providers/map/map_state.provider.dart
Normal file
151
mobile/lib/providers/map/map_state.provider.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/models/map/map_state.model.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'map_state.provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class MapStateNotifier extends _$MapStateNotifier {
|
||||
final _log = Logger("MapStateNotifier");
|
||||
|
||||
@override
|
||||
MapState build() {
|
||||
final appSettingsProvider = ref.read(appSettingsServiceProvider);
|
||||
|
||||
// Fetch and save the Style JSONs
|
||||
loadStyles();
|
||||
return MapState(
|
||||
themeMode: ThemeMode.values[
|
||||
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)],
|
||||
showFavoriteOnly: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
|
||||
includeArchived: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
|
||||
withPartners:
|
||||
appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapwithPartners),
|
||||
relativeTime:
|
||||
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate),
|
||||
);
|
||||
}
|
||||
|
||||
void loadStyles() async {
|
||||
final documents = (await getApplicationDocumentsDirectory()).path;
|
||||
|
||||
// Set to loading
|
||||
state = state.copyWith(lightStyleFetched: const AsyncLoading());
|
||||
|
||||
// Fetch and save light theme
|
||||
final lightResponse = await ref
|
||||
.read(apiServiceProvider)
|
||||
.systemConfigApi
|
||||
.getMapStyleWithHttpInfo(MapTheme.light);
|
||||
|
||||
if (lightResponse.statusCode >= HttpStatus.badRequest) {
|
||||
state = state.copyWith(
|
||||
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
|
||||
);
|
||||
_log.severe(
|
||||
"Cannot fetch map light style",
|
||||
lightResponse.toLoggerString(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final lightJSON = lightResponse.body;
|
||||
final lightFile = await File("$documents/map-style-light.json")
|
||||
.writeAsString(lightJSON, flush: true);
|
||||
|
||||
// Update state with path
|
||||
state =
|
||||
state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path));
|
||||
|
||||
// Set to loading
|
||||
state = state.copyWith(darkStyleFetched: const AsyncLoading());
|
||||
|
||||
// Fetch and save dark theme
|
||||
final darkResponse = await ref
|
||||
.read(apiServiceProvider)
|
||||
.systemConfigApi
|
||||
.getMapStyleWithHttpInfo(MapTheme.dark);
|
||||
|
||||
if (darkResponse.statusCode >= HttpStatus.badRequest) {
|
||||
state = state.copyWith(
|
||||
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
|
||||
);
|
||||
_log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
|
||||
return;
|
||||
}
|
||||
|
||||
final darkJSON = darkResponse.body;
|
||||
final darkFile = await File("$documents/map-style-dark.json")
|
||||
.writeAsString(darkJSON, flush: true);
|
||||
|
||||
// Update state with path
|
||||
state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path));
|
||||
}
|
||||
|
||||
void switchTheme(ThemeMode mode) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.mapThemeMode,
|
||||
mode.index,
|
||||
);
|
||||
state = state.copyWith(themeMode: mode);
|
||||
}
|
||||
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.mapShowFavoriteOnly,
|
||||
isFavoriteOnly,
|
||||
);
|
||||
state = state.copyWith(
|
||||
showFavoriteOnly: isFavoriteOnly,
|
||||
shouldRefetchMarkers: true,
|
||||
);
|
||||
}
|
||||
|
||||
void setRefetchMarkers(bool shouldRefetch) {
|
||||
state = state.copyWith(shouldRefetchMarkers: shouldRefetch);
|
||||
}
|
||||
|
||||
void switchIncludeArchived(bool isIncludeArchived) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.mapIncludeArchived,
|
||||
isIncludeArchived,
|
||||
);
|
||||
state = state.copyWith(
|
||||
includeArchived: isIncludeArchived,
|
||||
shouldRefetchMarkers: true,
|
||||
);
|
||||
}
|
||||
|
||||
void switchWithPartners(bool isWithPartners) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.mapwithPartners,
|
||||
isWithPartners,
|
||||
);
|
||||
state = state.copyWith(
|
||||
withPartners: isWithPartners,
|
||||
shouldRefetchMarkers: true,
|
||||
);
|
||||
}
|
||||
|
||||
void setRelativeTime(int relativeTime) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.mapRelativeDate,
|
||||
relativeTime,
|
||||
);
|
||||
state = state.copyWith(
|
||||
relativeTime: relativeTime,
|
||||
shouldRefetchMarkers: true,
|
||||
);
|
||||
}
|
||||
}
|
26
mobile/lib/providers/map/map_state.provider.g.dart
generated
Normal file
26
mobile/lib/providers/map/map_state.provider.g.dart
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'map_state.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$mapStateNotifierHash() => r'87a8623f726d438d115d5a15609c71372726ee2f';
|
||||
|
||||
/// See also [MapStateNotifier].
|
||||
@ProviderFor(MapStateNotifier)
|
||||
final mapStateNotifierProvider =
|
||||
NotifierProvider<MapStateNotifier, MapState>.internal(
|
||||
MapStateNotifier.new,
|
||||
name: r'mapStateNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$mapStateNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$MapStateNotifier = Notifier<MapState>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
10
mobile/lib/providers/memory.provider.dart
Normal file
10
mobile/lib/providers/memory.provider.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||
import 'package:immich_mobile/services/memory.service.dart';
|
||||
|
||||
final memoryFutureProvider =
|
||||
FutureProvider.autoDispose<List<Memory>?>((ref) async {
|
||||
final service = ref.watch(memoryServiceProvider);
|
||||
|
||||
return await service.getMemoryLane();
|
||||
});
|
5
mobile/lib/providers/multiselect.provider.dart
Normal file
5
mobile/lib/providers/multiselect.provider.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final multiselectProvider = StateProvider((ref) {
|
||||
return false;
|
||||
});
|
46
mobile/lib/providers/notification_permission.provider.dart
Normal file
46
mobile/lib/providers/notification_permission.provider.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
|
||||
NotificationPermissionNotifier()
|
||||
: super(
|
||||
Platform.isAndroid
|
||||
? PermissionStatus.granted
|
||||
: PermissionStatus.restricted,
|
||||
) {
|
||||
// Sets the initial state
|
||||
getNotificationPermission().then((p) => state = p);
|
||||
}
|
||||
|
||||
/// Requests the notification permission
|
||||
/// Note: In Android, this is always granted
|
||||
Future<PermissionStatus> requestNotificationPermission() async {
|
||||
final permission = await Permission.notification.request();
|
||||
state = permission;
|
||||
return permission;
|
||||
}
|
||||
|
||||
/// Whether the user has the permission or not
|
||||
/// Note: In Android, this is always true
|
||||
Future<bool> hasNotificationPermission() {
|
||||
return Permission.notification.isGranted;
|
||||
}
|
||||
|
||||
Future<PermissionStatus> getNotificationPermission() async {
|
||||
final status = await Permission.notification.status;
|
||||
state = status;
|
||||
return status;
|
||||
}
|
||||
|
||||
/// Either the permission was granted already or else ask for the permission
|
||||
Future<bool> hasOrAskForNotificationPermission() {
|
||||
return requestNotificationPermission().then((p) => p.isGranted);
|
||||
}
|
||||
}
|
||||
|
||||
final notificationPermissionProvider =
|
||||
StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>(
|
||||
(ref) => NotificationPermissionNotifier(),
|
||||
);
|
6
mobile/lib/providers/oauth.provider.dart
Normal file
6
mobile/lib/providers/oauth.provider.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/services/oauth.service.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
|
||||
final oAuthServiceProvider =
|
||||
Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));
|
60
mobile/lib/providers/partner.provider.dart
Normal file
60
mobile/lib/providers/partner.provider.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/services/partner.service.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
|
||||
PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
|
||||
final query = db.users.filter().isPartnerSharedWithEqualTo(true);
|
||||
query.findAll().then((partners) => state = partners);
|
||||
query.watch().listen((partners) => state = partners);
|
||||
}
|
||||
|
||||
Future<bool> updatePartner(User partner, {required bool inTimeline}) {
|
||||
return _ps.updatePartner(partner, inTimeline: inTimeline);
|
||||
}
|
||||
|
||||
final PartnerService _ps;
|
||||
}
|
||||
|
||||
final partnerSharedWithProvider =
|
||||
StateNotifierProvider<PartnerSharedWithNotifier, List<User>>((ref) {
|
||||
return PartnerSharedWithNotifier(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(partnerServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class PartnerSharedByNotifier extends StateNotifier<List<User>> {
|
||||
PartnerSharedByNotifier(Isar db) : super([]) {
|
||||
final query = db.users.filter().isPartnerSharedByEqualTo(true);
|
||||
query.findAll().then((partners) => state = partners);
|
||||
streamSub = query.watch().listen((partners) => state = partners);
|
||||
}
|
||||
|
||||
late final StreamSubscription<List<User>> streamSub;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final partnerSharedByProvider =
|
||||
StateNotifierProvider<PartnerSharedByNotifier, List<User>>((ref) {
|
||||
return PartnerSharedByNotifier(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final partnerAvailableProvider =
|
||||
FutureProvider.autoDispose<List<User>>((ref) async {
|
||||
final otherUsers = await ref.watch(otherUsersProvider.future);
|
||||
final currentPartners = ref.watch(partnerSharedByProvider);
|
||||
final available = Set<User>.of(otherUsers);
|
||||
available.removeAll(currentPartners);
|
||||
return available.toList();
|
||||
});
|
13
mobile/lib/providers/search/all_motion_photos.provider.dart
Normal file
13
mobile/lib/providers/search/all_motion_photos.provider.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
final allMotionPhotosProvider = FutureProvider<List<Asset>>((ref) async {
|
||||
return ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.livePhotoVideoIdIsNotNull()
|
||||
.findAll();
|
||||
});
|
17
mobile/lib/providers/search/all_video_assets.provider.dart
Normal file
17
mobile/lib/providers/search/all_video_assets.provider.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||
|
||||
final allVideoAssetsProvider = StreamProvider<RenderList>((ref) {
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.isArchivedEqualTo(false)
|
||||
.isTrashedEqualTo(false)
|
||||
.typeEqualTo(AssetType.video)
|
||||
.sortByFileCreatedAtDesc();
|
||||
return renderListGenerator(query, ref);
|
||||
});
|
62
mobile/lib/providers/search/paginated_search.provider.dart
Normal file
62
mobile/lib/providers/search/paginated_search.provider.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:immich_mobile/providers/asset_viewer/render_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/services/search.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'paginated_search.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
class PaginatedSearch extends _$PaginatedSearch {
|
||||
Future<List<Asset>?> _search(SearchFilter filter, int page) async {
|
||||
final service = ref.read(searchServiceProvider);
|
||||
final result = await service.search(filter, page);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Asset>> build() async {
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<Asset>> getNextPage(SearchFilter filter, int nextPage) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
final newState = await AsyncValue.guard(() async {
|
||||
final assets = await _search(filter, nextPage);
|
||||
|
||||
if (assets != null) {
|
||||
return [...?state.value, ...assets];
|
||||
}
|
||||
});
|
||||
|
||||
state = newState.valueOrNull == null
|
||||
? const AsyncValue.data([])
|
||||
: AsyncValue.data(newState.value!);
|
||||
|
||||
return newState.valueOrNull ?? [];
|
||||
}
|
||||
|
||||
clear() {
|
||||
state = const AsyncValue.data([]);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
AsyncValue<RenderList> paginatedSearchRenderList(
|
||||
PaginatedSearchRenderListRef ref,
|
||||
) {
|
||||
final assets = ref.watch(paginatedSearchProvider).value;
|
||||
|
||||
if (assets != null) {
|
||||
return ref.watch(
|
||||
renderListProviderWithGrouping(
|
||||
(assets, GroupAssetsBy.none),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const AsyncValue.loading();
|
||||
}
|
||||
}
|
44
mobile/lib/providers/search/paginated_search.provider.g.dart
generated
Normal file
44
mobile/lib/providers/search/paginated_search.provider.g.dart
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'paginated_search.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$paginatedSearchRenderListHash() =>
|
||||
r'c2cc2381ee6ea8f8e08d6d4c1289bbf0c6b9647e';
|
||||
|
||||
/// See also [paginatedSearchRenderList].
|
||||
@ProviderFor(paginatedSearchRenderList)
|
||||
final paginatedSearchRenderListProvider =
|
||||
AutoDisposeProvider<AsyncValue<RenderList>>.internal(
|
||||
paginatedSearchRenderList,
|
||||
name: r'paginatedSearchRenderListProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$paginatedSearchRenderListHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef PaginatedSearchRenderListRef
|
||||
= AutoDisposeProviderRef<AsyncValue<RenderList>>;
|
||||
String _$paginatedSearchHash() => r'8312f358261368cf2b5572b839fdd8f8fbe9a62e';
|
||||
|
||||
/// See also [PaginatedSearch].
|
||||
@ProviderFor(PaginatedSearch)
|
||||
final paginatedSearchProvider =
|
||||
AutoDisposeAsyncNotifierProvider<PaginatedSearch, List<Asset>>.internal(
|
||||
PaginatedSearch.new,
|
||||
name: r'paginatedSearchProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$paginatedSearchHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$PaginatedSearch = AutoDisposeAsyncNotifier<List<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
|
49
mobile/lib/providers/search/people.provider.dart
Normal file
49
mobile/lib/providers/search/people.provider.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/services/person.service.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'people.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<PersonResponseDto>> getAllPeople(
|
||||
GetAllPeopleRef ref,
|
||||
) async {
|
||||
final PersonService personService = ref.read(personServiceProvider);
|
||||
|
||||
final people = await personService.getAllPeople();
|
||||
|
||||
return people;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<RenderList> personAssets(PersonAssetsRef ref, String personId) async {
|
||||
final PersonService personService = ref.read(personServiceProvider);
|
||||
final assets = await personService.getPersonAssets(personId);
|
||||
if (assets == null) {
|
||||
return RenderList.empty();
|
||||
}
|
||||
|
||||
final settings = ref.read(appSettingsServiceProvider);
|
||||
final groupBy =
|
||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
|
||||
return await RenderList.fromAssets(assets, groupBy);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<bool> updatePersonName(
|
||||
UpdatePersonNameRef ref,
|
||||
String personId,
|
||||
String updatedName,
|
||||
) async {
|
||||
final PersonService personService = ref.read(personServiceProvider);
|
||||
final person = await personService.updateName(personId, updatedName);
|
||||
|
||||
if (person != null && person.name == updatedName) {
|
||||
ref.invalidate(getAllPeopleProvider);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
318
mobile/lib/providers/search/people.provider.g.dart
generated
Normal file
318
mobile/lib/providers/search/people.provider.g.dart
generated
Normal file
@@ -0,0 +1,318 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'people.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$getAllPeopleHash() => r'4eff6666be5a74710d1e8587e01d8154310d85bd';
|
||||
|
||||
/// See also [getAllPeople].
|
||||
@ProviderFor(getAllPeople)
|
||||
final getAllPeopleProvider =
|
||||
AutoDisposeFutureProvider<List<PersonResponseDto>>.internal(
|
||||
getAllPeople,
|
||||
name: r'getAllPeopleProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$getAllPeopleHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef GetAllPeopleRef = AutoDisposeFutureProviderRef<List<PersonResponseDto>>;
|
||||
String _$personAssetsHash() => r'1d6eff5ca3aa630b58c4dad9516193b21896984d';
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// See also [personAssets].
|
||||
@ProviderFor(personAssets)
|
||||
const personAssetsProvider = PersonAssetsFamily();
|
||||
|
||||
/// See also [personAssets].
|
||||
class PersonAssetsFamily extends Family<AsyncValue<RenderList>> {
|
||||
/// See also [personAssets].
|
||||
const PersonAssetsFamily();
|
||||
|
||||
/// See also [personAssets].
|
||||
PersonAssetsProvider call(
|
||||
String personId,
|
||||
) {
|
||||
return PersonAssetsProvider(
|
||||
personId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
PersonAssetsProvider getProviderOverride(
|
||||
covariant PersonAssetsProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.personId,
|
||||
);
|
||||
}
|
||||
|
||||
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'personAssetsProvider';
|
||||
}
|
||||
|
||||
/// See also [personAssets].
|
||||
class PersonAssetsProvider extends AutoDisposeFutureProvider<RenderList> {
|
||||
/// See also [personAssets].
|
||||
PersonAssetsProvider(
|
||||
String personId,
|
||||
) : this._internal(
|
||||
(ref) => personAssets(
|
||||
ref as PersonAssetsRef,
|
||||
personId,
|
||||
),
|
||||
from: personAssetsProvider,
|
||||
name: r'personAssetsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$personAssetsHash,
|
||||
dependencies: PersonAssetsFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
PersonAssetsFamily._allTransitiveDependencies,
|
||||
personId: personId,
|
||||
);
|
||||
|
||||
PersonAssetsProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.personId,
|
||||
}) : super.internal();
|
||||
|
||||
final String personId;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<RenderList> Function(PersonAssetsRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: PersonAssetsProvider._internal(
|
||||
(ref) => create(ref as PersonAssetsRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
personId: personId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<RenderList> createElement() {
|
||||
return _PersonAssetsProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is PersonAssetsProvider && other.personId == personId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, personId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
mixin PersonAssetsRef on AutoDisposeFutureProviderRef<RenderList> {
|
||||
/// The parameter `personId` of this provider.
|
||||
String get personId;
|
||||
}
|
||||
|
||||
class _PersonAssetsProviderElement
|
||||
extends AutoDisposeFutureProviderElement<RenderList> with PersonAssetsRef {
|
||||
_PersonAssetsProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get personId => (origin as PersonAssetsProvider).personId;
|
||||
}
|
||||
|
||||
String _$updatePersonNameHash() => r'7145aaaf6fc38fdafe3a283ebf3d3f4fd0774cd2';
|
||||
|
||||
/// See also [updatePersonName].
|
||||
@ProviderFor(updatePersonName)
|
||||
const updatePersonNameProvider = UpdatePersonNameFamily();
|
||||
|
||||
/// See also [updatePersonName].
|
||||
class UpdatePersonNameFamily extends Family<AsyncValue<bool>> {
|
||||
/// See also [updatePersonName].
|
||||
const UpdatePersonNameFamily();
|
||||
|
||||
/// See also [updatePersonName].
|
||||
UpdatePersonNameProvider call(
|
||||
String personId,
|
||||
String updatedName,
|
||||
) {
|
||||
return UpdatePersonNameProvider(
|
||||
personId,
|
||||
updatedName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
UpdatePersonNameProvider getProviderOverride(
|
||||
covariant UpdatePersonNameProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.personId,
|
||||
provider.updatedName,
|
||||
);
|
||||
}
|
||||
|
||||
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'updatePersonNameProvider';
|
||||
}
|
||||
|
||||
/// See also [updatePersonName].
|
||||
class UpdatePersonNameProvider extends AutoDisposeFutureProvider<bool> {
|
||||
/// See also [updatePersonName].
|
||||
UpdatePersonNameProvider(
|
||||
String personId,
|
||||
String updatedName,
|
||||
) : this._internal(
|
||||
(ref) => updatePersonName(
|
||||
ref as UpdatePersonNameRef,
|
||||
personId,
|
||||
updatedName,
|
||||
),
|
||||
from: updatePersonNameProvider,
|
||||
name: r'updatePersonNameProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$updatePersonNameHash,
|
||||
dependencies: UpdatePersonNameFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
UpdatePersonNameFamily._allTransitiveDependencies,
|
||||
personId: personId,
|
||||
updatedName: updatedName,
|
||||
);
|
||||
|
||||
UpdatePersonNameProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.personId,
|
||||
required this.updatedName,
|
||||
}) : super.internal();
|
||||
|
||||
final String personId;
|
||||
final String updatedName;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<bool> Function(UpdatePersonNameRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: UpdatePersonNameProvider._internal(
|
||||
(ref) => create(ref as UpdatePersonNameRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
personId: personId,
|
||||
updatedName: updatedName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<bool> createElement() {
|
||||
return _UpdatePersonNameProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is UpdatePersonNameProvider &&
|
||||
other.personId == personId &&
|
||||
other.updatedName == updatedName;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, personId.hashCode);
|
||||
hash = _SystemHash.combine(hash, updatedName.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
mixin UpdatePersonNameRef on AutoDisposeFutureProviderRef<bool> {
|
||||
/// The parameter `personId` of this provider.
|
||||
String get personId;
|
||||
|
||||
/// The parameter `updatedName` of this provider.
|
||||
String get updatedName;
|
||||
}
|
||||
|
||||
class _UpdatePersonNameProviderElement
|
||||
extends AutoDisposeFutureProviderElement<bool> with UpdatePersonNameRef {
|
||||
_UpdatePersonNameProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get personId => (origin as UpdatePersonNameProvider).personId;
|
||||
@override
|
||||
String get updatedName => (origin as UpdatePersonNameProvider).updatedName;
|
||||
}
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
@@ -0,0 +1,18 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
final recentlyAddedAssetProvider = FutureProvider<List<Asset>>((ref) async {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null) return [];
|
||||
|
||||
return ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(user.isarId)
|
||||
.sortByFileCreatedAtDesc()
|
||||
.findAll();
|
||||
});
|
27
mobile/lib/providers/search/search_filter.provider.dart
Normal file
27
mobile/lib/providers/search/search_filter.provider.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:immich_mobile/services/search.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'search_filter.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<String>> getSearchSuggestions(
|
||||
GetSearchSuggestionsRef ref,
|
||||
SearchSuggestionType type, {
|
||||
String? locationCountry,
|
||||
String? locationState,
|
||||
String? make,
|
||||
String? model,
|
||||
}) async {
|
||||
final SearchService service = ref.read(searchServiceProvider);
|
||||
|
||||
final suggestions = await service.getSearchSuggestions(
|
||||
type,
|
||||
country: locationCountry,
|
||||
state: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
);
|
||||
|
||||
return suggestions ?? [];
|
||||
}
|
229
mobile/lib/providers/search/search_filter.provider.g.dart
generated
Normal file
229
mobile/lib/providers/search/search_filter.provider.g.dart
generated
Normal file
@@ -0,0 +1,229 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_filter.provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$getSearchSuggestionsHash() =>
|
||||
r'bc1e9a1a060868f14e6eb970d2251dbfe39c6866';
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// See also [getSearchSuggestions].
|
||||
@ProviderFor(getSearchSuggestions)
|
||||
const getSearchSuggestionsProvider = GetSearchSuggestionsFamily();
|
||||
|
||||
/// See also [getSearchSuggestions].
|
||||
class GetSearchSuggestionsFamily extends Family<AsyncValue<List<String>>> {
|
||||
/// See also [getSearchSuggestions].
|
||||
const GetSearchSuggestionsFamily();
|
||||
|
||||
/// See also [getSearchSuggestions].
|
||||
GetSearchSuggestionsProvider call(
|
||||
SearchSuggestionType type, {
|
||||
String? locationCountry,
|
||||
String? locationState,
|
||||
String? make,
|
||||
String? model,
|
||||
}) {
|
||||
return GetSearchSuggestionsProvider(
|
||||
type,
|
||||
locationCountry: locationCountry,
|
||||
locationState: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
GetSearchSuggestionsProvider getProviderOverride(
|
||||
covariant GetSearchSuggestionsProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.type,
|
||||
locationCountry: provider.locationCountry,
|
||||
locationState: provider.locationState,
|
||||
make: provider.make,
|
||||
model: provider.model,
|
||||
);
|
||||
}
|
||||
|
||||
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'getSearchSuggestionsProvider';
|
||||
}
|
||||
|
||||
/// See also [getSearchSuggestions].
|
||||
class GetSearchSuggestionsProvider
|
||||
extends AutoDisposeFutureProvider<List<String>> {
|
||||
/// See also [getSearchSuggestions].
|
||||
GetSearchSuggestionsProvider(
|
||||
SearchSuggestionType type, {
|
||||
String? locationCountry,
|
||||
String? locationState,
|
||||
String? make,
|
||||
String? model,
|
||||
}) : this._internal(
|
||||
(ref) => getSearchSuggestions(
|
||||
ref as GetSearchSuggestionsRef,
|
||||
type,
|
||||
locationCountry: locationCountry,
|
||||
locationState: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
),
|
||||
from: getSearchSuggestionsProvider,
|
||||
name: r'getSearchSuggestionsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$getSearchSuggestionsHash,
|
||||
dependencies: GetSearchSuggestionsFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
GetSearchSuggestionsFamily._allTransitiveDependencies,
|
||||
type: type,
|
||||
locationCountry: locationCountry,
|
||||
locationState: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
);
|
||||
|
||||
GetSearchSuggestionsProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.type,
|
||||
required this.locationCountry,
|
||||
required this.locationState,
|
||||
required this.make,
|
||||
required this.model,
|
||||
}) : super.internal();
|
||||
|
||||
final SearchSuggestionType type;
|
||||
final String? locationCountry;
|
||||
final String? locationState;
|
||||
final String? make;
|
||||
final String? model;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<List<String>> Function(GetSearchSuggestionsRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: GetSearchSuggestionsProvider._internal(
|
||||
(ref) => create(ref as GetSearchSuggestionsRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
type: type,
|
||||
locationCountry: locationCountry,
|
||||
locationState: locationState,
|
||||
make: make,
|
||||
model: model,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<List<String>> createElement() {
|
||||
return _GetSearchSuggestionsProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is GetSearchSuggestionsProvider &&
|
||||
other.type == type &&
|
||||
other.locationCountry == locationCountry &&
|
||||
other.locationState == locationState &&
|
||||
other.make == make &&
|
||||
other.model == model;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, type.hashCode);
|
||||
hash = _SystemHash.combine(hash, locationCountry.hashCode);
|
||||
hash = _SystemHash.combine(hash, locationState.hashCode);
|
||||
hash = _SystemHash.combine(hash, make.hashCode);
|
||||
hash = _SystemHash.combine(hash, model.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
mixin GetSearchSuggestionsRef on AutoDisposeFutureProviderRef<List<String>> {
|
||||
/// The parameter `type` of this provider.
|
||||
SearchSuggestionType get type;
|
||||
|
||||
/// The parameter `locationCountry` of this provider.
|
||||
String? get locationCountry;
|
||||
|
||||
/// The parameter `locationState` of this provider.
|
||||
String? get locationState;
|
||||
|
||||
/// The parameter `make` of this provider.
|
||||
String? get make;
|
||||
|
||||
/// The parameter `model` of this provider.
|
||||
String? get model;
|
||||
}
|
||||
|
||||
class _GetSearchSuggestionsProviderElement
|
||||
extends AutoDisposeFutureProviderElement<List<String>>
|
||||
with GetSearchSuggestionsRef {
|
||||
_GetSearchSuggestionsProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
SearchSuggestionType get type =>
|
||||
(origin as GetSearchSuggestionsProvider).type;
|
||||
@override
|
||||
String? get locationCountry =>
|
||||
(origin as GetSearchSuggestionsProvider).locationCountry;
|
||||
@override
|
||||
String? get locationState =>
|
||||
(origin as GetSearchSuggestionsProvider).locationState;
|
||||
@override
|
||||
String? get make => (origin as GetSearchSuggestionsProvider).make;
|
||||
@override
|
||||
String? get model => (origin as GetSearchSuggestionsProvider).model;
|
||||
}
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
51
mobile/lib/providers/search/search_page_state.provider.dart
Normal file
51
mobile/lib/providers/search/search_page_state.provider.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
|
||||
|
||||
import 'package:immich_mobile/services/search.service.dart';
|
||||
|
||||
final getPreviewPlacesProvider =
|
||||
FutureProvider.autoDispose<List<SearchCuratedContent>>((ref) async {
|
||||
final SearchService searchService = ref.watch(searchServiceProvider);
|
||||
|
||||
final exploreData = await searchService.getExploreData();
|
||||
|
||||
if (exploreData == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final locations =
|
||||
exploreData.firstWhere((data) => data.fieldName == "exifInfo.city").items;
|
||||
|
||||
final curatedContent = locations
|
||||
.map(
|
||||
(l) => SearchCuratedContent(
|
||||
label: l.value,
|
||||
id: l.data.id,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return curatedContent;
|
||||
});
|
||||
|
||||
final getAllPlacesProvider =
|
||||
FutureProvider.autoDispose<List<SearchCuratedContent>>((ref) async {
|
||||
final SearchService searchService = ref.watch(searchServiceProvider);
|
||||
|
||||
final assetPlaces = await searchService.getAllPlaces();
|
||||
|
||||
if (assetPlaces == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final curatedContent = assetPlaces
|
||||
.map(
|
||||
(data) => SearchCuratedContent(
|
||||
label: data.exifInfo!.city!,
|
||||
id: data.id,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return curatedContent;
|
||||
});
|
183
mobile/lib/providers/server_info.provider.dart
Normal file
183
mobile/lib/providers/server_info.provider.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||
|
||||
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
||||
import 'package:immich_mobile/services/server_info.service.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_config.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_features.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
ServerInfoNotifier(this._serverInfoService)
|
||||
: super(
|
||||
ServerInfo(
|
||||
serverVersion: const ServerVersion(
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
),
|
||||
latestVersion: const ServerVersion(
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
),
|
||||
serverFeatures: const ServerFeatures(
|
||||
map: true,
|
||||
trash: true,
|
||||
oauthEnabled: false,
|
||||
passwordLogin: true,
|
||||
),
|
||||
serverConfig: const ServerConfig(
|
||||
trashDays: 30,
|
||||
oauthButtonText: '',
|
||||
externalDomain: '',
|
||||
),
|
||||
serverDiskInfo: const ServerDiskInfo(
|
||||
diskAvailable: "0",
|
||||
diskSize: "0",
|
||||
diskUse: "0",
|
||||
diskUsagePercentage: 0,
|
||||
),
|
||||
isVersionMismatch: false,
|
||||
isNewReleaseAvailable: false,
|
||||
versionMismatchErrorMessage: "",
|
||||
),
|
||||
);
|
||||
|
||||
final ServerInfoService _serverInfoService;
|
||||
final _log = Logger("ServerInfoNotifier");
|
||||
|
||||
Future<void> getServerInfo() async {
|
||||
await getServerVersion();
|
||||
await getServerFeatures();
|
||||
await getServerConfig();
|
||||
}
|
||||
|
||||
getServerVersion() async {
|
||||
try {
|
||||
final serverVersion = await _serverInfoService.getServerVersion();
|
||||
|
||||
if (serverVersion == null) {
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
versionMismatchErrorMessage: "common_server_error".tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _checkServerVersionMismatch(serverVersion);
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("Failed to get server version", e, stackTrace);
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_checkServerVersionMismatch(ServerVersion serverVersion) async {
|
||||
state = state.copyWith(serverVersion: serverVersion);
|
||||
|
||||
var packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
Map<String, int> appVersion = _getDetailVersion(packageInfo.version);
|
||||
|
||||
if (appVersion["major"]! > serverVersion.major) {
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
versionMismatchErrorMessage:
|
||||
"profile_drawer_server_out_of_date_major".tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (appVersion["major"]! < serverVersion.major) {
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
versionMismatchErrorMessage:
|
||||
"profile_drawer_client_out_of_date_major".tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (appVersion["minor"]! > serverVersion.minor) {
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
versionMismatchErrorMessage:
|
||||
"profile_drawer_server_out_of_date_minor".tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (appVersion["minor"]! < serverVersion.minor) {
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
versionMismatchErrorMessage:
|
||||
"profile_drawer_client_out_of_date_minor".tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: false,
|
||||
versionMismatchErrorMessage: "",
|
||||
);
|
||||
}
|
||||
|
||||
handleNewRelease(
|
||||
ServerVersion serverVersion,
|
||||
ServerVersion latestVersion,
|
||||
) {
|
||||
// Update local server version
|
||||
_checkServerVersionMismatch(serverVersion);
|
||||
|
||||
final majorEqual = latestVersion.major == serverVersion.major;
|
||||
final minorEqual = majorEqual && latestVersion.minor == serverVersion.minor;
|
||||
final newVersionAvailable = latestVersion.major > serverVersion.major ||
|
||||
(majorEqual && latestVersion.minor > serverVersion.minor) ||
|
||||
(minorEqual && latestVersion.patch > serverVersion.patch);
|
||||
|
||||
state = state.copyWith(
|
||||
latestVersion: latestVersion,
|
||||
isNewReleaseAvailable: newVersionAvailable,
|
||||
);
|
||||
}
|
||||
|
||||
getServerFeatures() async {
|
||||
final serverFeatures = await _serverInfoService.getServerFeatures();
|
||||
if (serverFeatures == null) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(serverFeatures: serverFeatures);
|
||||
}
|
||||
|
||||
getServerConfig() async {
|
||||
final serverConfig = await _serverInfoService.getServerConfig();
|
||||
if (serverConfig == null) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(serverConfig: serverConfig);
|
||||
}
|
||||
|
||||
Map<String, int> _getDetailVersion(String version) {
|
||||
List<String> detail = version.split(".");
|
||||
|
||||
var major = detail[0];
|
||||
var minor = detail[1];
|
||||
var patch = detail[2];
|
||||
|
||||
return {
|
||||
"major": int.parse(major),
|
||||
"minor": int.parse(minor),
|
||||
"patch": int.parse(patch.replaceAll("-DEBUG", "")),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final serverInfoProvider =
|
||||
StateNotifierProvider<ServerInfoNotifier, ServerInfo>((ref) {
|
||||
return ServerInfoNotifier(ref.read(serverInfoServiceProvider));
|
||||
});
|
29
mobile/lib/providers/shared_link.provider.dart
Normal file
29
mobile/lib/providers/shared_link.provider.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/services/shared_link.service.dart';
|
||||
|
||||
class SharedLinksNotifier extends StateNotifier<AsyncValue<List<SharedLink>>> {
|
||||
final SharedLinkService _sharedLinkService;
|
||||
|
||||
SharedLinksNotifier(this._sharedLinkService) : super(const AsyncLoading()) {
|
||||
fetchLinks();
|
||||
}
|
||||
|
||||
Future<void> fetchLinks() async {
|
||||
state = await _sharedLinkService.getAllSharedLinks();
|
||||
}
|
||||
|
||||
Future<void> deleteLink(String id) async {
|
||||
await _sharedLinkService.deleteSharedLink(id);
|
||||
state = const AsyncLoading();
|
||||
fetchLinks();
|
||||
}
|
||||
}
|
||||
|
||||
final sharedLinksStateProvider =
|
||||
StateNotifierProvider<SharedLinksNotifier, AsyncValue<List<SharedLink>>>(
|
||||
(ref) {
|
||||
return SharedLinksNotifier(
|
||||
ref.watch(sharedLinkServiceProvider),
|
||||
);
|
||||
});
|
13
mobile/lib/providers/tab.provider.dart
Normal file
13
mobile/lib/providers/tab.provider.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
enum TabEnum {
|
||||
home,
|
||||
search,
|
||||
sharing,
|
||||
library,
|
||||
}
|
||||
|
||||
/// Provides the currently active tab
|
||||
final tabProvider = StateProvider<TabEnum>(
|
||||
(ref) => TabEnum.home,
|
||||
);
|
172
mobile/lib/providers/trash.provider.dart
Normal file
172
mobile/lib/providers/trash.provider.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/services/trash.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:immich_mobile/utils/renderlist_generator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TrashNotifier extends StateNotifier<bool> {
|
||||
final Isar _db;
|
||||
final Ref _ref;
|
||||
final TrashService _trashService;
|
||||
final _log = Logger('TrashNotifier');
|
||||
|
||||
TrashNotifier(
|
||||
this._trashService,
|
||||
this._db,
|
||||
this._ref,
|
||||
) : super(false);
|
||||
|
||||
Future<void> emptyTrash() async {
|
||||
try {
|
||||
final user = _ref.read(currentUserProvider);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
await _trashService.emptyTrash();
|
||||
|
||||
final idsToRemove = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isTrashedEqualTo(true)
|
||||
.remoteIdProperty()
|
||||
.findAll();
|
||||
|
||||
// TODO: handle local asset removal on emptyTrash
|
||||
_ref
|
||||
.read(syncServiceProvider)
|
||||
.handleRemoteAssetRemoval(idsToRemove.cast<String>().toList());
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot empty trash", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAssets(Iterable<Asset> assetList) async {
|
||||
try {
|
||||
final user = _ref.read(currentUserProvider);
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final isRemoved = await _ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteRemoteOnlyAssets(assetList, force: true);
|
||||
|
||||
if (isRemoved) {
|
||||
final idsToRemove =
|
||||
assetList.where((a) => a.isRemote).map((a) => a.remoteId!).toList();
|
||||
|
||||
_ref
|
||||
.read(syncServiceProvider)
|
||||
.handleRemoteAssetRemoval(idsToRemove.cast<String>().toList());
|
||||
}
|
||||
|
||||
return isRemoved;
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot remove assets", error, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> restoreAsset(Asset asset) async {
|
||||
try {
|
||||
final result = await _trashService.restoreAsset(asset);
|
||||
|
||||
if (result) {
|
||||
final remoteAsset = asset.isRemote;
|
||||
|
||||
asset.isTrashed = false;
|
||||
|
||||
if (remoteAsset) {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.put(asset);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore asset", error, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
|
||||
try {
|
||||
final result = await _trashService.restoreAssets(assetList);
|
||||
|
||||
if (result) {
|
||||
final remoteAssets = assetList.where((a) => a.isRemote).toList();
|
||||
|
||||
final updatedAssets = remoteAssets.map((e) {
|
||||
e.isTrashed = false;
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(updatedAssets);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore assets", error, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> restoreTrash() async {
|
||||
try {
|
||||
final user = _ref.read(currentUserProvider);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
await _trashService.restoreTrash();
|
||||
|
||||
final assets = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isTrashedEqualTo(true)
|
||||
.findAll();
|
||||
|
||||
final updatedAssets = assets.map((e) {
|
||||
e.isTrashed = false;
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(updatedAssets);
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final trashProvider = StateNotifierProvider<TrashNotifier, bool>((ref) {
|
||||
return TrashNotifier(
|
||||
ref.watch(trashServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
||||
final trashedAssetsProvider = StreamProvider<RenderList>((ref) {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null) return const Stream.empty();
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isTrashedEqualTo(true)
|
||||
.sortByFileCreatedAt();
|
||||
return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
|
||||
});
|
107
mobile/lib/providers/upload_profile_image.provider.dart
Normal file
107
mobile/lib/providers/upload_profile_image.provider.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:immich_mobile/services/user.service.dart';
|
||||
|
||||
enum UploadProfileStatus {
|
||||
idle,
|
||||
loading,
|
||||
success,
|
||||
failure,
|
||||
}
|
||||
|
||||
class UploadProfileImageState {
|
||||
// enum
|
||||
final UploadProfileStatus status;
|
||||
final String profileImagePath;
|
||||
UploadProfileImageState({
|
||||
required this.status,
|
||||
required this.profileImagePath,
|
||||
});
|
||||
|
||||
UploadProfileImageState copyWith({
|
||||
UploadProfileStatus? status,
|
||||
String? profileImagePath,
|
||||
}) {
|
||||
return UploadProfileImageState(
|
||||
status: status ?? this.status,
|
||||
profileImagePath: profileImagePath ?? this.profileImagePath,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
result.addAll({'status': status.index});
|
||||
result.addAll({'profileImagePath': profileImagePath});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
factory UploadProfileImageState.fromMap(Map<String, dynamic> map) {
|
||||
return UploadProfileImageState(
|
||||
status: UploadProfileStatus.values[map['status'] ?? 0],
|
||||
profileImagePath: map['profileImagePath'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory UploadProfileImageState.fromJson(String source) =>
|
||||
UploadProfileImageState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'UploadProfileImageState(status: $status, profileImagePath: $profileImagePath)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is UploadProfileImageState &&
|
||||
other.status == status &&
|
||||
other.profileImagePath == profileImagePath;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => status.hashCode ^ profileImagePath.hashCode;
|
||||
}
|
||||
|
||||
class UploadProfileImageNotifier
|
||||
extends StateNotifier<UploadProfileImageState> {
|
||||
UploadProfileImageNotifier(this._userSErvice)
|
||||
: super(
|
||||
UploadProfileImageState(
|
||||
profileImagePath: '',
|
||||
status: UploadProfileStatus.idle,
|
||||
),
|
||||
);
|
||||
|
||||
final UserService _userSErvice;
|
||||
|
||||
Future<bool> upload(XFile file) async {
|
||||
state = state.copyWith(status: UploadProfileStatus.loading);
|
||||
|
||||
var res = await _userSErvice.uploadProfileImage(file);
|
||||
|
||||
if (res != null) {
|
||||
debugPrint("Successfully upload profile image");
|
||||
state = state.copyWith(
|
||||
status: UploadProfileStatus.success,
|
||||
profileImagePath: res.profileImagePath,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
state = state.copyWith(status: UploadProfileStatus.failure);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final uploadProfileImageProvider =
|
||||
StateNotifierProvider<UploadProfileImageNotifier, UploadProfileImageState>(
|
||||
((ref) => UploadProfileImageNotifier(ref.watch(userServiceProvider))),
|
||||
);
|
74
mobile/lib/providers/user.provider.dart
Normal file
74
mobile/lib/providers/user.provider.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class CurrentUserProvider extends StateNotifier<User?> {
|
||||
CurrentUserProvider(this._apiService) : super(null) {
|
||||
state = Store.tryGet(StoreKey.currentUser);
|
||||
streamSub =
|
||||
Store.watch(StoreKey.currentUser).listen((user) => state = user);
|
||||
}
|
||||
|
||||
final ApiService _apiService;
|
||||
late final StreamSubscription<User?> streamSub;
|
||||
|
||||
refresh() async {
|
||||
try {
|
||||
final user = await _apiService.userApi.getMyUserInfo();
|
||||
if (user != null) {
|
||||
Store.put(
|
||||
StoreKey.currentUser,
|
||||
User.fromUserDto(user),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final currentUserProvider =
|
||||
StateNotifierProvider<CurrentUserProvider, User?>((ref) {
|
||||
return CurrentUserProvider(
|
||||
ref.watch(apiServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class TimelineUserIdsProvider extends StateNotifier<List<int>> {
|
||||
TimelineUserIdsProvider(Isar db, User? currentUser) : super([]) {
|
||||
final query = db.users
|
||||
.filter()
|
||||
.inTimelineEqualTo(true)
|
||||
.or()
|
||||
.isarIdEqualTo(currentUser?.isarId ?? Isar.autoIncrement)
|
||||
.isarIdProperty();
|
||||
query.findAll().then((users) => state = users);
|
||||
streamSub = query.watch().listen((users) => state = users);
|
||||
}
|
||||
|
||||
late final StreamSubscription<List<int>> streamSub;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
streamSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final timelineUsersIdsProvider =
|
||||
StateNotifierProvider<TimelineUserIdsProvider, List<int>>((ref) {
|
||||
return TimelineUserIdsProvider(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(currentUserProvider),
|
||||
);
|
||||
});
|
324
mobile/lib/providers/websocket.provider.dart
Normal file
324
mobile/lib/providers/websocket.provider.dart
Normal file
@@ -0,0 +1,324 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/services/sync.service.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:socket_io_client/socket_io_client.dart';
|
||||
|
||||
enum PendingAction {
|
||||
assetDelete,
|
||||
assetUploaded,
|
||||
assetHidden,
|
||||
}
|
||||
|
||||
class PendingChange {
|
||||
final String id;
|
||||
final PendingAction action;
|
||||
final dynamic value;
|
||||
|
||||
const PendingChange(
|
||||
this.id,
|
||||
this.action,
|
||||
this.value,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'PendingChange(id: $id, action: $action, value: $value)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is PendingChange && other.id == id && other.action == action;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ action.hashCode;
|
||||
}
|
||||
|
||||
class WebsocketState {
|
||||
final Socket? socket;
|
||||
final bool isConnected;
|
||||
final List<PendingChange> pendingChanges;
|
||||
|
||||
WebsocketState({
|
||||
this.socket,
|
||||
required this.isConnected,
|
||||
required this.pendingChanges,
|
||||
});
|
||||
|
||||
WebsocketState copyWith({
|
||||
Socket? socket,
|
||||
bool? isConnected,
|
||||
List<PendingChange>? pendingChanges,
|
||||
}) {
|
||||
return WebsocketState(
|
||||
socket: socket ?? this.socket,
|
||||
isConnected: isConnected ?? this.isConnected,
|
||||
pendingChanges: pendingChanges ?? this.pendingChanges,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'WebsocketState(socket: $socket, isConnected: $isConnected)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is WebsocketState &&
|
||||
other.socket == socket &&
|
||||
other.isConnected == isConnected;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => socket.hashCode ^ isConnected.hashCode;
|
||||
}
|
||||
|
||||
class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
WebsocketNotifier(this._ref)
|
||||
: super(
|
||||
WebsocketState(socket: null, isConnected: false, pendingChanges: []),
|
||||
);
|
||||
|
||||
final _log = Logger('WebsocketNotifier');
|
||||
final Ref _ref;
|
||||
final Debouncer _debounce =
|
||||
Debouncer(interval: const Duration(milliseconds: 500));
|
||||
|
||||
/// Connects websocket to server unless already connected
|
||||
void connect() {
|
||||
if (state.isConnected) return;
|
||||
final authenticationState = _ref.read(authenticationProvider);
|
||||
|
||||
if (authenticationState.isAuthenticated) {
|
||||
final accessToken = Store.get(StoreKey.accessToken);
|
||||
try {
|
||||
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
|
||||
final headers = {"x-immich-user-token": accessToken};
|
||||
if (endpoint.userInfo.isNotEmpty) {
|
||||
headers["Authorization"] =
|
||||
"Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
|
||||
}
|
||||
|
||||
debugPrint("Attempting to connect to websocket");
|
||||
// Configure socket transports must be specified
|
||||
Socket socket = io(
|
||||
endpoint.origin,
|
||||
OptionBuilder()
|
||||
.setPath("${endpoint.path}/socket.io")
|
||||
.setTransports(['websocket'])
|
||||
.enableReconnection()
|
||||
.enableForceNew()
|
||||
.enableForceNewConnection()
|
||||
.enableAutoConnect()
|
||||
.setExtraHeaders(headers)
|
||||
.build(),
|
||||
);
|
||||
|
||||
socket.onConnect((_) {
|
||||
debugPrint("Established Websocket Connection");
|
||||
state = WebsocketState(
|
||||
isConnected: true,
|
||||
socket: socket,
|
||||
pendingChanges: state.pendingChanges,
|
||||
);
|
||||
});
|
||||
|
||||
socket.onDisconnect((_) {
|
||||
debugPrint("Disconnect to Websocket Connection");
|
||||
state = WebsocketState(
|
||||
isConnected: false,
|
||||
socket: null,
|
||||
pendingChanges: state.pendingChanges,
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('error', (errorMessage) {
|
||||
_log.severe("Websocket Error - $errorMessage");
|
||||
state = WebsocketState(
|
||||
isConnected: false,
|
||||
socket: null,
|
||||
pendingChanges: state.pendingChanges,
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('on_upload_success', _handleOnUploadSuccess);
|
||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||
socket.on('on_asset_delete', _handleOnAssetDelete);
|
||||
socket.on('on_asset_trash', _handleServerUpdates);
|
||||
socket.on('on_asset_restore', _handleServerUpdates);
|
||||
socket.on('on_asset_update', _handleServerUpdates);
|
||||
socket.on('on_asset_stack_update', _handleServerUpdates);
|
||||
socket.on('on_asset_hidden', _handleOnAssetHidden);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
} catch (e) {
|
||||
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
debugPrint("Attempting to disconnect from websocket");
|
||||
|
||||
var socket = state.socket?.disconnect();
|
||||
|
||||
if (socket?.disconnected == true) {
|
||||
state = WebsocketState(
|
||||
isConnected: false,
|
||||
socket: null,
|
||||
pendingChanges: state.pendingChanges,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void stopListenToEvent(String eventName) {
|
||||
debugPrint("Stop listening to event $eventName");
|
||||
state.socket?.off(eventName);
|
||||
}
|
||||
|
||||
void listenUploadEvent() {
|
||||
debugPrint("Start listening to event on_upload_success");
|
||||
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
|
||||
}
|
||||
|
||||
void addPendingChange(PendingAction action, dynamic value) {
|
||||
final now = DateTime.now();
|
||||
state = state.copyWith(
|
||||
pendingChanges: [
|
||||
...state.pendingChanges,
|
||||
PendingChange(now.millisecondsSinceEpoch.toString(), action, value),
|
||||
],
|
||||
);
|
||||
_debounce.run(handlePendingChanges);
|
||||
}
|
||||
|
||||
Future<void> _handlePendingDeletes() async {
|
||||
final deleteChanges = state.pendingChanges
|
||||
.where((c) => c.action == PendingAction.assetDelete)
|
||||
.toList();
|
||||
if (deleteChanges.isNotEmpty) {
|
||||
List<String> remoteIds =
|
||||
deleteChanges.map((a) => a.value.toString()).toList();
|
||||
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
|
||||
state = state.copyWith(
|
||||
pendingChanges: state.pendingChanges
|
||||
.whereNot((c) => deleteChanges.contains(c))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePendingUploaded() async {
|
||||
final uploadedChanges = state.pendingChanges
|
||||
.where((c) => c.action == PendingAction.assetUploaded)
|
||||
.toList();
|
||||
if (uploadedChanges.isNotEmpty) {
|
||||
List<AssetResponseDto?> remoteAssets = uploadedChanges
|
||||
.map((a) => AssetResponseDto.fromJson(a.value))
|
||||
.toList();
|
||||
for (final dto in remoteAssets) {
|
||||
if (dto != null) {
|
||||
final newAsset = Asset.remote(dto);
|
||||
await _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
|
||||
}
|
||||
}
|
||||
state = state.copyWith(
|
||||
pendingChanges: state.pendingChanges
|
||||
.whereNot((c) => uploadedChanges.contains(c))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlingPendingHidden() async {
|
||||
final hiddenChanges = state.pendingChanges
|
||||
.where((c) => c.action == PendingAction.assetHidden)
|
||||
.toList();
|
||||
if (hiddenChanges.isNotEmpty) {
|
||||
List<String> remoteIds =
|
||||
hiddenChanges.map((a) => a.value.toString()).toList();
|
||||
final db = _ref.watch(dbProvider);
|
||||
await db.writeTxn(() => db.assets.deleteAllByRemoteId(remoteIds));
|
||||
|
||||
state = state.copyWith(
|
||||
pendingChanges: state.pendingChanges
|
||||
.whereNot((c) => hiddenChanges.contains(c))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void handlePendingChanges() async {
|
||||
await _handlePendingUploaded();
|
||||
await _handlePendingDeletes();
|
||||
await _handlingPendingHidden();
|
||||
}
|
||||
|
||||
void _handleOnConfigUpdate(dynamic _) {
|
||||
_ref.read(serverInfoProvider.notifier).getServerFeatures();
|
||||
_ref.read(serverInfoProvider.notifier).getServerConfig();
|
||||
}
|
||||
|
||||
// Refresh updated assets
|
||||
void _handleServerUpdates(dynamic _) {
|
||||
_ref.read(assetProvider.notifier).getAllAsset();
|
||||
}
|
||||
|
||||
void _handleOnUploadSuccess(dynamic data) =>
|
||||
addPendingChange(PendingAction.assetUploaded, data);
|
||||
|
||||
void _handleOnAssetDelete(dynamic data) =>
|
||||
addPendingChange(PendingAction.assetDelete, data);
|
||||
|
||||
void _handleOnAssetHidden(dynamic data) =>
|
||||
addPendingChange(PendingAction.assetHidden, data);
|
||||
|
||||
_handleReleaseUpdates(dynamic data) {
|
||||
// Json guard
|
||||
if (data is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
final json = data.cast<String, dynamic>();
|
||||
final serverVersionJson =
|
||||
json.containsKey('serverVersion') ? json['serverVersion'] : null;
|
||||
final releaseVersionJson =
|
||||
json.containsKey('releaseVersion') ? json['releaseVersion'] : null;
|
||||
if (serverVersionJson == null || releaseVersionJson == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final serverVersionDto =
|
||||
ServerVersionResponseDto.fromJson(serverVersionJson);
|
||||
final releaseVersionDto =
|
||||
ServerVersionResponseDto.fromJson(releaseVersionJson);
|
||||
if (serverVersionDto == null || releaseVersionDto == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final serverVersion = ServerVersion.fromDto(serverVersionDto);
|
||||
final releaseVersion = ServerVersion.fromDto(releaseVersionDto);
|
||||
_ref
|
||||
.read(serverInfoProvider.notifier)
|
||||
.handleNewRelease(serverVersion, releaseVersion);
|
||||
}
|
||||
}
|
||||
|
||||
final websocketProvider =
|
||||
StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
|
||||
return WebsocketNotifier(ref);
|
||||
});
|
Reference in New Issue
Block a user