mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
refactor(mobile): services and providers (#9232)
* refactor(mobile): services and provider * providers
This commit is contained in:
parent
ec4eb7cd19
commit
c1253663b7
242 changed files with 497 additions and 503 deletions
|
|
@ -1,67 +0,0 @@
|
|||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity_service.provider.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
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 {}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import 'package:immich_mobile/modules/activities/services/activity.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'activity_service.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
ActivityService activityService(ActivityServiceRef ref) =>
|
||||
ActivityService(ref.watch(apiServiceProvider));
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import 'package:immich_mobile/modules/activities/providers/activity_service.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'activity_statistics.provider.g.dart';
|
||||
|
||||
/// Maintains the current number of comments by <shared-album, asset>
|
||||
@riverpod
|
||||
class ActivityStatistics extends _$ActivityStatistics {
|
||||
@override
|
||||
int build(String albumId, [String? assetId]) {
|
||||
ref
|
||||
.watch(activityServiceProvider)
|
||||
.getStatistics(albumId, assetId: assetId)
|
||||
.then((comments) => state = comments);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void addActivity() => state = state + 1;
|
||||
|
||||
void removeActivity() => state = state - 1;
|
||||
}
|
||||
|
||||
/// Mock class for testing
|
||||
abstract class ActivityStatisticsInternal extends _$ActivityStatistics {}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import 'package:immich_mobile/constants/errors.dart';
|
||||
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class ActivityService with ErrorLoggerMixin {
|
||||
final ApiService _apiService;
|
||||
|
||||
@override
|
||||
final Logger logger = Logger("ActivityService");
|
||||
|
||||
ActivityService(this._apiService);
|
||||
|
||||
Future<List<Activity>> getAllActivities(
|
||||
String albumId, {
|
||||
String? assetId,
|
||||
}) async {
|
||||
return logError(
|
||||
() async {
|
||||
final list = await _apiService.activityApi
|
||||
.getActivities(albumId, assetId: assetId);
|
||||
return list != null ? list.map(Activity.fromDto).toList() : [];
|
||||
},
|
||||
defaultValue: [],
|
||||
errorMessage: "Failed to get all activities for album $albumId",
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> getStatistics(String albumId, {String? assetId}) async {
|
||||
return logError(
|
||||
() async {
|
||||
final dto = await _apiService.activityApi
|
||||
.getActivityStatistics(albumId, assetId: assetId);
|
||||
return dto?.comments ?? 0;
|
||||
},
|
||||
defaultValue: 0,
|
||||
errorMessage: "Failed to statistics for album $albumId",
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> removeActivity(String id) async {
|
||||
return logError(
|
||||
() async {
|
||||
await _apiService.activityApi.deleteActivity(id);
|
||||
return true;
|
||||
},
|
||||
defaultValue: false,
|
||||
errorMessage: "Failed to delete activity",
|
||||
);
|
||||
}
|
||||
|
||||
AsyncFuture<Activity> addActivity(
|
||||
String albumId,
|
||||
ActivityType type, {
|
||||
String? assetId,
|
||||
String? comment,
|
||||
}) async {
|
||||
return guardError(
|
||||
() async {
|
||||
final dto = await _apiService.activityApi.createActivity(
|
||||
ActivityCreateDto(
|
||||
albumId: albumId,
|
||||
type: type == ActivityType.comment
|
||||
? ReactionType.comment
|
||||
: ReactionType.like,
|
||||
assetId: assetId,
|
||||
comment: comment,
|
||||
),
|
||||
);
|
||||
if (dto != null) {
|
||||
return Activity.fromDto(dto);
|
||||
}
|
||||
throw NoResponseDtoError();
|
||||
},
|
||||
errorMessage: "Failed to create $type for album $albumId",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,13 +6,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/modules/activities/widgets/activity_text_field.dart';
|
||||
import 'package:immich_mobile/modules/activities/widgets/activity_tile.dart';
|
||||
import 'package:immich_mobile/modules/activities/widgets/dismissible_activity.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ActivitiesPage extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
|
||||
class ActivityTextField extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/datetime_extensions.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
|
||||
class ActivityTile extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/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/shared/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();
|
||||
});
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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(),
|
||||
);
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/albums/album_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/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);
|
||||
});
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/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/shared/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),
|
||||
);
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||
|
||||
final otherUsersProvider = FutureProvider.autoDispose<List<User>>((ref) {
|
||||
UserService userService = ref.watch(userServiceProvider);
|
||||
|
||||
return userService.getUsersInDb();
|
||||
});
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final albumServiceProvider = Provider(
|
||||
(ref) => AlbumService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class AlbumService {
|
||||
final ApiService _apiService;
|
||||
final UserService _userService;
|
||||
final SyncService _syncService;
|
||||
final Isar _db;
|
||||
final BackupService _backupService;
|
||||
final Logger _log = Logger('AlbumService');
|
||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||
|
||||
AlbumService(
|
||||
this._apiService,
|
||||
this._userService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
this._backupService,
|
||||
);
|
||||
|
||||
/// Checks all selected device albums for changes of albums and their assets
|
||||
/// Updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshDeviceAlbums() async {
|
||||
if (!_localCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
_log.info("refreshDeviceAlbums is already in progress");
|
||||
return _localCompleter.future;
|
||||
}
|
||||
_localCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
final List<String> excludedIds =
|
||||
await _backupService.excludedAlbumsQuery().idProperty().findAll();
|
||||
final List<String> selectedIds =
|
||||
await _backupService.selectedAlbumsQuery().idProperty().findAll();
|
||||
if (selectedIds.isEmpty) {
|
||||
final numLocal = await _db.albums.where().localIdIsNotNull().count();
|
||||
if (numLocal > 0) {
|
||||
_syncService.removeAllLocalAlbumsAndAssets();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final List<AssetPathEntity> onDevice =
|
||||
await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
filterOption: FilterOptionGroup(containsPathModified: true),
|
||||
);
|
||||
_log.info("Found ${onDevice.length} device albums");
|
||||
Set<String>? excludedAssets;
|
||||
if (excludedIds.isNotEmpty) {
|
||||
if (Platform.isIOS) {
|
||||
// iOS and Android device album working principle differ significantly
|
||||
// on iOS, an asset can be in multiple albums
|
||||
// on Android, an asset can only be in exactly one album (folder!) at the same time
|
||||
// thus, on Android, excluding an album can be done by ignoring that album
|
||||
// however, on iOS, it it necessary to load the assets from all excluded
|
||||
// albums and check every asset from any selected album against the set
|
||||
// of excluded assets
|
||||
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
|
||||
_log.info("Found ${excludedAssets.length} assets to exclude");
|
||||
}
|
||||
// remove all excluded albums
|
||||
onDevice.removeWhere((e) => excludedIds.contains(e.id));
|
||||
_log.info(
|
||||
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
||||
);
|
||||
}
|
||||
final hasAll = selectedIds
|
||||
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
|
||||
.whereNotNull()
|
||||
.any((a) => a.isAll);
|
||||
if (hasAll) {
|
||||
if (Platform.isAndroid) {
|
||||
// remove the virtual "Recent" album and keep and individual albums
|
||||
// on Android, the virtual "Recent" `lastModified` value is always null
|
||||
onDevice.removeWhere((e) => e.isAll);
|
||||
_log.info("'Recents' is selected, keeping all individual albums");
|
||||
}
|
||||
} else {
|
||||
// keep only the explicitly selected albums
|
||||
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
|
||||
_log.info("'Recents' is not selected, keeping only selected albums");
|
||||
}
|
||||
changes =
|
||||
await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);
|
||||
_log.info("Syncing completed. Changes: $changes");
|
||||
} finally {
|
||||
_localCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadExcludedAssetIds(
|
||||
List<AssetPathEntity> albums,
|
||||
List<String> excludedAlbumIds,
|
||||
) async {
|
||||
final Set<String> result = HashSet<String>();
|
||||
for (AssetPathEntity a in albums) {
|
||||
if (excludedAlbumIds.contains(a.id)) {
|
||||
final List<AssetEntity> assets =
|
||||
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
|
||||
result.addAll(assets.map((e) => e.id));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Checks remote albums (owned if `isShared` is false) for changes,
|
||||
/// updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
|
||||
if (!_remoteCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
return _remoteCompleter.future;
|
||||
}
|
||||
_remoteCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
await _userService.refreshUsers();
|
||||
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumApi
|
||||
.getAllAlbums(shared: isShared ? true : null);
|
||||
if (serverAlbums == null) {
|
||||
return false;
|
||||
}
|
||||
changes = await _syncService.syncRemoteAlbumsToDb(
|
||||
serverAlbums,
|
||||
isShared: isShared,
|
||||
loadDetails: (dto) async => dto.assetCount == dto.assets.length
|
||||
? dto
|
||||
: (await _apiService.albumApi.getAlbumInfo(dto.id)) ?? dto,
|
||||
);
|
||||
} finally {
|
||||
_remoteCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
String albumName,
|
||||
Iterable<Asset> assets, [
|
||||
Iterable<User> sharedUsers = const [],
|
||||
]) async {
|
||||
try {
|
||||
AlbumResponseDto? remote = await _apiService.albumApi.createAlbum(
|
||||
CreateAlbumDto(
|
||||
albumName: albumName,
|
||||
assetIds: assets.map((asset) => asset.remoteId!).toList(),
|
||||
sharedWithUserIds: sharedUsers.map((e) => e.id).toList(),
|
||||
),
|
||||
);
|
||||
if (remote != null) {
|
||||
Album album = await Album.remote(remote);
|
||||
await _db.writeTxn(() => _db.albums.store(album));
|
||||
return album;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Creates names like Untitled, Untitled (1), Untitled (2), ...
|
||||
*/
|
||||
Future<String> _getNextAlbumName() async {
|
||||
const baseName = "Untitled";
|
||||
for (int round = 0;; round++) {
|
||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||
|
||||
if (null ==
|
||||
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
|
||||
return proposedName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Album?> createAlbumWithGeneratedName(
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
return createAlbum(
|
||||
await _getNextAlbumName(),
|
||||
assets,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Future<AlbumAddAssetsResponse?> addAdditionalAssetToAlbum(
|
||||
Iterable<Asset> assets,
|
||||
Album album,
|
||||
) async {
|
||||
try {
|
||||
var response = await _apiService.albumApi.addAssetsToAlbum(
|
||||
album.remoteId!,
|
||||
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()),
|
||||
);
|
||||
|
||||
if (response != null) {
|
||||
List<Asset> successAssets = [];
|
||||
List<String> duplicatedAssets = [];
|
||||
|
||||
for (final result in response) {
|
||||
if (result.success) {
|
||||
successAssets
|
||||
.add(assets.firstWhere((asset) => asset.remoteId == result.id));
|
||||
} else if (!result.success &&
|
||||
result.error == BulkIdResponseDtoErrorEnum.duplicate) {
|
||||
duplicatedAssets.add(result.id);
|
||||
}
|
||||
}
|
||||
|
||||
await _updateAssets(album.id, add: successAssets);
|
||||
|
||||
return AlbumAddAssetsResponse(
|
||||
alreadyInAlbum: duplicatedAssets,
|
||||
successfullyAdded: successAssets.length,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _updateAssets(
|
||||
int albumId, {
|
||||
Iterable<Asset> add = const [],
|
||||
Iterable<Asset> remove = const [],
|
||||
}) {
|
||||
return _db.writeTxn(() async {
|
||||
final album = await _db.albums.get(albumId);
|
||||
if (album == null) return;
|
||||
await album.assets.update(link: add, unlink: remove);
|
||||
album.startDate =
|
||||
await album.assets.filter().fileCreatedAtProperty().min();
|
||||
album.endDate = await album.assets.filter().fileCreatedAtProperty().max();
|
||||
album.lastModifiedAssetTimestamp =
|
||||
await album.assets.filter().updatedAtProperty().max();
|
||||
await _db.albums.put(album);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> addAdditionalUserToAlbum(
|
||||
List<String> sharedUserIds,
|
||||
Album album,
|
||||
) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.addUsersToAlbum(
|
||||
album.remoteId!,
|
||||
AddUsersDto(sharedUserIds: sharedUserIds),
|
||||
);
|
||||
if (result != null) {
|
||||
album.sharedUsers
|
||||
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
|
||||
album.shared = result.shared;
|
||||
await _db.writeTxn(() async {
|
||||
await _db.albums.put(album);
|
||||
await album.sharedUsers.save();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> setActivityEnabled(Album album, bool enabled) async {
|
||||
try {
|
||||
final result = await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
UpdateAlbumDto(isActivityEnabled: enabled),
|
||||
);
|
||||
if (result != null) {
|
||||
album.activityEnabled = enabled;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error setActivityEnabled ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
try {
|
||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||
if (album.owner.value?.isarId == userId) {
|
||||
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
||||
}
|
||||
if (album.shared) {
|
||||
final foreignAssets =
|
||||
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
final List<Album> albums =
|
||||
await _db.albums.filter().sharedEqualTo(true).findAll();
|
||||
final List<Asset> existing = [];
|
||||
for (Album a in albums) {
|
||||
existing.addAll(
|
||||
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
|
||||
);
|
||||
}
|
||||
final List<int> idsToRemove =
|
||||
_syncService.sharedAssetsToRemove(foreignAssets, existing);
|
||||
if (idsToRemove.isNotEmpty) {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
|
||||
}
|
||||
} else {
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeUserFromAlbum(album.remoteId!, "me");
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error leaveAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(
|
||||
Album album,
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
try {
|
||||
final response = await _apiService.albumApi.removeAssetFromAlbum(
|
||||
album.remoteId!,
|
||||
BulkIdsDto(
|
||||
ids: assets.map((asset) => asset.remoteId!).toList(),
|
||||
),
|
||||
);
|
||||
if (response != null) {
|
||||
final toRemove = response.every((e) => e.success)
|
||||
? assets
|
||||
: response
|
||||
.where((e) => e.success)
|
||||
.map((e) => assets.firstWhere((a) => a.remoteId == e.id));
|
||||
await _updateAssets(album.id, remove: toRemove);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error removeAssetFromAlbum ${e.toString()}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> removeUserFromAlbum(
|
||||
Album album,
|
||||
User user,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.removeUserFromAlbum(
|
||||
album.remoteId!,
|
||||
user.id,
|
||||
);
|
||||
|
||||
album.sharedUsers.remove(user);
|
||||
await _db.writeTxn(() async {
|
||||
await album.sharedUsers.update(unlink: [user]);
|
||||
final a = await _db.albums.get(album.id);
|
||||
// trigger watcher
|
||||
await _db.albums.put(a!);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error removeUserFromAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> changeTitleAlbum(
|
||||
Album album,
|
||||
String newAlbumTitle,
|
||||
) async {
|
||||
try {
|
||||
await _apiService.albumApi.updateAlbumInfo(
|
||||
album.remoteId!,
|
||||
UpdateAlbumDto(
|
||||
albumName: newAlbumTitle,
|
||||
),
|
||||
);
|
||||
album.name = newAlbumTitle;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error changeTitleAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_title.provider.dart';
|
||||
|
||||
class AlbumTitleTextField extends ConsumerWidget {
|
||||
const AlbumTitleTextField({
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import 'package:flutter/material.dart';
|
|||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
|
||||
class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
|
|
|
|||
|
|
@ -9,19 +9,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||
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/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_title.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
// ignore: must_be_immutable
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_title.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
|
||||
import 'package:immich_mobile/providers/partner.provider.dart';
|
||||
import 'package:immich_mobile/modules/partner/ui/partner_list.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
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/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/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);
|
||||
});
|
||||
|
|
@ -2,8 +2,8 @@ import 'package:auto_route/auto_route.dart';
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/archive.provider.dart';
|
||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
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/modules/asset_viewer/image_providers/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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
/// An exception for the [ImageLoader] and the Immich image providers
|
||||
class ImageLoadingException implements Exception {
|
||||
final String message;
|
||||
ImageLoadingException(this.message);
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
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/modules/asset_viewer/image_providers/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/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/modules/settings/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;
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
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/modules/asset_viewer/image_providers/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/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;
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/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/shared/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,
|
||||
),
|
||||
);
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/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;
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/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),
|
||||
)),
|
||||
);
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/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),
|
||||
);
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
final scrollToTopNotifierProvider = ScrollNotifier();
|
||||
|
||||
class ScrollNotifier with ChangeNotifier {
|
||||
void scrollToTop() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AssetDescriptionService {
|
||||
AssetDescriptionService(this._db, this._api);
|
||||
|
||||
final Isar _db;
|
||||
final ApiService _api;
|
||||
|
||||
setDescription(
|
||||
String description,
|
||||
String remoteAssetId,
|
||||
int localExifId,
|
||||
) async {
|
||||
final result = await _api.assetApi.updateAsset(
|
||||
remoteAssetId,
|
||||
UpdateAssetDto(description: description),
|
||||
);
|
||||
|
||||
if (result?.exifInfo?.description != null) {
|
||||
var exifInfo = await _db.exifInfos.get(localExifId);
|
||||
|
||||
if (exifInfo != null) {
|
||||
exifInfo.description = result!.exifInfo!.description;
|
||||
await _db.writeTxn(
|
||||
() => _db.exifInfos.put(exifInfo),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> readLatest(String assetRemoteId, int localExifId) async {
|
||||
final latestAssetFromServer =
|
||||
await _api.assetApi.getAssetInfo(assetRemoteId);
|
||||
final localExifInfo = await _db.exifInfos.get(localExifId);
|
||||
|
||||
if (latestAssetFromServer != null && localExifInfo != null) {
|
||||
localExifInfo.description =
|
||||
latestAssetFromServer.exifInfo?.description ?? '';
|
||||
|
||||
await _db.writeTxn(
|
||||
() => _db.exifInfos.put(localExifInfo),
|
||||
);
|
||||
|
||||
return localExifInfo.description!;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
final assetDescriptionServiceProvider = Provider(
|
||||
(ref) => AssetDescriptionService(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
),
|
||||
);
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AssetStackService {
|
||||
AssetStackService(this._api);
|
||||
|
||||
final ApiService _api;
|
||||
|
||||
Future<void> updateStack(
|
||||
Asset parentAsset, {
|
||||
List<Asset>? childrenToAdd,
|
||||
List<Asset>? childrenToRemove,
|
||||
}) async {
|
||||
// Guard [local asset]
|
||||
if (parentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (childrenToAdd != null) {
|
||||
final toAdd = childrenToAdd
|
||||
.where((e) => e.isRemote)
|
||||
.map((e) => e.remoteId!)
|
||||
.toList();
|
||||
|
||||
await _api.assetApi.updateAssets(
|
||||
AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId),
|
||||
);
|
||||
}
|
||||
|
||||
if (childrenToRemove != null) {
|
||||
final toRemove = childrenToRemove
|
||||
.where((e) => e.isRemote)
|
||||
.map((e) => e.remoteId!)
|
||||
.toList();
|
||||
await _api.assetApi.updateAssets(
|
||||
AssetBulkUpdateDto(ids: toRemove, removeParent: true),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("Error while updating stack children: ${error.toString()}");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateStackParent(Asset oldParent, Asset newParent) async {
|
||||
// Guard [local asset]
|
||||
if (oldParent.remoteId == null || newParent.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _api.assetApi.updateStackParent(
|
||||
UpdateStackParentDto(
|
||||
oldParentId: oldParent.remoteId!,
|
||||
newParentId: newParent.remoteId!,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint("Error while updating stack parent: ${error.toString()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final assetStackServiceProvider = Provider(
|
||||
(ref) => AssetStackService(
|
||||
ref.watch(apiServiceProvider),
|
||||
),
|
||||
);
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
final imageViewerServiceProvider =
|
||||
Provider((ref) => ImageViewerService(ref.watch(apiServiceProvider)));
|
||||
|
||||
class ImageViewerService {
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("ImageViewerService");
|
||||
|
||||
ImageViewerService(this._apiService);
|
||||
|
||||
Future<bool> downloadAssetToDevice(Asset asset) async {
|
||||
File? imageFile;
|
||||
File? videoFile;
|
||||
try {
|
||||
// Download LivePhotos image and motion part
|
||||
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
|
||||
var imageResponse =
|
||||
await _apiService.downloadApi.downloadFileWithHttpInfo(
|
||||
asset.remoteId!,
|
||||
);
|
||||
|
||||
var motionReponse =
|
||||
await _apiService.downloadApi.downloadFileWithHttpInfo(
|
||||
asset.livePhotoVideoId!,
|
||||
);
|
||||
|
||||
if (imageResponse.statusCode != 200 ||
|
||||
motionReponse.statusCode != 200) {
|
||||
final failedResponse =
|
||||
imageResponse.statusCode != 200 ? imageResponse : motionReponse;
|
||||
_log.severe(
|
||||
"Motion asset download failed",
|
||||
failedResponse.toLoggerString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
AssetEntity? entity;
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
videoFile = await File('${tempDir.path}/livephoto.mov').create();
|
||||
imageFile = await File('${tempDir.path}/livephoto.heic').create();
|
||||
videoFile.writeAsBytesSync(motionReponse.bodyBytes);
|
||||
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
|
||||
|
||||
entity = await PhotoManager.editor.darwin.saveLivePhoto(
|
||||
imageFile: imageFile,
|
||||
videoFile: videoFile,
|
||||
title: asset.fileName,
|
||||
);
|
||||
|
||||
if (entity == null) {
|
||||
_log.warning(
|
||||
"Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
|
||||
);
|
||||
|
||||
entity = await PhotoManager.editor.saveImage(
|
||||
imageResponse.bodyBytes,
|
||||
title: asset.fileName,
|
||||
);
|
||||
}
|
||||
|
||||
return entity != null;
|
||||
} else {
|
||||
var res = await _apiService.downloadApi
|
||||
.downloadFileWithHttpInfo(asset.remoteId!);
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
_log.severe("Asset download failed", res.toLoggerString());
|
||||
return false;
|
||||
}
|
||||
|
||||
final AssetEntity? entity;
|
||||
|
||||
if (asset.isImage) {
|
||||
entity = await PhotoManager.editor.saveImage(
|
||||
res.bodyBytes,
|
||||
title: asset.fileName,
|
||||
);
|
||||
} else {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
videoFile = await File('${tempDir.path}/${asset.fileName}').create();
|
||||
videoFile.writeAsBytesSync(res.bodyBytes);
|
||||
entity = await PhotoManager.editor
|
||||
.saveVideo(videoFile, title: asset.fileName);
|
||||
}
|
||||
return entity != null;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.severe("Error saving downloaded asset", error, stack);
|
||||
return false;
|
||||
} finally {
|
||||
// Clear temp files
|
||||
imageFile?.delete();
|
||||
videoFile?.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,17 +6,17 @@ import 'package:flutter/material.dart';
|
|||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/services/asset_stack.service.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/video_controls.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class BottomGalleryBar extends ConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
|
||||
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/hooks/timer_hook.dart';
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/asset_description.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_description.provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_location.d
|
|||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_people.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class ExifBottomSheet extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/asset_people.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_people.provider.dart';
|
||||
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_people_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@ import 'package:auto_route/auto_route.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/modules/trash/providers/trashed_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/trash.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
|
||||
import 'package:immich_mobile/providers/partner.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class GalleryAppBar extends ConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/activities/providers/activity_statistics.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
|
||||
class TopControlAppBar extends HookConsumerWidget {
|
||||
const TopControlAppBar({
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import 'dart:math';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
|
||||
/// The video controls for the [videPlayerControlsProvider]
|
||||
class VideoControls extends ConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -8,19 +8,19 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/bottom_gallery_bar.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/gallery_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import 'package:auto_route/auto_route.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controller_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controller_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/video_player.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
||||
|
|
|
|||
|
|
@ -1,600 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/main.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/localization.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.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/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider_ios/path_provider_ios.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final backgroundServiceProvider = Provider(
|
||||
(ref) => BackgroundService(),
|
||||
);
|
||||
|
||||
/// Background backup service
|
||||
class BackgroundService {
|
||||
static const String _portNameLock = "immichLock";
|
||||
static const MethodChannel _foregroundChannel =
|
||||
MethodChannel('immich/foregroundChannel');
|
||||
static const MethodChannel _backgroundChannel =
|
||||
MethodChannel('immich/backgroundChannel');
|
||||
static const notifyInterval = Duration(milliseconds: 400);
|
||||
bool _isBackgroundInitialized = false;
|
||||
CancellationToken? _cancellationToken;
|
||||
bool _canceledBySystem = false;
|
||||
int _wantsLockTime = 0;
|
||||
bool _hasLock = false;
|
||||
SendPort? _waitingIsolate;
|
||||
ReceivePort? _rp;
|
||||
bool _errorGracePeriodExceeded = true;
|
||||
int _uploadedAssetsCount = 0;
|
||||
int _assetsToUploadCount = 0;
|
||||
String _lastPrintedDetailContent = "";
|
||||
String? _lastPrintedDetailTitle;
|
||||
late final ThrottleProgressUpdate _throttledNotifiy =
|
||||
ThrottleProgressUpdate(_updateProgress, notifyInterval);
|
||||
late final ThrottleProgressUpdate _throttledDetailNotify =
|
||||
ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
|
||||
|
||||
bool get isBackgroundInitialized {
|
||||
return _isBackgroundInitialized;
|
||||
}
|
||||
|
||||
/// Ensures that the background service is enqueued if enabled in settings
|
||||
Future<bool> resumeServiceIfEnabled() async {
|
||||
return await isBackgroundBackupEnabled() && await enableService();
|
||||
}
|
||||
|
||||
/// Enqueues the background service
|
||||
Future<bool> enableService({bool immediate = false}) async {
|
||||
try {
|
||||
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
|
||||
final String title =
|
||||
"backup_background_service_default_notification".tr();
|
||||
final bool ok = await _foregroundChannel.invokeMethod(
|
||||
'enable',
|
||||
[callback.toRawHandle(), title, immediate, getServerUrl()],
|
||||
);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the background service
|
||||
Future<bool> configureService({
|
||||
bool requireUnmetered = true,
|
||||
bool requireCharging = false,
|
||||
int triggerUpdateDelay = 5000,
|
||||
int triggerMaxDelay = 50000,
|
||||
}) async {
|
||||
try {
|
||||
final bool ok = await _foregroundChannel.invokeMethod(
|
||||
'configure',
|
||||
[
|
||||
requireUnmetered,
|
||||
requireCharging,
|
||||
triggerUpdateDelay,
|
||||
triggerMaxDelay,
|
||||
],
|
||||
);
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the background service (if currently running) and removes it from work queue
|
||||
Future<bool> disableService() async {
|
||||
try {
|
||||
final ok = await _foregroundChannel.invokeMethod('disable');
|
||||
return ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the background service is enabled
|
||||
Future<bool> isBackgroundBackupEnabled() async {
|
||||
try {
|
||||
return await _foregroundChannel.invokeMethod("isEnabled");
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if battery optimizations are disabled
|
||||
Future<bool> isIgnoringBatteryOptimizations() async {
|
||||
// iOS does not need battery optimizations enabled
|
||||
if (Platform.isIOS) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return await _foregroundChannel
|
||||
.invokeMethod('isIgnoringBatteryOptimizations');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Yet to be implemented
|
||||
Future<Uint8List?> digestFile(String path) {
|
||||
return _foregroundChannel.invokeMethod<Uint8List>("digestFile", [path]);
|
||||
}
|
||||
|
||||
Future<List<Uint8List?>?> digestFiles(List<String> paths) {
|
||||
return _foregroundChannel.invokeListMethod<Uint8List?>(
|
||||
"digestFiles",
|
||||
paths,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates the notification shown by the background service
|
||||
Future<bool?> _updateNotification({
|
||||
String? title,
|
||||
String? content,
|
||||
int progress = 0,
|
||||
int max = 0,
|
||||
bool indeterminate = false,
|
||||
bool isDetail = false,
|
||||
bool onlyIfFG = false,
|
||||
}) async {
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return _backgroundChannel.invokeMethod<bool>(
|
||||
'updateNotification',
|
||||
[title, content, progress, max, indeterminate, isDetail, onlyIfFG],
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_updateNotification] failed to communicate with plugin");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Shows a new priority notification
|
||||
Future<bool> _showErrorNotification({
|
||||
required String title,
|
||||
String? content,
|
||||
String? individualTag,
|
||||
}) async {
|
||||
try {
|
||||
if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
|
||||
return await _backgroundChannel
|
||||
.invokeMethod('showError', [title, content, individualTag]);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_showErrorNotification] failed to communicate with plugin");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _clearErrorNotifications() async {
|
||||
try {
|
||||
if (_isBackgroundInitialized) {
|
||||
return await _backgroundChannel.invokeMethod('clearErrorNotifications');
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
"[_clearErrorNotifications] failed to communicate with plugin",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// await to ensure this thread (foreground or background) has exclusive access
|
||||
Future<bool> acquireLock() async {
|
||||
if (_hasLock) {
|
||||
debugPrint("WARNING: [acquireLock] called more than once");
|
||||
return true;
|
||||
}
|
||||
final int lockTime = Timeline.now;
|
||||
_wantsLockTime = lockTime;
|
||||
final ReceivePort rp = ReceivePort(_portNameLock);
|
||||
_rp = rp;
|
||||
final SendPort sp = rp.sendPort;
|
||||
|
||||
while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) {
|
||||
try {
|
||||
await _checkLockReleasedWithHeartbeat(lockTime);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
if (_wantsLockTime != lockTime) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_hasLock = true;
|
||||
rp.listen(_heartbeatListener);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _checkLockReleasedWithHeartbeat(final int lockTime) async {
|
||||
SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock);
|
||||
if (other != null) {
|
||||
final ReceivePort tempRp = ReceivePort();
|
||||
final SendPort tempSp = tempRp.sendPort;
|
||||
final bs = tempRp.asBroadcastStream();
|
||||
while (_wantsLockTime == lockTime) {
|
||||
other.send(tempSp);
|
||||
final dynamic answer = await bs.first
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => null);
|
||||
if (_wantsLockTime != lockTime) {
|
||||
break;
|
||||
}
|
||||
if (answer == null) {
|
||||
// other isolate failed to answer, assuming it exited without releasing the lock
|
||||
if (other == IsolateNameServer.lookupPortByName(_portNameLock)) {
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
}
|
||||
break;
|
||||
} else if (answer == true) {
|
||||
// other isolate released the lock
|
||||
break;
|
||||
} else if (answer == false) {
|
||||
// other isolate is still active
|
||||
}
|
||||
final dynamic isFinished = await bs.first
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => false);
|
||||
if (isFinished == true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tempRp.close();
|
||||
}
|
||||
}
|
||||
|
||||
void _heartbeatListener(dynamic msg) {
|
||||
if (msg is SendPort) {
|
||||
_waitingIsolate = msg;
|
||||
msg.send(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// releases the exclusive access lock
|
||||
void releaseLock() {
|
||||
_wantsLockTime = 0;
|
||||
if (_hasLock) {
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
_waitingIsolate?.send(true);
|
||||
_waitingIsolate = null;
|
||||
_hasLock = false;
|
||||
}
|
||||
_rp?.close();
|
||||
_rp = null;
|
||||
}
|
||||
|
||||
void _setupBackgroundCallHandler() {
|
||||
_backgroundChannel.setMethodCallHandler(_callHandler);
|
||||
_isBackgroundInitialized = true;
|
||||
_backgroundChannel.invokeMethod('initialized');
|
||||
}
|
||||
|
||||
Future<bool> _callHandler(MethodCall call) async {
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
if (Platform.isIOS) {
|
||||
// NOTE: I'm not sure this is strictly necessary anymore, but
|
||||
// out of an abundance of caution, we will keep it in until someone
|
||||
// can say for sure
|
||||
PathProviderIOS.registerWith();
|
||||
}
|
||||
switch (call.method) {
|
||||
case "backgroundProcessing":
|
||||
case "onAssetsChanged":
|
||||
try {
|
||||
_clearErrorNotifications();
|
||||
|
||||
// iOS should time out after some threshhold so it doesn't wait
|
||||
// indefinitely and can run later
|
||||
// Android is fine to wait here until the lock releases
|
||||
final waitForLock = Platform.isIOS
|
||||
? acquireLock().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => false,
|
||||
)
|
||||
: acquireLock();
|
||||
|
||||
final bool hasAccess = await waitForLock;
|
||||
if (!hasAccess) {
|
||||
debugPrint("[_callHandler] could not acquire lock, exiting");
|
||||
return false;
|
||||
}
|
||||
|
||||
final translationsOk = await loadTranslations();
|
||||
if (!translationsOk) {
|
||||
debugPrint("[_callHandler] could not load translations");
|
||||
}
|
||||
|
||||
final bool ok = await _onAssetsChanged();
|
||||
return ok;
|
||||
} catch (error) {
|
||||
debugPrint(error.toString());
|
||||
return false;
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
case "systemStop":
|
||||
_canceledBySystem = true;
|
||||
_cancellationToken?.cancel();
|
||||
return true;
|
||||
default:
|
||||
debugPrint("Unknown method ${call.method}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _onAssetsChanged() async {
|
||||
final Isar db = await loadDb();
|
||||
|
||||
ApiService apiService = ApiService();
|
||||
apiService.setAccessToken(Store.get(StoreKey.accessToken));
|
||||
AppSettingsService settingService = AppSettingsService();
|
||||
BackupService backupService = BackupService(apiService, db, settingService);
|
||||
AppSettingsService settingsService = AppSettingsService();
|
||||
|
||||
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
||||
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
||||
if (selectedAlbums.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await PhotoManager.setIgnorePermissionCheck(true);
|
||||
|
||||
do {
|
||||
final bool backupOk = await _runBackup(
|
||||
backupService,
|
||||
settingsService,
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
);
|
||||
if (backupOk) {
|
||||
await Store.delete(StoreKey.backupFailedSince);
|
||||
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
|
||||
backupAlbums.sortBy((e) => e.id);
|
||||
db.writeTxnSync(() {
|
||||
final dbAlbums = db.backupAlbums.where().sortById().findAllSync();
|
||||
final List<int> toDelete = [];
|
||||
final List<BackupAlbum> toUpsert = [];
|
||||
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
|
||||
diffSortedListsSync(
|
||||
dbAlbums,
|
||||
backupAlbums,
|
||||
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
||||
both: (BackupAlbum a, BackupAlbum b) {
|
||||
a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
|
||||
? a.lastBackup
|
||||
: b.lastBackup;
|
||||
toUpsert.add(a);
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
|
||||
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
|
||||
);
|
||||
db.backupAlbums.deleteAllSync(toDelete);
|
||||
db.backupAlbums.putAllSync(toUpsert);
|
||||
});
|
||||
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
|
||||
Store.put(StoreKey.backupFailedSince, DateTime.now());
|
||||
return false;
|
||||
}
|
||||
// Android should check for new assets added while performing backup
|
||||
} while (Platform.isAndroid &&
|
||||
true ==
|
||||
await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _runBackup(
|
||||
BackupService backupService,
|
||||
AppSettingsService settingsService,
|
||||
List<BackupAlbum> selectedAlbums,
|
||||
List<BackupAlbum> excludedAlbums,
|
||||
) async {
|
||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
|
||||
final bool notifyTotalProgress = settingsService
|
||||
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
|
||||
final bool notifySingleProgress = settingsService
|
||||
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
);
|
||||
|
||||
try {
|
||||
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
|
||||
} catch (e) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_connection_failed_message".tr(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_canceledBySystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toUpload.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
_assetsToUploadCount = toUpload.length;
|
||||
_uploadedAssetsCount = 0;
|
||||
_updateNotification(
|
||||
title: "backup_background_service_in_progress_notification".tr(),
|
||||
content: notifyTotalProgress
|
||||
? formatAssetBackupProgress(
|
||||
_uploadedAssetsCount,
|
||||
_assetsToUploadCount,
|
||||
)
|
||||
: null,
|
||||
progress: 0,
|
||||
max: notifyTotalProgress ? _assetsToUploadCount : 0,
|
||||
indeterminate: !notifyTotalProgress,
|
||||
onlyIfFG: !notifyTotalProgress,
|
||||
);
|
||||
|
||||
_cancellationToken = CancellationToken();
|
||||
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
|
||||
|
||||
final bool ok = await backupService.backupAsset(
|
||||
toUpload,
|
||||
_cancellationToken!,
|
||||
pmProgressHandler,
|
||||
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
|
||||
notifySingleProgress ? _onProgress : (sent, total) {},
|
||||
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
|
||||
_onBackupError,
|
||||
sortAssets: true,
|
||||
);
|
||||
if (!ok && !_cancellationToken!.isCancelled) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_error_title".tr(),
|
||||
content: "backup_background_service_backup_failed_message".tr(),
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
|
||||
_uploadedAssetsCount++;
|
||||
_throttledNotifiy();
|
||||
}
|
||||
|
||||
void _onProgress(int sent, int total) {
|
||||
_throttledDetailNotify(progress: sent, total: total);
|
||||
}
|
||||
|
||||
void _updateDetailProgress(String? title, int progress, int total) {
|
||||
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 || _lastPrintedDetailTitle != title) {
|
||||
_lastPrintedDetailContent = msg;
|
||||
_lastPrintedDetailTitle = title;
|
||||
_updateNotification(
|
||||
progress: total > 0 ? (progress * 1000) ~/ total : 0,
|
||||
max: 1000,
|
||||
isDetail: true,
|
||||
title: title,
|
||||
content: msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateProgress(String? title, int progress, int total) {
|
||||
_updateNotification(
|
||||
progress: _uploadedAssetsCount,
|
||||
max: _assetsToUploadCount,
|
||||
title: title,
|
||||
content: formatAssetBackupProgress(
|
||||
_uploadedAssetsCount,
|
||||
_assetsToUploadCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||
_showErrorNotification(
|
||||
title: "backup_background_service_upload_failure_notification"
|
||||
.tr(args: [errorAssetInfo.fileName]),
|
||||
individualTag: errorAssetInfo.id,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||
_throttledDetailNotify.title =
|
||||
"backup_background_service_current_upload_notification"
|
||||
.tr(args: [currentUploadAsset.fileName]);
|
||||
_throttledDetailNotify.progress = 0;
|
||||
_throttledDetailNotify.total = 0;
|
||||
}
|
||||
|
||||
bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
|
||||
final int value = appSettingsService
|
||||
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
||||
if (value == 0) {
|
||||
return true;
|
||||
} else if (value == 5) {
|
||||
return false;
|
||||
}
|
||||
final DateTime? failedSince = Store.tryGet(StoreKey.backupFailedSince);
|
||||
if (failedSince == null) {
|
||||
return false;
|
||||
}
|
||||
final Duration duration = DateTime.now().difference(failedSince);
|
||||
if (value == 1) {
|
||||
return duration > const Duration(minutes: 30);
|
||||
} else if (value == 2) {
|
||||
return duration > const Duration(hours: 2);
|
||||
} else if (value == 3) {
|
||||
return duration > const Duration(hours: 8);
|
||||
} else if (value == 4) {
|
||||
return duration > const Duration(hours: 24);
|
||||
}
|
||||
assert(false, "Invalid value");
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
|
||||
if (!Platform.isIOS) {
|
||||
return null;
|
||||
}
|
||||
// Seconds since last run
|
||||
final double? lastRun = task == IosBackgroundTask.fetch
|
||||
? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime')
|
||||
: await _foregroundChannel.invokeMethod('lastBackgroundProcessingTime');
|
||||
if (lastRun == null) {
|
||||
return null;
|
||||
}
|
||||
final time = DateTime.fromMillisecondsSinceEpoch(lastRun.toInt() * 1000);
|
||||
return time;
|
||||
}
|
||||
|
||||
Future<int> getIOSBackupNumberOfProcesses() async {
|
||||
if (!Platform.isIOS) {
|
||||
return 0;
|
||||
}
|
||||
return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses');
|
||||
}
|
||||
|
||||
Future<bool> getIOSBackgroundAppRefreshEnabled() async {
|
||||
if (!Platform.isIOS) {
|
||||
return false;
|
||||
}
|
||||
return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled');
|
||||
}
|
||||
}
|
||||
|
||||
enum IosBackgroundTask { fetch, processing }
|
||||
|
||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||
@pragma('vm:entry-point')
|
||||
void _nativeEntry() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
BackgroundService backgroundService = BackgroundService();
|
||||
backgroundService._setupBackgroundCallHandler();
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:easy_localization/src/asset_loader.dart';
|
||||
import 'package:easy_localization/src/easy_localization_controller.dart';
|
||||
import 'package:easy_localization/src/localization.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
|
||||
/// Workaround to manually load translations in another Isolate
|
||||
Future<bool> loadTranslations() async {
|
||||
await EasyLocalizationController.initEasyLocation();
|
||||
|
||||
final controller = EasyLocalizationController(
|
||||
supportedLocales: locales.values.toList(),
|
||||
useFallbackTranslations: true,
|
||||
saveLocale: true,
|
||||
assetLoader: const RootBundleAssetLoader(),
|
||||
path: translationsPath,
|
||||
useOnlyLangCode: false,
|
||||
onLoadError: (e) => debugPrint(e.toString()),
|
||||
fallbackLocale: locales.values.first,
|
||||
);
|
||||
|
||||
await controller.loadTranslations();
|
||||
|
||||
return Localization.load(
|
||||
controller.locale,
|
||||
translations: controller.translations,
|
||||
fallbackTranslations: controller.fallbackTranslations,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,703 +0,0 @@
|
|||
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/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/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 = [
|
||||
AppStateEnum.inactive,
|
||||
AppStateEnum.paused,
|
||||
AppStateEnum.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,
|
||||
);
|
||||
});
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup_verification.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
// 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
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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(),
|
||||
);
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/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)),
|
||||
);
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
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/modules/backup/background_service/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/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/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 != AppStateEnum.active && appState != AppStateEnum.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
|
||||
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:cancellation_token_http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final backupServiceProvider = Provider(
|
||||
(ref) => BackupService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class BackupService {
|
||||
final httpClient = http.Client();
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final Logger _log = Logger("BackupService");
|
||||
final AppSettingsService _appSetting;
|
||||
|
||||
BackupService(this._apiService, this._db, this._appSetting);
|
||||
|
||||
Future<List<String>?> getDeviceBackupAsset() async {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
try {
|
||||
return await _apiService.assetApi.getAllUserAssetsByDeviceId(deviceId);
|
||||
} catch (e) {
|
||||
debugPrint('Error [getDeviceBackupAsset] ${e.toString()}');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) {
|
||||
final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList();
|
||||
return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates));
|
||||
}
|
||||
|
||||
/// Get duplicated asset id from database
|
||||
Future<Set<String>> getDuplicatedAssetIds() async {
|
||||
final duplicates = await _db.duplicatedAssets.where().findAll();
|
||||
return duplicates.map((e) => e.id).toSet();
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
selectedAlbumsQuery() =>
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
excludedAlbumsQuery() =>
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
||||
|
||||
/// Returns all assets newer than the last successful backup per album
|
||||
Future<List<AssetEntity>> buildUploadCandidates(
|
||||
List<BackupAlbum> selectedBackupAlbums,
|
||||
List<BackupAlbum> excludedBackupAlbums,
|
||||
) async {
|
||||
final filter = FilterOptionGroup(
|
||||
containsPathModified: true,
|
||||
orders: [const OrderOption(type: OrderOptionType.updateDate)],
|
||||
// title is needed to create Assets
|
||||
imageOption: const FilterOption(needTitle: true),
|
||||
videoOption: const FilterOption(needTitle: true),
|
||||
);
|
||||
final now = DateTime.now();
|
||||
final List<AssetPathEntity?> selectedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now);
|
||||
if (selectedAlbums.every((e) => e == null)) {
|
||||
return [];
|
||||
}
|
||||
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
|
||||
if (allIdx != -1) {
|
||||
final List<AssetPathEntity?> excludedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
|
||||
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums.slice(allIdx, allIdx + 1),
|
||||
selectedBackupAlbums.slice(allIdx, allIdx + 1),
|
||||
now,
|
||||
);
|
||||
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||
excludedAlbums,
|
||||
excludedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
return toAdd.toSet().difference(toRemove.toSet()).toList();
|
||||
} else {
|
||||
return await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums,
|
||||
selectedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
||||
List<BackupAlbum> albums,
|
||||
FilterOptionGroup filter,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetPathEntity?> result = [];
|
||||
for (BackupAlbum a in albums) {
|
||||
try {
|
||||
final AssetPathEntity album =
|
||||
await AssetPathEntity.obtainPathFromProperties(
|
||||
id: a.id,
|
||||
optionGroup: filter.copyWith(
|
||||
updateTimeCond: DateTimeCond(
|
||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||
min: a.lastBackup.subtract(const Duration(seconds: 2)),
|
||||
max: now,
|
||||
),
|
||||
),
|
||||
maxDateTimeToNow: false,
|
||||
);
|
||||
result.add(album);
|
||||
} on StateError {
|
||||
// either there are no assets matching the filter criteria OR the album no longer exists
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
|
||||
List<AssetPathEntity?> albums,
|
||||
List<BackupAlbum> backupAlbums,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetEntity> result = [];
|
||||
for (int i = 0; i < albums.length; i++) {
|
||||
final AssetPathEntity? a = albums[i];
|
||||
if (a != null &&
|
||||
a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
|
||||
result.addAll(
|
||||
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
|
||||
);
|
||||
backupAlbums[i].lastBackup = now;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns a new list of assets not yet uploaded
|
||||
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
|
||||
List<AssetEntity> candidates,
|
||||
) async {
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
||||
candidates = duplicatedAssetIds.isEmpty
|
||||
? candidates
|
||||
: candidates
|
||||
.whereNot((asset) => duplicatedAssetIds.contains(asset.id))
|
||||
.toList();
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> existing = {};
|
||||
try {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final CheckExistingAssetsResponseDto? duplicates =
|
||||
await _apiService.assetApi.checkExistingAssets(
|
||||
CheckExistingAssetsDto(
|
||||
deviceAssetIds: candidates.map((e) => e.id).toList(),
|
||||
deviceId: deviceId,
|
||||
),
|
||||
);
|
||||
if (duplicates != null) {
|
||||
existing.addAll(duplicates.existingIds);
|
||||
}
|
||||
} on ApiException {
|
||||
// workaround for older server versions or when checking for too many assets at once
|
||||
final List<String>? allAssetsInDatabase = await getDeviceBackupAsset();
|
||||
if (allAssetsInDatabase != null) {
|
||||
existing.addAll(allAssetsInDatabase);
|
||||
}
|
||||
}
|
||||
return existing.isEmpty
|
||||
? candidates
|
||||
: candidates.whereNot((e) => existing.contains(e.id)).toList();
|
||||
}
|
||||
|
||||
Future<bool> backupAsset(
|
||||
Iterable<AssetEntity> assetList,
|
||||
http.CancellationToken cancelToken,
|
||||
PMProgressHandler? pmProgressHandler,
|
||||
Function(String, String, bool) uploadSuccessCb,
|
||||
Function(int, int) uploadProgressCb,
|
||||
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
||||
Function(ErrorUploadAsset) errorCb, {
|
||||
bool sortAssets = false,
|
||||
}) async {
|
||||
final bool isIgnoreIcloudAssets =
|
||||
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
|
||||
|
||||
if (Platform.isAndroid &&
|
||||
!(await Permission.accessMediaLocation.status).isGranted) {
|
||||
// double check that permission is granted here, to guard against
|
||||
// uploading corrupt assets without EXIF information
|
||||
_log.warning("Media location permission is not granted. "
|
||||
"Cannot access original assets for backup.");
|
||||
return false;
|
||||
}
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
bool anyErrors = false;
|
||||
final List<String> duplicatedAssetIds = [];
|
||||
|
||||
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
||||
if (Platform.isIOS) {
|
||||
await PhotoManager.requestPermissionExtend();
|
||||
}
|
||||
|
||||
List<AssetEntity> assetsToUpload = sortAssets
|
||||
// Upload images before video assets
|
||||
// these are further sorted by using their creation date
|
||||
? assetList.sorted(
|
||||
(a, b) {
|
||||
final cmp = a.typeInt - b.typeInt;
|
||||
if (cmp != 0) return cmp;
|
||||
return a.createDateTime.compareTo(b.createDateTime);
|
||||
},
|
||||
)
|
||||
: assetList.toList();
|
||||
|
||||
for (var entity in assetsToUpload) {
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
|
||||
try {
|
||||
final isAvailableLocally =
|
||||
await entity.isLocallyAvailable(isOrigin: true);
|
||||
|
||||
// Handle getting files from iCloud
|
||||
if (!isAvailableLocally && Platform.isIOS) {
|
||||
// Skip iCloud assets if the user has disabled this feature
|
||||
if (isIgnoreIcloudAssets) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setCurrentUploadAssetCb(
|
||||
CurrentUploadAsset(
|
||||
id: entity.id,
|
||||
fileCreatedAt: entity.createDateTime.year == 1970
|
||||
? entity.modifiedDateTime
|
||||
: entity.createDateTime,
|
||||
fileName: await entity.titleAsync,
|
||||
fileType: _getAssetType(entity.type),
|
||||
iCloudAsset: true,
|
||||
),
|
||||
);
|
||||
|
||||
file = await entity.loadFile(progressHandler: pmProgressHandler);
|
||||
livePhotoFile = await entity.loadFile(
|
||||
withSubtype: true,
|
||||
progressHandler: pmProgressHandler,
|
||||
);
|
||||
} else {
|
||||
if (entity.type == AssetType.video) {
|
||||
file = await entity.originFile;
|
||||
} else {
|
||||
file = await entity.originFile.timeout(const Duration(seconds: 5));
|
||||
if (entity.isLivePhoto) {
|
||||
livePhotoFile = await entity.originFileWithSubtype
|
||||
.timeout(const Duration(seconds: 5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
String originalFileName = await entity.titleAsync;
|
||||
var fileStream = file.openRead();
|
||||
var assetRawUploadData = http.MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
file.lengthSync(),
|
||||
filename: originalFileName,
|
||||
);
|
||||
|
||||
var req = MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$savedEndpoint/asset/upload'),
|
||||
onProgress: ((bytes, totalBytes) =>
|
||||
uploadProgressCb(bytes, totalBytes)),
|
||||
);
|
||||
req.headers["x-immich-user-token"] = Store.get(StoreKey.accessToken);
|
||||
req.headers["Transfer-Encoding"] = "chunked";
|
||||
|
||||
req.fields['deviceAssetId'] = entity.id;
|
||||
req.fields['deviceId'] = deviceId;
|
||||
req.fields['fileCreatedAt'] =
|
||||
entity.createDateTime.toUtc().toIso8601String();
|
||||
req.fields['fileModifiedAt'] =
|
||||
entity.modifiedDateTime.toUtc().toIso8601String();
|
||||
req.fields['isFavorite'] = entity.isFavorite.toString();
|
||||
req.fields['duration'] = entity.videoDuration.toString();
|
||||
|
||||
req.files.add(assetRawUploadData);
|
||||
|
||||
var fileSize = file.lengthSync();
|
||||
|
||||
if (entity.isLivePhoto) {
|
||||
if (livePhotoFile != null) {
|
||||
final livePhotoTitle = p.setExtension(
|
||||
originalFileName,
|
||||
p.extension(livePhotoFile.path),
|
||||
);
|
||||
final fileStream = livePhotoFile.openRead();
|
||||
final livePhotoRawUploadData = http.MultipartFile(
|
||||
"livePhotoData",
|
||||
fileStream,
|
||||
livePhotoFile.lengthSync(),
|
||||
filename: livePhotoTitle,
|
||||
);
|
||||
req.files.add(livePhotoRawUploadData);
|
||||
fileSize += livePhotoFile.lengthSync();
|
||||
} else {
|
||||
_log.warning(
|
||||
"Failed to obtain motion part of the livePhoto - $originalFileName",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentUploadAssetCb(
|
||||
CurrentUploadAsset(
|
||||
id: entity.id,
|
||||
fileCreatedAt: entity.createDateTime.year == 1970
|
||||
? entity.modifiedDateTime
|
||||
: entity.createDateTime,
|
||||
fileName: originalFileName,
|
||||
fileType: _getAssetType(entity.type),
|
||||
fileSize: fileSize,
|
||||
iCloudAsset: false,
|
||||
),
|
||||
);
|
||||
|
||||
var response =
|
||||
await httpClient.send(req, cancellationToken: cancelToken);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// asset is a duplicate (already exists on the server)
|
||||
duplicatedAssetIds.add(entity.id);
|
||||
uploadSuccessCb(entity.id, deviceId, true);
|
||||
} else if (response.statusCode == 201) {
|
||||
// stored a new asset on the server
|
||||
uploadSuccessCb(entity.id, deviceId, false);
|
||||
} else {
|
||||
var data = await response.stream.bytesToString();
|
||||
var error = jsonDecode(data);
|
||||
var errorMessage = error['message'] ?? error['error'];
|
||||
|
||||
debugPrint(
|
||||
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
|
||||
);
|
||||
|
||||
errorCb(
|
||||
ErrorUploadAsset(
|
||||
asset: entity,
|
||||
id: entity.id,
|
||||
fileCreatedAt: entity.createDateTime,
|
||||
fileName: originalFileName,
|
||||
fileType: _getAssetType(entity.type),
|
||||
errorMessage: errorMessage,
|
||||
),
|
||||
);
|
||||
|
||||
if (errorMessage == "Quota has been exceeded!") {
|
||||
anyErrors = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} on http.CancelledException {
|
||||
debugPrint("Backup was cancelled by the user");
|
||||
anyErrors = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
debugPrint("ERROR backupAsset: ${e.toString()}");
|
||||
anyErrors = true;
|
||||
continue;
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await file?.delete();
|
||||
await livePhotoFile?.delete();
|
||||
} catch (e) {
|
||||
debugPrint("ERROR deleting file: ${e.toString()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicatedAssetIds.isNotEmpty) {
|
||||
await _saveDuplicatedAssetIds(duplicatedAssetIds);
|
||||
}
|
||||
return !anyErrors;
|
||||
}
|
||||
|
||||
String _getAssetType(AssetType assetType) {
|
||||
switch (assetType) {
|
||||
case AssetType.audio:
|
||||
return "AUDIO";
|
||||
case AssetType.image:
|
||||
return "IMAGE";
|
||||
case AssetType.video:
|
||||
return "VIDEO";
|
||||
case AssetType.other:
|
||||
return "OTHER";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MultipartRequest extends http.MultipartRequest {
|
||||
/// Creates a new [MultipartRequest].
|
||||
MultipartRequest(
|
||||
super.method,
|
||||
super.url, {
|
||||
required this.onProgress,
|
||||
});
|
||||
|
||||
final void Function(int bytes, int totalBytes) onProgress;
|
||||
|
||||
/// Freezes all mutable fields and returns a
|
||||
/// single-subscription [http.ByteStream]
|
||||
/// that will emit the request body.
|
||||
@override
|
||||
http.ByteStream finalize() {
|
||||
final byteStream = super.finalize();
|
||||
|
||||
final total = contentLength;
|
||||
var bytes = 0;
|
||||
|
||||
final t = StreamTransformer.fromHandlers(
|
||||
handleData: (List<int> data, EventSink<List<int>> sink) {
|
||||
bytes += data.length;
|
||||
onProgress.call(bytes, total);
|
||||
sink.add(data);
|
||||
},
|
||||
);
|
||||
final stream = byteStream.transform(t);
|
||||
return http.ByteStream(stream);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:photo_manager/photo_manager.dart' show PhotoManager;
|
||||
|
||||
/// Finds duplicates originating from missing EXIF information
|
||||
class BackupVerificationService {
|
||||
final Isar _db;
|
||||
|
||||
BackupVerificationService(this._db);
|
||||
|
||||
/// Returns at most [limit] assets that were backed up without exif
|
||||
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
|
||||
final owner = Store.get(StoreKey.currentUser).isarId;
|
||||
final List<Asset> onlyLocal = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(owner)
|
||||
.localIdIsNotNull()
|
||||
.findAll();
|
||||
final List<Asset> remoteMatches = await _getMatches(
|
||||
_db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(),
|
||||
owner,
|
||||
onlyLocal,
|
||||
limit,
|
||||
);
|
||||
final List<Asset> localMatches = await _getMatches(
|
||||
_db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(),
|
||||
owner,
|
||||
remoteMatches,
|
||||
limit,
|
||||
);
|
||||
|
||||
final List<Asset> deleteCandidates = [], originals = [];
|
||||
|
||||
await diffSortedLists(
|
||||
remoteMatches,
|
||||
localMatches,
|
||||
compare: (a, b) => a.fileName.compareTo(b.fileName),
|
||||
both: (a, b) async {
|
||||
a.exifInfo = await _db.exifInfos.get(a.id);
|
||||
deleteCandidates.add(a);
|
||||
originals.add(b);
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (a) {},
|
||||
onlySecond: (b) {},
|
||||
);
|
||||
final isolateToken = ServicesBinding.rootIsolateToken!;
|
||||
final List<Asset> toDelete;
|
||||
if (deleteCandidates.length > 10) {
|
||||
// performs 2 checks in parallel for a nice speedup
|
||||
final half = deleteCandidates.length ~/ 2;
|
||||
final lower = compute(
|
||||
_computeSaveToDelete,
|
||||
(
|
||||
deleteCandidates: deleteCandidates.slice(0, half),
|
||||
originals: originals.slice(0, half),
|
||||
auth: Store.get(StoreKey.accessToken),
|
||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||
rootIsolateToken: isolateToken,
|
||||
),
|
||||
);
|
||||
final upper = compute(
|
||||
_computeSaveToDelete,
|
||||
(
|
||||
deleteCandidates: deleteCandidates.slice(half),
|
||||
originals: originals.slice(half),
|
||||
auth: Store.get(StoreKey.accessToken),
|
||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||
rootIsolateToken: isolateToken,
|
||||
),
|
||||
);
|
||||
toDelete = await lower + await upper;
|
||||
} else {
|
||||
toDelete = await compute(
|
||||
_computeSaveToDelete,
|
||||
(
|
||||
deleteCandidates: deleteCandidates,
|
||||
originals: originals,
|
||||
auth: Store.get(StoreKey.accessToken),
|
||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||
rootIsolateToken: isolateToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
return toDelete;
|
||||
}
|
||||
|
||||
static Future<List<Asset>> _computeSaveToDelete(
|
||||
({
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> originals,
|
||||
String auth,
|
||||
String endpoint,
|
||||
RootIsolateToken rootIsolateToken,
|
||||
}) tuple,
|
||||
) async {
|
||||
assert(tuple.deleteCandidates.length == tuple.originals.length);
|
||||
final List<Asset> result = [];
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
|
||||
await PhotoManager.setIgnorePermissionCheck(true);
|
||||
final ApiService apiService = ApiService();
|
||||
apiService.setEndpoint(tuple.endpoint);
|
||||
apiService.setAccessToken(tuple.auth);
|
||||
for (int i = 0; i < tuple.deleteCandidates.length; i++) {
|
||||
if (await _compareAssets(
|
||||
tuple.deleteCandidates[i],
|
||||
tuple.originals[i],
|
||||
apiService,
|
||||
)) {
|
||||
result.add(tuple.deleteCandidates[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<bool> _compareAssets(
|
||||
Asset remote,
|
||||
Asset local,
|
||||
ApiService apiService,
|
||||
) async {
|
||||
if (remote.checksum == local.checksum) return false;
|
||||
ExifInfo? exif = remote.exifInfo;
|
||||
if (exif != null && exif.lat != null) return false;
|
||||
if (exif == null || exif.fileSize == null) {
|
||||
final dto = await apiService.assetApi.getAssetInfo(remote.remoteId!);
|
||||
if (dto != null && dto.exifInfo != null) {
|
||||
exif = ExifInfo.fromDto(dto.exifInfo!);
|
||||
}
|
||||
}
|
||||
final file = await local.local!.originFile;
|
||||
if (exif != null && file != null && exif.fileSize != null) {
|
||||
final origSize = await file.length();
|
||||
if (exif.fileSize! == origSize || exif.fileSize! != origSize) {
|
||||
final latLng = await local.local!.latlngAsync();
|
||||
|
||||
if (exif.lat == null &&
|
||||
latLng.latitude != null &&
|
||||
(remote.fileCreatedAt.isAtSameMomentAs(local.fileCreatedAt) ||
|
||||
remote.fileModifiedAt.isAtSameMomentAs(local.fileModifiedAt) ||
|
||||
_sameExceptTimeZone(
|
||||
remote.fileCreatedAt,
|
||||
local.fileCreatedAt,
|
||||
))) {
|
||||
if (remote.type == AssetType.video) {
|
||||
// it's very unlikely that a video of same length, filesize, name
|
||||
// and date is wrong match. Cannot easily compare videos anyway
|
||||
return true;
|
||||
}
|
||||
|
||||
// for images: make sure they are pixel-wise identical
|
||||
// (skip first few KBs containing metadata)
|
||||
final Uint64List localImage =
|
||||
_fakeDecodeImg(local, await file.readAsBytes());
|
||||
final res = await apiService.downloadApi
|
||||
.downloadFileWithHttpInfo(remote.remoteId!);
|
||||
final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);
|
||||
|
||||
final eq = const ListEquality().equals(remoteImage, localImage);
|
||||
return eq;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Uint64List _fakeDecodeImg(Asset asset, Uint8List bytes) {
|
||||
const headerLength = 131072; // assume header is at most 128 KB
|
||||
final start = bytes.length < headerLength * 2
|
||||
? (bytes.length ~/ (4 * 8)) * 8
|
||||
: headerLength;
|
||||
return bytes.buffer.asUint64List(start);
|
||||
}
|
||||
|
||||
static Future<List<Asset>> _getMatches(
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
|
||||
int ownerId,
|
||||
List<Asset> assets,
|
||||
int limit,
|
||||
) =>
|
||||
query
|
||||
.ownerIdEqualTo(ownerId)
|
||||
.anyOf(
|
||||
assets,
|
||||
(q, Asset a) => q
|
||||
.fileNameEqualTo(a.fileName)
|
||||
.and()
|
||||
.durationInSecondsEqualTo(a.durationInSeconds)
|
||||
.and()
|
||||
.fileCreatedAtBetween(
|
||||
a.fileCreatedAt.subtract(const Duration(hours: 12)),
|
||||
a.fileCreatedAt.add(const Duration(hours: 12)),
|
||||
)
|
||||
.and()
|
||||
.not()
|
||||
.checksumEqualTo(a.checksum),
|
||||
)
|
||||
.sortByFileName()
|
||||
.thenByFileCreatedAt()
|
||||
.thenByFileModifiedAt()
|
||||
.limit(limit)
|
||||
.findAll();
|
||||
|
||||
static bool _sameExceptTimeZone(DateTime a, DateTime b) {
|
||||
final ms = a.isAfter(b)
|
||||
? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
|
||||
: b.millisecondsSinceEpoch - a.microsecondsSinceEpoch;
|
||||
final x = ms / (1000 * 60 * 30);
|
||||
final y = ms ~/ (1000 * 60 * 30);
|
||||
return y.toDouble() == x && y < 24;
|
||||
}
|
||||
}
|
||||
|
||||
final backupVerificationServiceProvider = Provider(
|
||||
(ref) => BackupVerificationService(
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
|
|
@ -5,9 +5,9 @@ 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/models/backup/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class AlbumInfoCard extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ 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/models/backup/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class AlbumInfoListTile extends HookConsumerWidget {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.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/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// This is a simple debug widget which should be removed later on when we are
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/album_info_list_tile.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/error_backup_list.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/modules/backup/ui/current_backup_asset_info_box.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
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/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/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);
|
||||
});
|
||||
|
|
@ -2,8 +2,8 @@ import 'package:auto_route/auto_route.dart';
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final multiselectProvider = StateProvider((ref) {
|
||||
return false;
|
||||
});
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
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/shared/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))),
|
||||
);
|
||||
|
|
@ -3,9 +3,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
|
||||
class GroupDividerTitle extends HookConsumerWidget {
|
||||
const GroupDividerTitle({
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||
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/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import 'package:flutter/rendering.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_drag_region.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
|
||||
|
|
@ -18,9 +18,9 @@ import 'package:fluttertoast/fluttertoast.dart';
|
|||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_to_date_notifier.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/tab.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
import 'asset_grid_data_structure.dart';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
|
||||
import 'package:immich_mobile/utils/storage_indicator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/models/asset_selection_state.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue