mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/custom-date-range
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
abstract interface class IDatabaseRepository {
|
||||
Future<T> transaction<T>(Future<T> Function() callback);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ enum AlbumUserRole {
|
||||
// do not change this order!
|
||||
editor,
|
||||
viewer,
|
||||
owner,
|
||||
}
|
||||
|
||||
// Model for an album stored in the server
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
|
||||
part 'local_asset.model.dart';
|
||||
part 'remote_asset.model.dart';
|
||||
|
||||
@@ -11,6 +13,10 @@ enum AssetType {
|
||||
|
||||
enum AssetState { local, remote, merged }
|
||||
|
||||
// do not change!
|
||||
// keep in sync with PlatformAssetPlaybackStyle
|
||||
enum AssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
sealed class BaseAsset {
|
||||
final String name;
|
||||
final String? checksum;
|
||||
@@ -19,7 +25,7 @@ sealed class BaseAsset {
|
||||
final DateTime updatedAt;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? durationInSeconds;
|
||||
final int? durationMs;
|
||||
final bool isFavorite;
|
||||
final String? livePhotoVideoId;
|
||||
final bool isEdited;
|
||||
@@ -32,7 +38,7 @@ sealed class BaseAsset {
|
||||
required this.updatedAt,
|
||||
this.width,
|
||||
this.height,
|
||||
this.durationInSeconds,
|
||||
this.durationMs,
|
||||
this.isFavorite = false,
|
||||
this.livePhotoVideoId,
|
||||
required this.isEdited,
|
||||
@@ -42,11 +48,22 @@ sealed class BaseAsset {
|
||||
bool get isVideo => type == AssetType.video;
|
||||
|
||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
|
||||
|
||||
AssetPlaybackStyle get playbackStyle {
|
||||
if (isVideo) return AssetPlaybackStyle.video;
|
||||
if (isMotionPhoto) return AssetPlaybackStyle.livePhoto;
|
||||
if (isImage && durationMs != null && durationMs! > 0) {
|
||||
return AssetPlaybackStyle.imageAnimated;
|
||||
}
|
||||
if (isImage) return AssetPlaybackStyle.image;
|
||||
return AssetPlaybackStyle.unknown;
|
||||
}
|
||||
|
||||
Duration get duration {
|
||||
final durationInSeconds = this.durationInSeconds;
|
||||
if (durationInSeconds != null) {
|
||||
return Duration(seconds: durationInSeconds);
|
||||
final durationMs = this.durationMs;
|
||||
if (durationMs != null) {
|
||||
return Duration(milliseconds: durationMs);
|
||||
}
|
||||
return const Duration();
|
||||
}
|
||||
@@ -56,6 +73,8 @@ sealed class BaseAsset {
|
||||
bool get isLocalOnly => storage == AssetState.local;
|
||||
bool get isRemoteOnly => storage == AssetState.remote;
|
||||
|
||||
bool get isEditable => false;
|
||||
|
||||
// Overridden in subclasses
|
||||
AssetState get storage;
|
||||
String? get localId;
|
||||
@@ -71,7 +90,7 @@ sealed class BaseAsset {
|
||||
updatedAt: $updatedAt,
|
||||
width: ${width ?? "<NA>"},
|
||||
height: ${height ?? "<NA>"},
|
||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||
durationMs: ${durationMs ?? "<NA>"},
|
||||
isFavorite: $isFavorite,
|
||||
isEdited: $isEdited,
|
||||
}''';
|
||||
@@ -87,7 +106,7 @@ sealed class BaseAsset {
|
||||
updatedAt == other.updatedAt &&
|
||||
width == other.width &&
|
||||
height == other.height &&
|
||||
durationInSeconds == other.durationInSeconds &&
|
||||
durationMs == other.durationMs &&
|
||||
isFavorite == other.isFavorite &&
|
||||
isEdited == other.isEdited;
|
||||
}
|
||||
@@ -102,7 +121,7 @@ sealed class BaseAsset {
|
||||
updatedAt.hashCode ^
|
||||
width.hashCode ^
|
||||
height.hashCode ^
|
||||
durationInSeconds.hashCode ^
|
||||
durationMs.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
isEdited.hashCode;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ class LocalAsset extends BaseAsset {
|
||||
final String? remoteAssetId;
|
||||
final String? cloudId;
|
||||
final int orientation;
|
||||
@override
|
||||
final AssetPlaybackStyle playbackStyle;
|
||||
|
||||
final DateTime? adjustmentTime;
|
||||
final double? latitude;
|
||||
@@ -21,10 +23,11 @@ class LocalAsset extends BaseAsset {
|
||||
required super.updatedAt,
|
||||
super.width,
|
||||
super.height,
|
||||
super.durationInSeconds,
|
||||
super.durationMs,
|
||||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
required this.playbackStyle,
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
@@ -55,7 +58,8 @@ class LocalAsset extends BaseAsset {
|
||||
updatedAt: $updatedAt,
|
||||
width: ${width ?? "<NA>"},
|
||||
height: ${height ?? "<NA>"},
|
||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||
durationMs: ${durationMs ?? "<NA>"},
|
||||
playbackStyle: $playbackStyle,
|
||||
remoteId: ${remoteId ?? "<NA>"},
|
||||
cloudId: ${cloudId ?? "<NA>"},
|
||||
checksum: ${checksum ?? "<NA>"},
|
||||
@@ -76,6 +80,7 @@ class LocalAsset extends BaseAsset {
|
||||
id == other.id &&
|
||||
cloudId == other.cloudId &&
|
||||
orientation == other.orientation &&
|
||||
playbackStyle == other.playbackStyle &&
|
||||
adjustmentTime == other.adjustmentTime &&
|
||||
latitude == other.latitude &&
|
||||
longitude == other.longitude;
|
||||
@@ -87,6 +92,7 @@ class LocalAsset extends BaseAsset {
|
||||
id.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
orientation.hashCode ^
|
||||
playbackStyle.hashCode ^
|
||||
adjustmentTime.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode;
|
||||
@@ -102,9 +108,10 @@ class LocalAsset extends BaseAsset {
|
||||
DateTime? updatedAt,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationInSeconds,
|
||||
int? durationMs,
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
AssetPlaybackStyle? playbackStyle,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
@@ -121,9 +128,10 @@ class LocalAsset extends BaseAsset {
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
|
||||
@@ -22,7 +22,7 @@ class RemoteAsset extends BaseAsset {
|
||||
required super.updatedAt,
|
||||
super.width,
|
||||
super.height,
|
||||
super.durationInSeconds,
|
||||
super.durationMs,
|
||||
super.isFavorite = false,
|
||||
this.thumbHash,
|
||||
this.visibility = AssetVisibility.timeline,
|
||||
@@ -43,6 +43,9 @@ class RemoteAsset extends BaseAsset {
|
||||
@override
|
||||
String get heroTag => '${localId ?? checksum}_$id';
|
||||
|
||||
@override
|
||||
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
@@ -54,7 +57,7 @@ class RemoteAsset extends BaseAsset {
|
||||
updatedAt: $updatedAt,
|
||||
width: ${width ?? "<NA>"},
|
||||
height: ${height ?? "<NA>"},
|
||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||
durationMs: ${durationMs ?? "<NA>"},
|
||||
localId: ${localId ?? "<NA>"},
|
||||
isFavorite: $isFavorite,
|
||||
thumbHash: ${thumbHash ?? "<NA>"},
|
||||
@@ -99,7 +102,7 @@ class RemoteAsset extends BaseAsset {
|
||||
DateTime? updatedAt,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationInSeconds,
|
||||
int? durationMs,
|
||||
bool? isFavorite,
|
||||
String? thumbHash,
|
||||
AssetVisibility? visibility,
|
||||
@@ -118,7 +121,7 @@ class RemoteAsset extends BaseAsset {
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
thumbHash: thumbHash ?? this.thumbHash,
|
||||
visibility: visibility ?? this.visibility,
|
||||
@@ -128,3 +131,81 @@ class RemoteAsset extends BaseAsset {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteAssetExif extends RemoteAsset {
|
||||
final ExifInfo exifInfo;
|
||||
|
||||
const RemoteAssetExif({
|
||||
required super.id,
|
||||
super.localId,
|
||||
required super.name,
|
||||
required super.ownerId,
|
||||
required super.checksum,
|
||||
required super.type,
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
super.width,
|
||||
super.height,
|
||||
super.durationMs,
|
||||
super.isFavorite = false,
|
||||
super.thumbHash,
|
||||
super.visibility = AssetVisibility.timeline,
|
||||
super.livePhotoVideoId,
|
||||
super.stackId,
|
||||
super.isEdited = false,
|
||||
this.exifInfo = const ExifInfo(),
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! RemoteAssetExif) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return super == other && exifInfo == other.exifInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => super.hashCode ^ exifInfo.hashCode;
|
||||
|
||||
@override
|
||||
RemoteAssetExif copyWith({
|
||||
String? id,
|
||||
String? localId,
|
||||
String? name,
|
||||
String? ownerId,
|
||||
String? checksum,
|
||||
AssetType? type,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationMs,
|
||||
bool? isFavorite,
|
||||
String? thumbHash,
|
||||
AssetVisibility? visibility,
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
ExifInfo? exifInfo,
|
||||
}) {
|
||||
return RemoteAssetExif(
|
||||
id: id ?? this.id,
|
||||
localId: localId ?? this.localId,
|
||||
name: name ?? this.name,
|
||||
ownerId: ownerId ?? this.ownerId,
|
||||
checksum: checksum ?? this.checksum,
|
||||
type: type ?? this.type,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
thumbHash: thumbHash ?? this.thumbHash,
|
||||
visibility: visibility ?? this.visibility,
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
exifInfo: exifInfo ?? this.exifInfo, // Use the new parameter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import "package:openapi/api.dart" show CropParameters, RotateParameters, MirrorParameters;
|
||||
|
||||
enum AssetEditAction { rotate, crop, mirror, other }
|
||||
|
||||
sealed class AssetEdit {
|
||||
const AssetEdit();
|
||||
}
|
||||
|
||||
class CropEdit extends AssetEdit {
|
||||
final CropParameters parameters;
|
||||
|
||||
const CropEdit(this.parameters);
|
||||
}
|
||||
|
||||
class RotateEdit extends AssetEdit {
|
||||
final RotateParameters parameters;
|
||||
|
||||
const RotateEdit(this.parameters);
|
||||
}
|
||||
|
||||
class MirrorEdit extends AssetEdit {
|
||||
final MirrorParameters parameters;
|
||||
|
||||
const MirrorEdit(this.parameters);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
|
||||
class AppConfig {
|
||||
final ThemeConfig theme;
|
||||
|
||||
const AppConfig({this.theme = const ThemeConfig()});
|
||||
|
||||
AppConfig copyWith({ThemeConfig? theme}) => .new(theme: theme ?? this.theme);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is AppConfig && other.theme == theme);
|
||||
|
||||
@override
|
||||
int get hashCode => theme.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'AppConfig(theme: $theme)';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
class SystemConfig {
|
||||
final LogLevel logLevel;
|
||||
|
||||
const SystemConfig({this.logLevel = .info});
|
||||
|
||||
SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel);
|
||||
|
||||
@override
|
||||
int get hashCode => logLevel.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel)';
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
|
||||
class ThemeConfig {
|
||||
final ThemeMode mode;
|
||||
final ImmichColorPreset primaryColor;
|
||||
final bool dynamicTheme;
|
||||
final bool colorfulInterface;
|
||||
|
||||
const ThemeConfig({
|
||||
this.mode = .system,
|
||||
this.primaryColor = .indigo,
|
||||
this.dynamicTheme = false,
|
||||
this.colorfulInterface = true,
|
||||
});
|
||||
|
||||
ThemeConfig copyWith({
|
||||
ThemeMode? mode,
|
||||
ImmichColorPreset? primaryColor,
|
||||
bool? dynamicTheme,
|
||||
bool? colorfulInterface,
|
||||
}) => .new(
|
||||
mode: mode ?? this.mode,
|
||||
primaryColor: primaryColor ?? this.primaryColor,
|
||||
dynamicTheme: dynamicTheme ?? this.dynamicTheme,
|
||||
colorfulInterface: colorfulInterface ?? this.colorfulInterface,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is ThemeConfig &&
|
||||
other.mode == mode &&
|
||||
other.primaryColor == primaryColor &&
|
||||
other.dynamicTheme == dynamicTheme &&
|
||||
other.colorfulInterface == colorfulInterface);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(mode, primaryColor, dynamicTheme, colorfulInterface);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ThemeConfig(mode: $mode, primaryColor: $primaryColor, dynamicTheme: $dynamicTheme, colorfulInterface: $colorfulInterface)';
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class DeviceAsset {
|
||||
final String assetId;
|
||||
final Uint8List hash;
|
||||
final DateTime modifiedTime;
|
||||
|
||||
const DeviceAsset({required this.assetId, required this.hash, required this.modifiedTime});
|
||||
|
||||
@override
|
||||
bool operator ==(covariant DeviceAsset other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.assetId == assetId && other.hash == hash && other.modifiedTime == modifiedTime;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return assetId.hashCode ^ hash.hashCode ^ modifiedTime.hashCode;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DeviceAsset(assetId: $assetId, hash: $hash, modifiedTime: $modifiedTime)';
|
||||
}
|
||||
|
||||
DeviceAsset copyWith({String? assetId, Uint8List? hash, DateTime? modifiedTime}) {
|
||||
return DeviceAsset(
|
||||
assetId: assetId ?? this.assetId,
|
||||
hash: hash ?? this.hash,
|
||||
modifiedTime: modifiedTime ?? this.modifiedTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ class ExifInfo {
|
||||
final String? timeZone;
|
||||
final DateTime? dateTimeOriginal;
|
||||
final int? rating;
|
||||
final int? width;
|
||||
final int? height;
|
||||
|
||||
// GPS
|
||||
final double? latitude;
|
||||
@@ -27,7 +29,7 @@ class ExifInfo {
|
||||
bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0;
|
||||
|
||||
String get exposureTime {
|
||||
if (exposureSeconds == null) {
|
||||
if (exposureSeconds == null || exposureSeconds! <= 0 || exposureSeconds!.isNaN) {
|
||||
return "";
|
||||
}
|
||||
if (exposureSeconds! < 1) {
|
||||
@@ -48,6 +50,8 @@ class ExifInfo {
|
||||
this.timeZone,
|
||||
this.dateTimeOriginal,
|
||||
this.rating,
|
||||
this.width,
|
||||
this.height,
|
||||
this.isFlipped = false,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
@@ -74,6 +78,8 @@ class ExifInfo {
|
||||
other.timeZone == timeZone &&
|
||||
other.dateTimeOriginal == dateTimeOriginal &&
|
||||
other.rating == rating &&
|
||||
other.width == width &&
|
||||
other.height == height &&
|
||||
other.latitude == latitude &&
|
||||
other.longitude == longitude &&
|
||||
other.city == city &&
|
||||
@@ -98,6 +104,8 @@ class ExifInfo {
|
||||
timeZone.hashCode ^
|
||||
dateTimeOriginal.hashCode ^
|
||||
rating.hashCode ^
|
||||
width.hashCode ^
|
||||
height.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode ^
|
||||
city.hashCode ^
|
||||
@@ -123,6 +131,8 @@ isFlipped: $isFlipped,
|
||||
timeZone: ${timeZone ?? 'NA'},
|
||||
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
||||
rating: ${rating ?? 'NA'},
|
||||
width: ${width ?? 'NA'},
|
||||
height: ${height ?? 'NA'},
|
||||
latitude: ${latitude ?? 'NA'},
|
||||
longitude: ${longitude ?? 'NA'},
|
||||
city: ${city ?? 'NA'},
|
||||
@@ -146,6 +156,8 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
||||
String? timeZone,
|
||||
DateTime? dateTimeOriginal,
|
||||
int? rating,
|
||||
int? width,
|
||||
int? height,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
@@ -168,6 +180,8 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
||||
timeZone: timeZone ?? this.timeZone,
|
||||
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
||||
rating: rating ?? this.rating,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
isFlipped: isFlipped ?? this.isFlipped,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/system_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
enum MetadataDomain<T extends Object> {
|
||||
appConfig<AppConfig>('config.app'),
|
||||
systemConfig<SystemConfig>('config.system');
|
||||
|
||||
final String prefix;
|
||||
const MetadataDomain(this.prefix);
|
||||
}
|
||||
|
||||
enum MetadataKey<T extends Object> {
|
||||
// Theme
|
||||
primaryColor<ImmichColorPreset>(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)),
|
||||
dynamicTheme<bool>(.appConfig, 'theme.dynamicTheme', false),
|
||||
colorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values));
|
||||
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
final T defaultValue;
|
||||
final _MetadataCodec<T>? _codecOverride;
|
||||
|
||||
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]);
|
||||
|
||||
String get key => '${domain.prefix}.$name';
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw) ?? defaultValue;
|
||||
|
||||
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
|
||||
}
|
||||
|
||||
sealed class _MetadataCodec<T extends Object> {
|
||||
const _MetadataCodec();
|
||||
|
||||
String encode(T value);
|
||||
T? decode(String raw);
|
||||
|
||||
static const Map<Type, _MetadataCodec<Object>> _primitives = {
|
||||
int: _PrimitiveCodec.integer,
|
||||
double: _PrimitiveCodec.real,
|
||||
bool: _PrimitiveCodec.boolean,
|
||||
String: _PrimitiveCodec.string,
|
||||
DateTime: _DateTimeCodec(),
|
||||
};
|
||||
|
||||
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) {
|
||||
final codec = _primitives[sample.runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError(
|
||||
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
|
||||
);
|
||||
}
|
||||
return codec as _MetadataCodec<T>;
|
||||
}
|
||||
}
|
||||
|
||||
final class _EnumCodec<T extends Enum> extends _MetadataCodec<T> {
|
||||
final List<T> values;
|
||||
|
||||
const _EnumCodec(this.values);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
const _DateTimeCodec();
|
||||
|
||||
@override
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime? decode(String raw) => DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
|
||||
final T? Function(String) _parse;
|
||||
|
||||
const _PrimitiveCodec._(this._parse);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T? decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = _PrimitiveCodec<int>._(int.tryParse);
|
||||
static const real = _PrimitiveCodec<double>._(double.tryParse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse);
|
||||
static const string = _PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String? _identity(String s) => s;
|
||||
}
|
||||
@@ -3,30 +3,21 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
class SearchResult {
|
||||
final List<BaseAsset> assets;
|
||||
final double scrollOffset;
|
||||
final int? nextPage;
|
||||
|
||||
const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
|
||||
|
||||
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
|
||||
return SearchResult(
|
||||
assets: assets ?? this.assets,
|
||||
nextPage: nextPage ?? this.nextPage,
|
||||
scrollOffset: scrollOffset ?? this.scrollOffset,
|
||||
);
|
||||
}
|
||||
const SearchResult({required this.assets, this.nextPage});
|
||||
|
||||
@override
|
||||
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
|
||||
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant SearchResult other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
|
||||
return listEquals(other.assets, assets) && other.nextPage == nextPage;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
|
||||
int get hashCode => assets.hashCode ^ nextPage.hashCode;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ enum StoreKey<T> {
|
||||
// user settings from [AppSettingsEnum] below:
|
||||
loadPreview<bool>._(100),
|
||||
loadOriginal<bool>._(101),
|
||||
themeMode<String>._(102),
|
||||
tilesPerRow<int>._(103),
|
||||
dynamicLayout<bool>._(104),
|
||||
groupAssetsBy<int>._(105),
|
||||
@@ -35,7 +34,6 @@ enum StoreKey<T> {
|
||||
albumThumbnailCacheSize<int>._(112),
|
||||
selectedAlbumSortOrder<int>._(113),
|
||||
advancedTroubleshooting<bool>._(114),
|
||||
logLevel<int>._(115),
|
||||
preferRemoteImage<bool>._(116),
|
||||
loopVideo<bool>._(117),
|
||||
// map related settings
|
||||
@@ -50,11 +48,6 @@ enum StoreKey<T> {
|
||||
enableHapticFeedback<bool>._(126),
|
||||
customHeaders<String>._(127),
|
||||
|
||||
// theme settings
|
||||
primaryColor<String>._(128),
|
||||
dynamicTheme<bool>._(129),
|
||||
colorfulInterface<bool>._(130),
|
||||
|
||||
syncAlbums<bool>._(131),
|
||||
|
||||
// Auto endpoint switching
|
||||
@@ -98,7 +91,14 @@ enum StoreKey<T> {
|
||||
cleanupCutoffDaysAgo<int>._(1011),
|
||||
cleanupDefaultsInitialized<bool>._(1012),
|
||||
|
||||
syncMigrationStatus<String>._(1013);
|
||||
syncMigrationStatus<String>._(1013),
|
||||
|
||||
// Legacy keys that have been migrated to the new metadata store
|
||||
legacyPrimaryColor<String>._(128),
|
||||
legacyDynamicTheme<bool>._(129),
|
||||
legacyColorfulInterface<bool>._(130),
|
||||
legacyThemeMode<String>._(102),
|
||||
legacyLogLevel<int>._(115);
|
||||
|
||||
const StoreKey._(this.id);
|
||||
final int id;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
|
||||
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
|
||||
|
||||
class AssetService {
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
@@ -58,49 +55,6 @@ class AssetService {
|
||||
return _remoteAssetRepository.getExif(id);
|
||||
}
|
||||
|
||||
Future<double> getAspectRatio(BaseAsset asset) async {
|
||||
final dimension = asset is LocalAsset
|
||||
? await _getLocalAssetDimensions(asset)
|
||||
: await _getRemoteAssetDimensions(asset as RemoteAsset);
|
||||
|
||||
if (dimension.width == null || dimension.height == null || dimension.height == 0) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
|
||||
}
|
||||
|
||||
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
|
||||
double? width = asset.width?.toDouble();
|
||||
double? height = asset.height?.toDouble();
|
||||
int orientation = asset.orientation;
|
||||
|
||||
if (width == null || height == null) {
|
||||
final fetched = await _localAssetRepository.get(asset.id);
|
||||
width = fetched?.width?.toDouble();
|
||||
height = fetched?.height?.toDouble();
|
||||
orientation = fetched?.orientation ?? 0;
|
||||
}
|
||||
|
||||
// On Android, local assets need orientation correction for 90°/270° rotations
|
||||
// On iOS, the Photos framework pre-corrects dimensions
|
||||
final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
|
||||
return (width: width, height: height, isFlipped: isFlipped);
|
||||
}
|
||||
|
||||
Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
|
||||
double? width = asset.width?.toDouble();
|
||||
double? height = asset.height?.toDouble();
|
||||
|
||||
if (width == null || height == null) {
|
||||
final fetched = await _remoteAssetRepository.get(asset.id);
|
||||
width = fetched?.width?.toDouble();
|
||||
height = fetched?.height?.toDouble();
|
||||
}
|
||||
|
||||
return (width: width, height: height, isFlipped: false);
|
||||
}
|
||||
|
||||
Future<List<(String, String)>> getPlaces(String userId) {
|
||||
return _remoteAssetRepository.getPlaces(userId);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
@@ -17,20 +16,16 @@ import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||
import 'package:immich_mobile/wm_executor.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class BackgroundWorkerFgService {
|
||||
@@ -60,27 +55,19 @@ class BackgroundWorkerFgService {
|
||||
|
||||
class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
ProviderContainer? _ref;
|
||||
final Isar _isar;
|
||||
final Drift _drift;
|
||||
final DriftLogger _driftLogger;
|
||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||
final CancellationToken _cancellationToken = CancellationToken();
|
||||
final _cancellationToken = Completer<void>();
|
||||
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
BackgroundWorkerBgService({required Isar isar, required Drift drift, required DriftLogger driftLogger})
|
||||
: _isar = isar,
|
||||
_drift = drift,
|
||||
BackgroundWorkerBgService({required Drift drift, required DriftLogger driftLogger})
|
||||
: _drift = drift,
|
||||
_driftLogger = driftLogger,
|
||||
_backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||
_ref = ProviderContainer(
|
||||
overrides: [
|
||||
dbProvider.overrideWithValue(isar),
|
||||
isarProvider.overrideWithValue(isar),
|
||||
driftProvider.overrideWith(driftOverride(drift)),
|
||||
],
|
||||
);
|
||||
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(drift))]);
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
@@ -88,8 +75,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
HttpSSLOptions.apply();
|
||||
|
||||
await Future.wait(
|
||||
[
|
||||
loadTranslations(),
|
||||
@@ -106,7 +91,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
),
|
||||
FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false),
|
||||
FileDownloader().trackTasks(),
|
||||
_ref?.read(fileMediaRepositoryProvider).enableBackgroundAccess(),
|
||||
].nonNulls,
|
||||
);
|
||||
|
||||
@@ -192,15 +176,8 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
||||
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
||||
|
||||
await _drift.close();
|
||||
await _driftLogger.close();
|
||||
|
||||
_ref?.dispose();
|
||||
_ref = null;
|
||||
|
||||
_cancellationToken.cancel();
|
||||
_logger.info("Cleaning up background worker");
|
||||
|
||||
_cancellationToken.complete();
|
||||
final cleanupFutures = [
|
||||
nativeSyncApi?.cancelHashing(),
|
||||
workerManagerPatch.dispose().catchError((_) async {
|
||||
@@ -211,13 +188,15 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
Store.dispose(),
|
||||
|
||||
backgroundSyncManager?.cancel(),
|
||||
_drift.optimize(allTables: true),
|
||||
];
|
||||
|
||||
if (_isar.isOpen) {
|
||||
cleanupFutures.add(_isar.close());
|
||||
}
|
||||
await Future.wait(cleanupFutures.nonNulls);
|
||||
_logger.info("Background worker resources cleaned up");
|
||||
await _drift.close();
|
||||
await _driftLogger.close();
|
||||
|
||||
_ref?.dispose();
|
||||
_ref = null;
|
||||
} catch (error, stack) {
|
||||
dPrint(() => 'Failed to cleanup background worker: $error with stack: $stack');
|
||||
}
|
||||
@@ -305,7 +284,6 @@ Future<void> backgroundSyncNativeEntrypoint() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
final (isar, drift, logDB) = await Bootstrap.initDB();
|
||||
await Bootstrap.initDomain(isar, drift, logDB, shouldBufferLogs: false, listenStoreUpdates: false);
|
||||
await BackgroundWorkerBgService(isar: isar, drift: drift, driftLogger: logDB).init();
|
||||
final (drift, logDB) = await Bootstrap.initDomain(shouldBufferLogs: false, listenStoreUpdates: false);
|
||||
await BackgroundWorkerBgService(drift: drift, driftLogger: logDB).init();
|
||||
}
|
||||
|
||||
@@ -337,7 +337,7 @@ class LocalSyncService {
|
||||
a.createdAt.isAtSameMomentAs(b.createdAt) &&
|
||||
a.width == b.width &&
|
||||
a.height == b.height &&
|
||||
a.durationInSeconds == b.durationInSeconds;
|
||||
a.durationMs == b.durationMs;
|
||||
}
|
||||
|
||||
final firstAdjustment = a.adjustmentTime?.millisecondsSinceEpoch ?? 0;
|
||||
@@ -346,7 +346,7 @@ class LocalSyncService {
|
||||
a.createdAt.isAtSameMomentAs(b.createdAt) &&
|
||||
a.width == b.width &&
|
||||
a.height == b.height &&
|
||||
a.durationInSeconds == b.durationInSeconds &&
|
||||
a.durationMs == b.durationMs &&
|
||||
a.latitude == b.latitude &&
|
||||
a.longitude == b.longitude;
|
||||
}
|
||||
@@ -432,12 +432,22 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
updatedAt: tryFromSecondsSinceEpoch(updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
||||
width: width,
|
||||
height: height,
|
||||
durationInSeconds: durationInSeconds,
|
||||
durationMs: durationMs,
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
playbackStyle: _toPlaybackStyle(playbackStyle),
|
||||
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
||||
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
||||
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
||||
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
|
||||
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
|
||||
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
|
||||
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
|
||||
};
|
||||
|
||||
@@ -2,20 +2,20 @@ import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Service responsible for handling application logging.
|
||||
///
|
||||
/// It listens to Dart's [Logger.root], buffers logs in memory (optionally),
|
||||
/// writes them to a persistent [ILogRepository], and manages log levels
|
||||
/// via [IStoreRepository]
|
||||
/// writes them to a persistent [LogRepository], and manages log levels via
|
||||
/// [MetadataRepository].
|
||||
class LogService {
|
||||
final LogRepository _logRepository;
|
||||
final IStoreRepository _storeRepository;
|
||||
final MetadataRepository _metadataRepository;
|
||||
|
||||
final List<LogMessage> _msgBuffer = [];
|
||||
|
||||
@@ -38,12 +38,12 @@ class LogService {
|
||||
|
||||
static Future<LogService> init({
|
||||
required LogRepository logRepository,
|
||||
required IStoreRepository storeRepository,
|
||||
required MetadataRepository metadataRepository,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
_instance ??= await create(
|
||||
logRepository: logRepository,
|
||||
storeRepository: storeRepository,
|
||||
metadataRepository: metadataRepository,
|
||||
shouldBuffer: shouldBuffer,
|
||||
);
|
||||
return _instance!;
|
||||
@@ -51,17 +51,17 @@ class LogService {
|
||||
|
||||
static Future<LogService> create({
|
||||
required LogRepository logRepository,
|
||||
required IStoreRepository storeRepository,
|
||||
required MetadataRepository metadataRepository,
|
||||
bool shouldBuffer = true,
|
||||
}) async {
|
||||
final instance = LogService._(logRepository, storeRepository, shouldBuffer);
|
||||
final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
|
||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||
final level = await instance._storeRepository.tryGet(StoreKey.logLevel) ?? LogLevel.info.index;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO;
|
||||
final level = instance._metadataRepository.systemConfig.logLevel;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
||||
return instance;
|
||||
}
|
||||
|
||||
LogService._(this._logRepository, this._storeRepository, this._shouldBuffer) {
|
||||
LogService._(this._logRepository, this._metadataRepository, this._shouldBuffer) {
|
||||
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class LogService {
|
||||
}
|
||||
|
||||
Future<void> setLogLevel(LogLevel level) async {
|
||||
await _storeRepository.upsert(StoreKey.logLevel, level.index);
|
||||
await _metadataRepository.write(MetadataKey.logLevel, level);
|
||||
Logger.root.level = level.toLevel();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
|
||||
class RemoteAlbumService {
|
||||
final DriftRemoteAlbumRepository _repository;
|
||||
@@ -28,10 +28,6 @@ class RemoteAlbumService {
|
||||
return _repository.get(albumId);
|
||||
}
|
||||
|
||||
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
|
||||
return _repository.getByName(albumName, ownerId);
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> sortAlbums(
|
||||
List<RemoteAlbum> albums,
|
||||
AlbumSortMode sortMode, {
|
||||
@@ -86,8 +82,18 @@ class RemoteAlbumService {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async {
|
||||
final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds);
|
||||
Future<RemoteAlbum> createAlbum({
|
||||
required String title,
|
||||
required UserDto owner,
|
||||
required List<String> assetIds,
|
||||
String? description,
|
||||
}) async {
|
||||
final album = await _albumApiRepository.createDriftAlbum(
|
||||
title,
|
||||
owner,
|
||||
description: description,
|
||||
assetIds: assetIds,
|
||||
);
|
||||
await _repository.create(album, assetIds);
|
||||
|
||||
return album;
|
||||
@@ -101,8 +107,10 @@ class RemoteAlbumService {
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
}) async {
|
||||
final owner = await _repository.getOwner(albumId);
|
||||
final updatedAlbum = await _albumApiRepository.updateAlbum(
|
||||
albumId,
|
||||
owner,
|
||||
name: name,
|
||||
description: description,
|
||||
thumbnailAssetId: thumbnailAssetId,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/search_result.model.dart';
|
||||
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart' as api show AssetVisibility;
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
|
||||
class SearchService {
|
||||
@@ -52,42 +51,3 @@ class SearchService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetResponseDto {
|
||||
RemoteAsset toDto() {
|
||||
return RemoteAsset(
|
||||
id: id,
|
||||
name: originalFileName,
|
||||
checksum: checksum,
|
||||
createdAt: fileCreatedAt,
|
||||
updatedAt: updatedAt,
|
||||
ownerId: ownerId,
|
||||
visibility: switch (visibility) {
|
||||
api.AssetVisibility.timeline => AssetVisibility.timeline,
|
||||
api.AssetVisibility.hidden => AssetVisibility.hidden,
|
||||
api.AssetVisibility.archive => AssetVisibility.archive,
|
||||
api.AssetVisibility.locked => AssetVisibility.locked,
|
||||
_ => AssetVisibility.timeline,
|
||||
},
|
||||
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
||||
height: exifInfo?.exifImageHeight?.toInt(),
|
||||
width: exifInfo?.exifImageWidth?.toInt(),
|
||||
isFavorite: isFavorite,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
thumbHash: thumbhash,
|
||||
localId: null,
|
||||
type: type.toAssetType(),
|
||||
isEdited: isEdited,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetTypeEnum {
|
||||
AssetType toAssetType() => switch (this) {
|
||||
AssetTypeEnum.IMAGE => AssetType.image,
|
||||
AssetTypeEnum.VIDEO => AssetType.video,
|
||||
AssetTypeEnum.AUDIO => AssetType.audio,
|
||||
AssetTypeEnum.OTHER => AssetType.other,
|
||||
_ => throw Exception('Unknown AssetType value: $this'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
|
||||
/// Provides access to a persistent key-value store with an in-memory cache.
|
||||
/// Listens for repository changes to keep the cache updated.
|
||||
class StoreService {
|
||||
final IStoreRepository _storeRepository;
|
||||
final DriftStoreRepository _storeRepository;
|
||||
|
||||
/// In-memory cache. Keys are [StoreKey.id]
|
||||
final Map<int, Object?> _cache = {};
|
||||
StreamSubscription<List<StoreDto>>? _storeUpdateSubscription;
|
||||
|
||||
StoreService._({required IStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository;
|
||||
StoreService._({required DriftStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository;
|
||||
|
||||
// TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider
|
||||
static StoreService? _instance;
|
||||
@@ -24,12 +24,12 @@ class StoreService {
|
||||
}
|
||||
|
||||
// TODO: Replace the implementation with the one from create after removing the typedef
|
||||
static Future<StoreService> init({required IStoreRepository storeRepository, bool listenUpdates = true}) async {
|
||||
static Future<StoreService> init({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async {
|
||||
_instance ??= await create(storeRepository: storeRepository, listenUpdates: listenUpdates);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static Future<StoreService> create({required IStoreRepository storeRepository, bool listenUpdates = true}) async {
|
||||
static Future<StoreService> create({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async {
|
||||
final instance = StoreService._(isarStoreRepository: storeRepository);
|
||||
await instance.populateCache();
|
||||
if (listenUpdates) {
|
||||
@@ -91,8 +91,6 @@ class StoreService {
|
||||
await _storeRepository.deleteAll();
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
bool get isBetaTimelineEnabled => tryGet(StoreKey.betaTimeline) ?? true;
|
||||
}
|
||||
|
||||
class StoreKeyNotFoundException implements Exception {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -12,6 +15,7 @@ final syncLinkedAlbumServiceProvider = Provider(
|
||||
ref.watch(localAlbumRepository),
|
||||
ref.watch(remoteAlbumRepository),
|
||||
ref.watch(driftAlbumApiRepositoryProvider),
|
||||
ref.watch(storeServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,8 +23,14 @@ class SyncLinkedAlbumService {
|
||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||
final DriftRemoteAlbumRepository _remoteAlbumRepository;
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final StoreService _storeService;
|
||||
|
||||
SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository);
|
||||
SyncLinkedAlbumService(
|
||||
this._localAlbumRepository,
|
||||
this._remoteAlbumRepository,
|
||||
this._albumApiRepository,
|
||||
this._storeService,
|
||||
);
|
||||
|
||||
final _log = Logger("SyncLinkedAlbumService");
|
||||
|
||||
@@ -103,7 +113,11 @@ class SyncLinkedAlbumService {
|
||||
/// Creates a new remote album and links it to the local album
|
||||
Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async {
|
||||
dPrint(() => "Creating new remote album for local album: ${localAlbum.name}");
|
||||
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, assetIds: []);
|
||||
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(
|
||||
localAlbum.name,
|
||||
_storeService.get(StoreKey.currentUser),
|
||||
assetIds: [],
|
||||
);
|
||||
await _remoteAlbumRepository.create(newRemoteAlbum, []);
|
||||
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@@ -192,27 +193,40 @@ class SyncStreamService {
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
|
||||
await _applyRemoteRestoreToLocal();
|
||||
} else {
|
||||
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
}
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
return;
|
||||
case SyncEntityType.assetV2:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV2>();
|
||||
await _syncStreamRepository.updateAssetsV2(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList());
|
||||
}
|
||||
return;
|
||||
case SyncEntityType.assetDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
||||
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList());
|
||||
}
|
||||
return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets);
|
||||
case SyncEntityType.assetExifV1:
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||
case SyncEntityType.assetEditV1:
|
||||
return _syncStreamRepository.updateAssetEditsV1(data.cast());
|
||||
case SyncEntityType.assetEditDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetEditsV1(data.cast());
|
||||
case SyncEntityType.assetMetadataV1:
|
||||
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
|
||||
case SyncEntityType.assetMetadataDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
|
||||
case SyncEntityType.partnerAssetV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
|
||||
case SyncEntityType.partnerAssetV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner');
|
||||
case SyncEntityType.partnerAssetBackfillV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner backfill');
|
||||
case SyncEntityType.partnerAssetBackfillV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner backfill');
|
||||
case SyncEntityType.partnerAssetDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetsV1(data.cast(), debugLabel: "partner");
|
||||
case SyncEntityType.partnerAssetExifV1:
|
||||
@@ -221,6 +235,8 @@ class SyncStreamService {
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'partner backfill');
|
||||
case SyncEntityType.albumV1:
|
||||
return _syncStreamRepository.updateAlbumsV1(data.cast());
|
||||
case SyncEntityType.albumV2:
|
||||
return _syncStreamRepository.updateAlbumsV2(data.cast());
|
||||
case SyncEntityType.albumDeleteV1:
|
||||
return _syncStreamRepository.deleteAlbumsV1(data.cast());
|
||||
case SyncEntityType.albumUserV1:
|
||||
@@ -231,10 +247,16 @@ class SyncStreamService {
|
||||
return _syncStreamRepository.deleteAlbumUsersV1(data.cast());
|
||||
case SyncEntityType.albumAssetCreateV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset create');
|
||||
case SyncEntityType.albumAssetCreateV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset create');
|
||||
case SyncEntityType.albumAssetUpdateV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset update');
|
||||
case SyncEntityType.albumAssetUpdateV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset update');
|
||||
case SyncEntityType.albumAssetBackfillV1:
|
||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset backfill');
|
||||
case SyncEntityType.albumAssetBackfillV2:
|
||||
return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset backfill');
|
||||
case SyncEntityType.albumAssetExifCreateV1:
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif create');
|
||||
case SyncEntityType.albumAssetExifUpdateV1:
|
||||
@@ -336,12 +358,13 @@ class SyncStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
|
||||
Future<void> handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) async {
|
||||
if (batchData.isEmpty) return;
|
||||
|
||||
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
|
||||
_logger.info('Processing batch of ${batchData.length} AssetUploadReadyV2 events');
|
||||
|
||||
final List<SyncAssetV1> assets = [];
|
||||
final List<SyncAssetV2> assets = [];
|
||||
final List<SyncAssetExifV1> exifs = [];
|
||||
|
||||
try {
|
||||
for (final data in batchData) {
|
||||
@@ -351,49 +374,132 @@ class SyncStreamService {
|
||||
|
||||
final payload = data;
|
||||
final assetData = payload['asset'];
|
||||
final exifData = payload['exif'];
|
||||
|
||||
if (assetData == null) {
|
||||
if (assetData == null || exifData == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final asset = SyncAssetV1.fromJson(assetData);
|
||||
final asset = SyncAssetV2.fromJson(assetData);
|
||||
final exif = SyncAssetExifV1.fromJson(exifData);
|
||||
|
||||
if (asset != null) {
|
||||
if (asset != null && exif != null) {
|
||||
assets.add(asset);
|
||||
exifs.add(exif);
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.isNotEmpty) {
|
||||
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
|
||||
_logger.info('Successfully processed ${assets.length} edited assets');
|
||||
if (assets.isNotEmpty && exifs.isNotEmpty) {
|
||||
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch');
|
||||
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
|
||||
_logger.info('Successfully processed ${assets.length} assets in batch');
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
|
||||
_logger.severe("Error processing AssetUploadReadyV2 websocket batch events", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
|
||||
if (checksums.isEmpty) {
|
||||
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
|
||||
_logger.info('Processing AssetEditReadyV1 event');
|
||||
|
||||
try {
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ArgumentError("Invalid data format for AssetEditReadyV1 event");
|
||||
}
|
||||
|
||||
final payload = data;
|
||||
|
||||
if (payload['asset'] == null) {
|
||||
throw ArgumentError("Missing 'asset' field in AssetEditReadyV1 event data");
|
||||
}
|
||||
|
||||
final asset = SyncAssetV1.fromJson(payload['asset']);
|
||||
if (asset == null) {
|
||||
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV1 event data");
|
||||
}
|
||||
|
||||
List<SyncAssetEditV1> assetEdits = [];
|
||||
|
||||
// Edits are only send on v2.6.0+
|
||||
if (payload['edit'] != null && payload['edit'] is List<dynamic>) {
|
||||
assetEdits = (payload['edit'] as List<dynamic>)
|
||||
.map((e) => SyncAssetEditV1.fromJson(e))
|
||||
.whereType<SyncAssetEditV1>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
|
||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||
|
||||
_logger.info(
|
||||
'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits',
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe("Error processing AssetEditReadyV1 websocket event", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleWsAssetEditReadyV2(dynamic data) async {
|
||||
_logger.info('Processing AssetEditReadyV2 event');
|
||||
|
||||
try {
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ArgumentError("Invalid data format for AssetEditReadyV2 event");
|
||||
}
|
||||
|
||||
final payload = data;
|
||||
|
||||
if (payload['asset'] == null) {
|
||||
throw ArgumentError("Missing 'asset' field in AssetEditReadyV2 event data");
|
||||
}
|
||||
|
||||
final asset = SyncAssetV2.fromJson(payload['asset']);
|
||||
if (asset == null) {
|
||||
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV2 event data");
|
||||
}
|
||||
|
||||
final assetEdits = (payload['edit'] as List<dynamic>)
|
||||
.map((e) => SyncAssetEditV1.fromJson(e))
|
||||
.whereType<SyncAssetEditV1>()
|
||||
.toList();
|
||||
|
||||
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
|
||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||
|
||||
_logger.info(
|
||||
'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits',
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe("Error processing AssetEditReadyV2 websocket event", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return Future.value();
|
||||
} else {
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
}
|
||||
await _trashLocalAssets(localAssetsToTrash);
|
||||
} else {
|
||||
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
|
||||
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyRemoteRestoreToLocal() async {
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
@@ -403,4 +509,23 @@ class SyncStreamService {
|
||||
_logger.info("No remote assets found for restoration");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
await _handleRemoteDeleted(remoteIds);
|
||||
await _applyRemoteRestoreToLocal();
|
||||
}
|
||||
|
||||
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
await _handleRemoteDeleted(remoteIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ enum TimelineOrigin {
|
||||
search,
|
||||
deepLink,
|
||||
albumActivities,
|
||||
folder,
|
||||
}
|
||||
|
||||
class TimelineFactory {
|
||||
@@ -78,6 +79,9 @@ class TimelineFactory {
|
||||
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssets(assets, type));
|
||||
|
||||
TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));
|
||||
|
||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||
|
||||
@@ -112,7 +116,7 @@ class TimelineService {
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
_buffer.clear();
|
||||
_buffer = [];
|
||||
} else {
|
||||
final int offset;
|
||||
final int count;
|
||||
|
||||
@@ -4,23 +4,17 @@ import 'dart:typed_data';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class UserService {
|
||||
final Logger _log = Logger("UserService");
|
||||
final IsarUserRepository _isarUserRepository;
|
||||
final UserApiRepository _userApiRepository;
|
||||
final StoreService _storeService;
|
||||
|
||||
UserService({
|
||||
required IsarUserRepository isarUserRepository,
|
||||
required UserApiRepository userApiRepository,
|
||||
required StoreService storeService,
|
||||
}) : _isarUserRepository = isarUserRepository,
|
||||
_userApiRepository = userApiRepository,
|
||||
_storeService = storeService;
|
||||
UserService({required UserApiRepository userApiRepository, required StoreService storeService})
|
||||
: _userApiRepository = userApiRepository,
|
||||
_storeService = storeService;
|
||||
|
||||
UserDto getMyUser() {
|
||||
return _storeService.get(StoreKey.currentUser);
|
||||
@@ -38,7 +32,6 @@ class UserService {
|
||||
final user = await _userApiRepository.getMyUser();
|
||||
if (user == null) return null;
|
||||
await _storeService.put(StoreKey.currentUser, user);
|
||||
await _isarUserRepository.update(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -47,19 +40,10 @@ class UserService {
|
||||
final path = await _userApiRepository.createProfileImage(name: name, data: image);
|
||||
final updatedUser = getMyUser();
|
||||
await _storeService.put(StoreKey.currentUser, updatedUser);
|
||||
await _isarUserRepository.update(updatedUser);
|
||||
return path;
|
||||
} catch (e) {
|
||||
_log.warning("Failed to upload profile image", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<UserDto>> getAll() async {
|
||||
return await _isarUserRepository.getAll();
|
||||
}
|
||||
|
||||
Future<void> deleteAll() {
|
||||
return _isarUserRepository.deleteAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketBatch(List<dynamic> batchData) {
|
||||
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
}
|
||||
@@ -196,11 +196,31 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
|
||||
Future<void> syncWebsocketBatchV2(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
}
|
||||
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
|
||||
_syncWebsocketTask = _handleWsAssetUploadReadyV2Batch(batchData);
|
||||
return _syncWebsocketTask!.whenComplete(() {
|
||||
_syncWebsocketTask = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketEditV1(dynamic data) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
}
|
||||
_syncWebsocketTask = _handleWsAssetEditReadyV1(data);
|
||||
return _syncWebsocketTask!.whenComplete(() {
|
||||
_syncWebsocketTask = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketEditV2(dynamic data) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
}
|
||||
_syncWebsocketTask = _handleWsAssetEditReadyV2(data);
|
||||
return _syncWebsocketTask!.whenComplete(() {
|
||||
_syncWebsocketTask = null;
|
||||
});
|
||||
@@ -242,7 +262,17 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
|
||||
debugLabel: 'websocket-batch',
|
||||
);
|
||||
|
||||
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
|
||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
|
||||
Cancelable<void> _handleWsAssetUploadReadyV2Batch(List<dynamic> batchData) => runInIsolateGentle(
|
||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV2Batch(batchData),
|
||||
debugLabel: 'websocket-batch',
|
||||
);
|
||||
|
||||
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
|
||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
|
||||
debugLabel: 'websocket-edit',
|
||||
);
|
||||
|
||||
Cancelable<void> _handleWsAssetEditReadyV2(dynamic data) => runInIsolateGentle(
|
||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV2(data),
|
||||
debugLabel: 'websocket-edit',
|
||||
);
|
||||
|
||||
@@ -80,12 +80,14 @@ Future<void> _processCloudIdMappingsInBatches(
|
||||
AssetMetadataBulkUpsertItemDto(
|
||||
assetId: mapping.remoteAssetId,
|
||||
key: kMobileMetadataKey,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: mapping.localAsset.cloudId,
|
||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
||||
latitude: mapping.localAsset.latitude?.toString(),
|
||||
longitude: mapping.localAsset.longitude?.toString(),
|
||||
value: Map<String, Object>.from(
|
||||
RemoteAssetMobileAppMetadata(
|
||||
cloudId: mapping.localAsset.cloudId,
|
||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
||||
latitude: mapping.localAsset.latitude?.toString(),
|
||||
longitude: mapping.localAsset.longitude?.toString(),
|
||||
).toJson(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user