Compare commits

..

9 Commits

Author SHA1 Message Date
Santo Shakil 26e535fb75 use a page scoped future provider for the loading state 2026-06-12 11:19:53 +06:00
Santo Shakil 039a855db1 fix(mobile): endless spinner on album selection when device has no albums 2026-06-11 23:13:40 +06:00
Alex aa6af7ce36 chore: workflow trigger i18n (#28992) 2026-06-11 10:55:18 -05:00
Santo Shakil 59d036a2ed fix(mobile): give android notification channels proper names (#28986) 2026-06-11 15:07:37 +00:00
renovate[bot] 7a5c014558 fix(deps): update typescript-projects (#28627)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-06-11 17:02:54 +02:00
Santo Shakil e2954b6411 fix(mobile): show albums whose assets are all trashed (#28985) 2026-06-11 09:41:02 -05:00
renovate[bot] 0fb18ed241 chore(deps): update dependency commander to v15 (#28936) 2026-06-11 12:18:25 +02:00
renovate[bot] c0b3b08ce6 chore(deps): update exiftool to v35.21.0 (#28933) 2026-06-11 12:16:13 +02:00
Mees Frensel e8a1084e5b fix(web): heatmap layout and date formatting (#28976)
* fix(web): heatmap layout and date formatting

* chore

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2026-06-11 08:36:34 +00:00
82 changed files with 1933 additions and 31376 deletions
+1 -1
View File
@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
wrangler = "4.91.0"
wrangler = "4.98.0"
+2
View File
@@ -2389,6 +2389,8 @@
"trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF of an asset is extracted",
"trigger_asset_uploaded": "Asset Upload",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kicks off the workflow",
+14 -14
View File
@@ -83,7 +83,7 @@ version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
@@ -217,37 +217,37 @@ checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c70773
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
[[tools.pnpm]]
version = "11.4.0"
version = "11.5.2"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:cc38ebd5b2610a5744f84576b963c49e6609a8df5aed714ae3de749998d4478c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-arm64.tar.gz"
checksum = "sha256:7fef0c74081135d777754fccf25272f698e504b26ba0568504846c0cea402f8f"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-arm64.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:a1e2ec9123c709fd04b704227cfcf3b50cd2bbbc1bd39d2df414530b5697eb75"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-arm64-musl.tar.gz"
checksum = "sha256:843beed7bca760276d29f8950ca219600995d345dbc93fad8150b3e5f83b74d4"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-arm64-musl.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:f3f8d1217eef013bbc71a24d52efb1f1041e4aff55edd80e0b08e25f409305a4"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-x64.tar.gz"
checksum = "sha256:2033a702618c8576dc6bb0f6adb3a67ab506031351ddd59ca50d1bcaf5d13dc5"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-x64.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:60010ad00a96b71e20d1618acaca7a71395e710cbd5e88946c030a1d07c56916"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-x64-musl.tar.gz"
checksum = "sha256:0b794b23461c7475f7ffc29c4945692838b51ddadd857a2ace8edf0018798305"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-linux-x64-musl.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:ba59014c2c1ce8b76af9f559385206a2623de4ff2b694b5c91598a8f44abb4e2"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-darwin-arm64.tar.gz"
checksum = "sha256:54993dae26bea0f3c1b0e15f9427f6f6a86827d56f32d1d1554d8cda59a62399"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-darwin-arm64.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:84ce90e38bc0b1164173eb853a0fbffc7edcb050cb0d5c8ce4ca609f5c808e0a"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-win32-x64.zip"
checksum = "sha256:b3ddff2c2bf87d3996fadf074bac58cd2259f718a17912a04ae930e3775b30e9"
url = "https://github.com/pnpm/pnpm/releases/download/v11.5.2/pnpm-win32-x64.zip"
provenance = "github-attestations"
[[tools.terragrunt]]
+1 -1
View File
@@ -16,7 +16,7 @@ config_roots = [
[tools]
node = "24.15.0"
pnpm = "11.4.0"
pnpm = "11.5.2"
terragrunt = "1.0.3"
opentofu = "1.11.6"
"npm:oazapfts" = "7.5.0"
@@ -69,7 +69,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
val notificationChannel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_ID,
ctx.getString(R.string.background_worker_notification_channel_name),
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(notificationChannel)
@@ -5,4 +5,9 @@
<string name="memory_widget_description">See memories from Immich.</string>
<string name="random_widget_description">View a random image from your library or a specific album.</string>
<string name="bg_downloader_notification_channel_name">Uploads and downloads</string>
<string name="bg_downloader_notification_channel_description">Progress updates for uploads and downloads</string>
<string name="background_worker_notification_channel_name">Background backup</string>
</resources>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -40,7 +40,7 @@ void main() {
tearDown(() async {
await workerManagerPatch.dispose();
await server.close();
await Store.delete(StoreKey.legacyServerEndpoint);
await Store.delete(StoreKey.serverEndpoint);
await Store.delete(StoreKey.syncMigrationStatus);
});
@@ -119,9 +119,7 @@ void main() {
final releaseTxn = Completer<void>();
final txnHeld = Completer<void>();
final txn = drift.transaction(() async {
await drift
.into(drift.userEntity)
.insert(
await drift.into(drift.userEntity).insert(
UserEntityCompanion.insert(
id: 'holder',
name: 'holder',
@@ -101,7 +101,7 @@ class AppConfig {
String toString() =>
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share)';
T read<T>(SettingsKey<T> key) =>
T read<T extends Object>(SettingsKey<T> key) =>
(switch (key) {
.logLevel => logLevel,
.themePrimaryColor => theme.primaryColor,
@@ -150,10 +150,10 @@ class AppConfig {
})
as T;
factory AppConfig.fromEntries(Map<SettingsKey, Object?> overrides) =>
factory AppConfig.fromEntries(Map<SettingsKey<Object>, Object> overrides) =>
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
AppConfig write<T, U extends T>(SettingsKey<T> key, U value) {
AppConfig write<T extends Object>(SettingsKey<T> key, T value) {
return switch (key) {
.logLevel => copyWith(logLevel: value as LogLevel),
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
@@ -167,10 +167,8 @@ class AppConfig {
.viewerAutoPlayVideo => copyWith(viewer: viewer.copyWith(autoPlayVideo: value as bool)),
.viewerTapToNavigate => copyWith(viewer: viewer.copyWith(tapToNavigate: value as bool)),
.networkAutoEndpointSwitching => copyWith(network: network.copyWith(autoEndpointSwitching: value as bool)),
.networkPreferredWifiName => copyWith(
network: network.copyWith(preferredWifiName: .fromNullable((value as String?))),
),
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: .fromNullable((value as String?)))),
.networkPreferredWifiName => copyWith(network: network.copyWith(preferredWifiName: (value as String))),
.networkLocalEndpoint => copyWith(network: network.copyWith(localEndpoint: (value as String))),
.networkExternalEndpointList => copyWith(network: network.copyWith(externalEndpointList: value as List<String>)),
.networkCustomHeaders => copyWith(network: network.copyWith(customHeaders: value as Map<String, String>)),
.albumSortMode => copyWith(album: album.copyWith(sortMode: value as AlbumSortMode)),
@@ -1,31 +1,30 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/utils/option.dart';
class NetworkConfig {
final bool autoEndpointSwitching;
final String? preferredWifiName;
final String? localEndpoint;
final String preferredWifiName;
final String localEndpoint;
final List<String> externalEndpointList;
final Map<String, String> customHeaders;
const NetworkConfig({
this.autoEndpointSwitching = false,
this.preferredWifiName,
this.localEndpoint,
this.preferredWifiName = '',
this.localEndpoint = '',
this.externalEndpointList = const [],
this.customHeaders = const {},
});
NetworkConfig copyWith({
bool? autoEndpointSwitching,
Option<String>? preferredWifiName,
Option<String>? localEndpoint,
String? preferredWifiName,
String? localEndpoint,
List<String>? externalEndpointList,
Map<String, String>? customHeaders,
}) => NetworkConfig(
autoEndpointSwitching: autoEndpointSwitching ?? this.autoEndpointSwitching,
preferredWifiName: preferredWifiName.patch(this.preferredWifiName),
localEndpoint: localEndpoint.patch(this.localEndpoint),
preferredWifiName: preferredWifiName ?? this.preferredWifiName,
localEndpoint: localEndpoint ?? this.localEndpoint,
externalEndpointList: externalEndpointList ?? this.externalEndpointList,
customHeaders: customHeaders ?? this.customHeaders,
);
@@ -1,63 +0,0 @@
import 'package:immich_mobile/domain/models/value_codec.dart';
import 'package:immich_mobile/utils/option.dart';
enum SessionKey<T> {
serverUrl<String?>(),
accessToken<String?>(),
serverEndpoint<String?>();
ValueCodec<T> get _codec => ValueCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw);
}
const defaultSession = Session();
class Session {
final String? serverUrl;
final String? accessToken;
final String? serverEndpoint;
const Session({this.serverUrl, this.accessToken, this.serverEndpoint});
Session copyWith({Option<String>? serverUrl, Option<String>? accessToken, Option<String>? serverEndpoint}) => .new(
serverUrl: serverUrl.patch(this.serverUrl),
accessToken: accessToken.patch(this.accessToken),
serverEndpoint: serverEndpoint.patch(this.serverEndpoint),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Session &&
other.serverUrl == serverUrl &&
other.accessToken == accessToken &&
other.serverEndpoint == serverEndpoint);
@override
int get hashCode => Object.hash(serverUrl, accessToken, serverEndpoint);
@override
String toString() => 'Session(serverUrl: $serverUrl, accessToken: $accessToken, serverEndpoint: $serverEndpoint)';
T read<T>(SessionKey<T> key) =>
(switch (key) {
.serverUrl => serverUrl,
.accessToken => accessToken,
.serverEndpoint => serverEndpoint,
})
as T;
factory Session.fromEntries(Map<SessionKey, Object?> overrides) =>
overrides.entries.fold(const Session(), (session, entry) => session.write(entry.key, entry.value));
Session write<T, U extends T>(SessionKey<T> key, U value) {
return switch (key) {
.serverUrl => copyWith(serverUrl: .fromNullable(value as String?)),
.accessToken => copyWith(accessToken: .fromNullable(value as String?)),
.serverEndpoint => copyWith(serverEndpoint: .fromNullable(value as String?)),
};
}
}
+153 -20
View File
@@ -1,15 +1,16 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/value_codec.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum SettingsKey<T> {
enum SettingsKey<T extends Object> {
// Theme
themePrimaryColor<ImmichColorPreset>(codec: EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
themeDynamic<bool>(),
themeColorfulInterface<bool>(),
@@ -25,13 +26,13 @@ enum SettingsKey<T> {
// Network
networkAutoEndpointSwitching<bool>(),
networkExternalEndpointList<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)),
networkPreferredWifiName<String?>(),
networkLocalEndpoint<String?>(),
networkPreferredWifiName<String>(),
networkLocalEndpoint<String>(),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
// Album
albumSortMode<AlbumSortMode>(codec: EnumCodec(AlbumSortMode.values)),
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
albumIsReverse<bool>(),
albumIsGrid<bool>(),
@@ -45,43 +46,175 @@ enum SettingsKey<T> {
// Timeline
timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
// Map
mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(),
mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(),
// Cleanup
cleanupKeepFavorites<bool>(),
cleanupKeepMediaType<AssetKeepType>(codec: EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(),
cleanupDefaultsInitialized<bool>(),
// Share
shareFileType<ShareAssetType>(codec: EnumCodec(ShareAssetType.values)),
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)),
// Slideshow
slideshowTransition<bool>(),
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values));
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final ValueCodec<T>? _codecOverride;
final _SettingsCodec<T>? _codecOverride;
const SettingsKey({ValueCodec<T>? codec}) : _codecOverride = codec;
const SettingsKey({_SettingsCodec<T>? codec}) : _codecOverride = codec;
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
_SettingsCodec<T> get _codec => _codecOverride ?? _SettingsCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw);
}
sealed class _SettingsCodec<T extends Object> {
const _SettingsCodec();
String encode(T value);
T decode(String raw);
static const Map<Type, _SettingsCodec<Object>> _primitives = {
int: _PrimitiveCodec.integer,
double: _PrimitiveCodec.real,
bool: _PrimitiveCodec.boolean,
String: _PrimitiveCodec.string,
DateTime: _DateTimeCodec(),
};
static _SettingsCodec<T> forType<T extends Object>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the SettingsKey.');
}
return codec as _SettingsCodec<T>;
}
}
final class _EnumCodec<T extends Enum> extends _SettingsCodec<T> {
final List<T> values;
const _EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class _DateTimeCodec extends _SettingsCodec<DateTime> {
const _DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
final _SettingsCodec<K> _keyCodec;
final _SettingsCodec<V> _valueCodec;
const _MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
return {};
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class _ListCodec<T extends Object> extends _SettingsCodec<List<T>> {
final _SettingsCodec<T> _elementCodec;
const _ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
}
}
}
final class _PrimitiveCodec<T extends Object> extends _SettingsCodec<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.parse);
static const real = _PrimitiveCodec<double>._(double.parse);
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
static const string = _PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}
+6 -3
View File
@@ -1,8 +1,14 @@
import 'package:immich_mobile/domain/models/user.model.dart';
/// Key for each possible value in the `Store`.
/// Defines the data type for each value
enum StoreKey<T> {
version<int>._(0),
currentUser<UserDto>._(2),
deviceId<String>._(4),
serverUrl<String>._(10),
accessToken<String>._(11),
serverEndpoint<String>._(12),
advancedTroubleshooting<bool>._(114),
enableHapticFeedback<bool>._(126),
@@ -13,9 +19,6 @@ enum StoreKey<T> {
syncMigrationStatus<String>._(1013),
// Legacy keys that have been migrated to the new metadata store
legacyServerUrl<String>._(10),
legacyAccessToken<String>._(11),
legacyServerEndpoint<String>._(12),
legacyBackupRequireCharging<bool>._(7),
legacyBackupTriggerDelay<int>._(8),
legacySyncAlbums<bool>._(131),
-141
View File
@@ -1,141 +0,0 @@
import 'dart:convert';
sealed class ValueCodec<T> {
const ValueCodec();
String encode(T value);
T decode(String raw);
static final Map<Type, ValueCodec<Object>> _primitives = {
..._register<int>(PrimitiveCodec.integer),
..._register<double>(PrimitiveCodec.real),
..._register<bool>(PrimitiveCodec.boolean),
..._register<String>(PrimitiveCodec.string),
..._register<DateTime>(const DateTimeCodec()),
};
static Map<Type, ValueCodec<Object>> _register<T>(ValueCodec<Object> codec) => {
T: codec,
// Reifies the nullable type T so it can be used as a key in the _primitives map
_typeOf<T?>(): codec,
};
static Type _typeOf<T>() => T;
static ValueCodec<T> forType<T>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the key.');
}
return codec as ValueCodec<T>;
}
}
final class EnumCodec<T extends Enum> extends ValueCodec<T> {
final List<T> values;
const EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class DateTimeCodec extends ValueCodec<DateTime> {
const DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class MapCodec<K extends Object, V extends Object> extends ValueCodec<Map<K, V>> {
final ValueCodec<K> _keyCodec;
final ValueCodec<V> _valueCodec;
const MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
continue;
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class ListCodec<T extends Object> extends ValueCodec<List<T>> {
final ValueCodec<T> _elementCodec;
const ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
}
}
}
final class PrimitiveCodec<T extends Object> extends ValueCodec<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.parse);
static const real = PrimitiveCodec<double>._(double.parse);
static const boolean = PrimitiveCodec<bool>._(bool.parse);
static const string = PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}
@@ -2,12 +2,13 @@ import 'dart:async';
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/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.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';
@@ -17,7 +18,7 @@ final syncLinkedAlbumServiceProvider = Provider(
ref.watch(localAlbumRepository),
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(authUserRepositoryProvider),
ref.watch(storeServiceProvider),
cancellation: ref.watch(cancellationProvider),
),
);
@@ -26,14 +27,14 @@ class SyncLinkedAlbumService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftAuthUserRepository _authUserRepository;
final StoreService _storeService;
final Completer<void>? _cancellation;
SyncLinkedAlbumService(
this._localAlbumRepository,
this._remoteAlbumRepository,
this._albumApiRepository,
this._authUserRepository, {
this._storeService, {
this._cancellation,
});
@@ -122,12 +123,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 currentUser = await _authUserRepository.get();
if (currentUser == null) {
_log.warning("No user logged in, skipping remote album creation for local album: ${localAlbum.name}");
return;
}
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, currentUser, assetIds: []);
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(
localAlbum.name,
_storeService.get(StoreKey.currentUser),
assetIds: [],
);
await _remoteAlbumRepository.create(newRemoteAlbum, []);
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
}
+14 -11
View File
@@ -1,24 +1,29 @@
import 'dart:async';
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/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:logging/logging.dart';
class UserService {
final Logger _log = Logger("UserService");
final UserApiRepository _userApiRepository;
final DriftAuthUserRepository _authUserRepository;
final StoreService _storeService;
UserService({required this._userApiRepository, required this._authUserRepository});
UserService({required this._userApiRepository, required this._storeService});
Future<UserDto?> tryGetMyUser() {
return _authUserRepository.get();
UserDto getMyUser() {
return _storeService.get(StoreKey.currentUser);
}
UserDto? tryGetMyUser() {
return _storeService.tryGet(StoreKey.currentUser);
}
Stream<UserDto?> watchMyUser() {
return _authUserRepository.watch();
return _storeService.watch(StoreKey.currentUser);
}
Future<UserDto?> refreshMyUser() async {
@@ -26,17 +31,15 @@ class UserService {
if (user == null) {
return null;
}
await _authUserRepository.upsert(user);
await _storeService.put(StoreKey.currentUser, user);
return user;
}
Future<String?> createProfileImage(String name, Uint8List image) async {
try {
final path = await _userApiRepository.createProfileImage(name: name, data: image);
final updatedUser = await tryGetMyUser();
if (updatedUser != null) {
await _authUserRepository.upsert(updatedUser);
}
final updatedUser = getMyUser();
await _storeService.put(StoreKey.currentUser, updatedUser);
return path;
} catch (e) {
_log.warning("Failed to upload profile image", e);
@@ -1,13 +1,14 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:logging/logging.dart';
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) async {
final user = await ref.read(authUserRepositoryProvider).get();
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
final user = Store.tryGet(StoreKey.currentUser);
if (user == null) {
Logger("SyncLinkedAlbum").warning("No user logged in, skipping linked album sync");
return;
return Future.value();
}
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
}
@@ -1,18 +0,0 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class SessionEntity extends Table with DriftDefaultsMixin {
const SessionEntity();
TextColumn get key => text()();
TextColumn get value => text().nullable()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {key};
@override
String get tableName => "session";
}
@@ -1,427 +0,0 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/session.entity.dart'
as i2;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
typedef $$SessionEntityTableCreateCompanionBuilder =
i1.SessionEntityCompanion Function({
required String key,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
typedef $$SessionEntityTableUpdateCompanionBuilder =
i1.SessionEntityCompanion Function({
i0.Value<String> key,
i0.Value<String?> value,
i0.Value<DateTime> updatedAt,
});
class $$SessionEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$SessionEntityTable> {
$$SessionEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$SessionEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$SessionEntityTable> {
$$SessionEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get key => $composableBuilder(
column: $table.key,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get value => $composableBuilder(
column: $table.value,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$SessionEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$SessionEntityTable> {
$$SessionEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get key =>
$composableBuilder(column: $table.key, builder: (column) => column);
i0.GeneratedColumn<String> get value =>
$composableBuilder(column: $table.value, builder: (column) => column);
i0.GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
}
class $$SessionEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$SessionEntityTable,
i1.SessionEntityData,
i1.$$SessionEntityTableFilterComposer,
i1.$$SessionEntityTableOrderingComposer,
i1.$$SessionEntityTableAnnotationComposer,
$$SessionEntityTableCreateCompanionBuilder,
$$SessionEntityTableUpdateCompanionBuilder,
(
i1.SessionEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$SessionEntityTable,
i1.SessionEntityData
>,
),
i1.SessionEntityData,
i0.PrefetchHooks Function()
> {
$$SessionEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$SessionEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$SessionEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$SessionEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$$SessionEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> key = const i0.Value.absent(),
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SessionEntityCompanion(
key: key,
value: value,
updatedAt: updatedAt,
),
createCompanionCallback:
({
required String key,
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SessionEntityCompanion.insert(
key: key,
value: value,
updatedAt: updatedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$SessionEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$SessionEntityTable,
i1.SessionEntityData,
i1.$$SessionEntityTableFilterComposer,
i1.$$SessionEntityTableOrderingComposer,
i1.$$SessionEntityTableAnnotationComposer,
$$SessionEntityTableCreateCompanionBuilder,
$$SessionEntityTableUpdateCompanionBuilder,
(
i1.SessionEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$SessionEntityTable,
i1.SessionEntityData
>,
),
i1.SessionEntityData,
i0.PrefetchHooks Function()
>;
class $SessionEntityTable extends i2.SessionEntity
with i0.TableInfo<$SessionEntityTable, i1.SessionEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$SessionEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _keyMeta = const i0.VerificationMeta('key');
@override
late final i0.GeneratedColumn<String> key = i0.GeneratedColumn<String>(
'key',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _valueMeta = const i0.VerificationMeta(
'value',
);
@override
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
'value',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
'updatedAt',
);
@override
late final i0.GeneratedColumn<DateTime> updatedAt =
i0.GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i3.currentDateAndTime,
);
@override
List<i0.GeneratedColumn> get $columns => [key, value, updatedAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'session';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.SessionEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('key')) {
context.handle(
_keyMeta,
key.isAcceptableOrUnknown(data['key']!, _keyMeta),
);
} else if (isInserting) {
context.missing(_keyMeta);
}
if (data.containsKey('value')) {
context.handle(
_valueMeta,
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
);
}
if (data.containsKey('updated_at')) {
context.handle(
_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {key};
@override
i1.SessionEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.SessionEntityData(
key: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}key'],
)!,
value: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}value'],
),
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
)!,
);
}
@override
$SessionEntityTable createAlias(String alias) {
return $SessionEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class SessionEntityData extends i0.DataClass
implements i0.Insertable<i1.SessionEntityData> {
final String key;
final String? value;
final DateTime updatedAt;
const SessionEntityData({
required this.key,
this.value,
required this.updatedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['key'] = i0.Variable<String>(key);
if (!nullToAbsent || value != null) {
map['value'] = i0.Variable<String>(value);
}
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
return map;
}
factory SessionEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return SessionEntityData(
key: serializer.fromJson<String>(json['key']),
value: serializer.fromJson<String?>(json['value']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'key': serializer.toJson<String>(key),
'value': serializer.toJson<String?>(value),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
i1.SessionEntityData copyWith({
String? key,
i0.Value<String?> value = const i0.Value.absent(),
DateTime? updatedAt,
}) => i1.SessionEntityData(
key: key ?? this.key,
value: value.present ? value.value : this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
SessionEntityData copyWithCompanion(i1.SessionEntityCompanion data) {
return SessionEntityData(
key: data.key.present ? data.key.value : this.key,
value: data.value.present ? data.value.value : this.value,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
);
}
@override
String toString() {
return (StringBuffer('SessionEntityData(')
..write('key: $key, ')
..write('value: $value, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(key, value, updatedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.SessionEntityData &&
other.key == this.key &&
other.value == this.value &&
other.updatedAt == this.updatedAt);
}
class SessionEntityCompanion extends i0.UpdateCompanion<i1.SessionEntityData> {
final i0.Value<String> key;
final i0.Value<String?> value;
final i0.Value<DateTime> updatedAt;
const SessionEntityCompanion({
this.key = const i0.Value.absent(),
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
});
SessionEntityCompanion.insert({
required String key,
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
}) : key = i0.Value(key);
static i0.Insertable<i1.SessionEntityData> custom({
i0.Expression<String>? key,
i0.Expression<String>? value,
i0.Expression<DateTime>? updatedAt,
}) {
return i0.RawValuesInsertable({
if (key != null) 'key': key,
if (value != null) 'value': value,
if (updatedAt != null) 'updated_at': updatedAt,
});
}
i1.SessionEntityCompanion copyWith({
i0.Value<String>? key,
i0.Value<String?>? value,
i0.Value<DateTime>? updatedAt,
}) {
return i1.SessionEntityCompanion(
key: key ?? this.key,
value: value ?? this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (key.present) {
map['key'] = i0.Variable<String>(key.value);
}
if (value.present) {
map['value'] = i0.Variable<String>(value.value);
}
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('SessionEntityCompanion(')
..write('key: $key, ')
..write('value: $value, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
}
@@ -6,7 +6,7 @@ class SettingsEntity extends Table with DriftDefaultsMixin {
TextColumn get key => text()();
TextColumn get value => text().nullable()();
TextColumn get value => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
+21 -20
View File
@@ -10,13 +10,13 @@ import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
typedef $$SettingsEntityTableCreateCompanionBuilder =
i1.SettingsEntityCompanion Function({
required String key,
i0.Value<String?> value,
required String value,
i0.Value<DateTime> updatedAt,
});
typedef $$SettingsEntityTableUpdateCompanionBuilder =
i1.SettingsEntityCompanion Function({
i0.Value<String> key,
i0.Value<String?> value,
i0.Value<String> value,
i0.Value<DateTime> updatedAt,
});
@@ -127,7 +127,7 @@ class $$SettingsEntityTableTableManager
updateCompanionCallback:
({
i0.Value<String> key = const i0.Value.absent(),
i0.Value<String?> value = const i0.Value.absent(),
i0.Value<String> value = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SettingsEntityCompanion(
key: key,
@@ -137,7 +137,7 @@ class $$SettingsEntityTableTableManager
createCompanionCallback:
({
required String key,
i0.Value<String?> value = const i0.Value.absent(),
required String value,
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.SettingsEntityCompanion.insert(
key: key,
@@ -196,9 +196,9 @@ class $SettingsEntityTable extends i2.SettingsEntity
late final i0.GeneratedColumn<String> value = i0.GeneratedColumn<String>(
'value',
aliasedName,
true,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
'updatedAt',
@@ -240,6 +240,8 @@ class $SettingsEntityTable extends i2.SettingsEntity
_valueMeta,
value.isAcceptableOrUnknown(data['value']!, _valueMeta),
);
} else if (isInserting) {
context.missing(_valueMeta);
}
if (data.containsKey('updated_at')) {
context.handle(
@@ -263,7 +265,7 @@ class $SettingsEntityTable extends i2.SettingsEntity
value: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}value'],
),
)!,
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
@@ -285,20 +287,18 @@ class $SettingsEntityTable extends i2.SettingsEntity
class SettingsEntityData extends i0.DataClass
implements i0.Insertable<i1.SettingsEntityData> {
final String key;
final String? value;
final String value;
final DateTime updatedAt;
const SettingsEntityData({
required this.key,
this.value,
required this.value,
required this.updatedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['key'] = i0.Variable<String>(key);
if (!nullToAbsent || value != null) {
map['value'] = i0.Variable<String>(value);
}
map['value'] = i0.Variable<String>(value);
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
return map;
}
@@ -310,7 +310,7 @@ class SettingsEntityData extends i0.DataClass
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return SettingsEntityData(
key: serializer.fromJson<String>(json['key']),
value: serializer.fromJson<String?>(json['value']),
value: serializer.fromJson<String>(json['value']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@@ -319,18 +319,18 @@ class SettingsEntityData extends i0.DataClass
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'key': serializer.toJson<String>(key),
'value': serializer.toJson<String?>(value),
'value': serializer.toJson<String>(value),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
i1.SettingsEntityData copyWith({
String? key,
i0.Value<String?> value = const i0.Value.absent(),
String? value,
DateTime? updatedAt,
}) => i1.SettingsEntityData(
key: key ?? this.key,
value: value.present ? value.value : this.value,
value: value ?? this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
SettingsEntityData copyWithCompanion(i1.SettingsEntityCompanion data) {
@@ -365,7 +365,7 @@ class SettingsEntityData extends i0.DataClass
class SettingsEntityCompanion
extends i0.UpdateCompanion<i1.SettingsEntityData> {
final i0.Value<String> key;
final i0.Value<String?> value;
final i0.Value<String> value;
final i0.Value<DateTime> updatedAt;
const SettingsEntityCompanion({
this.key = const i0.Value.absent(),
@@ -374,9 +374,10 @@ class SettingsEntityCompanion
});
SettingsEntityCompanion.insert({
required String key,
this.value = const i0.Value.absent(),
required String value,
this.updatedAt = const i0.Value.absent(),
}) : key = i0.Value(key);
}) : key = i0.Value(key),
value = i0.Value(value);
static i0.Insertable<i1.SettingsEntityData> custom({
i0.Expression<String>? key,
i0.Expression<String>? value,
@@ -391,7 +392,7 @@ class SettingsEntityCompanion
i1.SettingsEntityCompanion copyWith({
i0.Value<String>? key,
i0.Value<String?>? value,
i0.Value<String>? value,
i0.Value<DateTime>? updatedAt,
}) {
return i1.SettingsEntityCompanion(
@@ -1,42 +0,0 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';
abstract class CachedKeyValueRepository<K extends Enum, S> {
CachedKeyValueRepository(this._snapshot);
S _snapshot;
S get snapshot => _snapshot;
@protected
set snapshot(S value) => _snapshot = value;
List<K> get keys;
Object decodeValue(K key, String raw);
S buildSnapshot(Map<K, Object?> overrides);
Selectable<({String key, String? value})> selectable();
Future<void> refresh() async => _snapshot = _build(await selectable().get());
@protected
Stream<S> watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows));
S _build(List<({String key, String? value})> rows) => buildSnapshot(
rows.fold({}, (overrides, row) {
final key = keys.firstWhereOrNull((key) => key.name == row.key);
if (key == null) {
return overrides;
}
Object? decodedValue;
if (row.value != null) {
decodedValue = decodeValue(key, row.value!);
}
return {...overrides, key: decodedValue};
}),
);
}
@@ -25,7 +25,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/session.entity.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
@@ -68,7 +67,6 @@ import 'package:sqlite_async/sqlite_async.dart';
AssetEditEntity,
SettingsEntity,
AssetOcrEntity,
SessionEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -122,7 +120,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 31;
int get schemaVersion => 29;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -310,12 +308,6 @@ class Drift extends $Drift {
await m.createTable(v29.assetOcrEntity);
await m.createIndex(v29.idxAssetOcrAssetId);
},
from29To30: (m, v30) async {
await m.alterTable(TableMigration(v30.settings));
},
from30To31: (m, v31) async {
await m.createTable(v31.session);
},
),
);
@@ -47,11 +47,9 @@ import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart
as i22;
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
as i23;
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart'
as i24;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i25;
import 'package:drift/internal/modular.dart' as i26;
as i24;
import 'package:drift/internal/modular.dart' as i25;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -101,12 +99,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i23.$AssetOcrEntityTable assetOcrEntity = i23.$AssetOcrEntityTable(
this,
);
late final i24.$SessionEntityTable sessionEntity = i24.$SessionEntityTable(
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
this,
);
i25.MergedAssetDrift get mergedAssetDrift => i26.ReadDatabaseContainer(
this,
).accessor<i25.MergedAssetDrift>(i25.MergedAssetDrift.new);
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -145,7 +140,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
assetEditEntity,
settingsEntity,
assetOcrEntity,
sessionEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i11.idxRemoteExifCity,
@@ -420,6 +414,4 @@ class $DriftManager {
i22.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
i23.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
i23.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
i24.$$SessionEntityTableTableManager get sessionEntity =>
i24.$$SessionEntityTableTableManager(_db, _db.sessionEntity);
}
File diff suppressed because it is too large Load Diff
@@ -20,7 +20,10 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
const DriftRemoteAlbumRepository(this._db) : super(_db);
Future<List<RemoteAlbum>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {SortRemoteAlbumsBy.updatedAt}}) {
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
// Count non-trashed assets via the joined asset table. Filtering trashed assets in the
// join condition (instead of the where clause) keeps albums whose assets are all trashed
// in the result, the same way truly empty albums are kept
final assetCount = _db.remoteAssetEntity.id.count(distinct: true);
final query = _db.remoteAlbumEntity.select().join([
leftOuterJoin(
@@ -30,7 +33,8 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId) &
_db.remoteAssetEntity.deletedAt.isNull(),
useColumns: false,
),
leftOuterJoin(
@@ -47,7 +51,6 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
),
]);
query
..where(_db.remoteAssetEntity.deletedAt.isNull())
..addColumns([assetCount])
..addColumns([_db.userEntity.name, _db.userEntity.id])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
@@ -79,7 +82,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}
Future<RemoteAlbum?> get(String albumId) {
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
final assetCount = _db.remoteAssetEntity.id.count(distinct: true);
final query =
_db.remoteAlbumEntity.select().join([
@@ -90,7 +93,8 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId) &
_db.remoteAssetEntity.deletedAt.isNull(),
useColumns: false,
),
leftOuterJoin(
@@ -106,7 +110,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
useColumns: false,
),
])
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
..where(_db.remoteAlbumEntity.id.equals(albumId))
..addColumns([assetCount])
..addColumns([_db.userEntity.name, _db.userEntity.id])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
@@ -515,7 +519,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
return [];
}
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
final assetCount = _db.remoteAssetEntity.id.count(distinct: true);
final query =
_db.remoteAlbumEntity.select().join([
leftOuterJoin(
@@ -525,7 +529,8 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId) &
_db.remoteAssetEntity.deletedAt.isNull(),
useColumns: false,
),
leftOuterJoin(
@@ -541,7 +546,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
useColumns: false,
),
])
..where(_db.remoteAlbumEntity.id.isIn(albumIds) & _db.remoteAssetEntity.deletedAt.isNull())
..where(_db.remoteAlbumEntity.id.isIn(albumIds))
..addColumns([assetCount])
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
..addColumns([_db.userEntity.name, _db.userEntity.id])
@@ -1,82 +0,0 @@
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SessionRepository extends CachedKeyValueRepository<SessionKey, Session> {
final Drift _db;
SessionRepository._(this._db) : super(const .new());
static SessionRepository? _instance;
static SessionRepository get instance {
final instance = _instance;
if (instance == null) {
throw StateError('SessionRepository not initialized. Call ensureInitialized() first');
}
return instance;
}
static Future<SessionRepository> ensureInitialized(Drift db) async {
if (_instance == null) {
final instance = SessionRepository._(db);
await instance.refresh();
_instance = instance;
}
return _instance!;
}
@override
List<SessionKey> get keys => SessionKey.values;
@override
Object decodeValue(SessionKey key, String raw) => key.decode(raw);
@override
Session buildSnapshot(Map<SessionKey, Object?> overrides) => Session.fromEntries(overrides);
@override
@protected
Selectable<({String key, String? value})> selectable() =>
_db.select(_db.sessionEntity).map((row) => (key: row.key, value: row.value));
Session get session => snapshot;
Future<void> clear(Iterable<SessionKey> keys) async {
if (keys.isEmpty) {
return;
}
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.sessionEntity)..where((row) => row.key.isIn(names))).go();
var session = snapshot;
for (final key in keys) {
session = session.write(key, defaultSession.read(key));
}
snapshot = session;
}
Future<void> write<T, U extends T>(SessionKey<T> key, U value) async {
if (value == snapshot.read(key)) {
return;
}
String? resolvedValue;
if (value != null) {
resolvedValue = key.encode(value);
}
await _db
.into(_db.sessionEntity)
.insertOnConflictUpdate(
SessionEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
);
snapshot = snapshot.write(key, value);
}
Stream<Session> watch() => watchSnapshot();
}
@@ -1,15 +1,14 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig> {
class SettingsRepository extends DriftDatabaseRepository {
final Drift _db;
SettingsRepository._(this._db) : super(const .new());
SettingsRepository._(this._db) : super(_db);
static SettingsRepository? _instance;
@@ -21,6 +20,9 @@ class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig
return instance;
}
AppConfig _appConfig = const .new();
AppConfig get appConfig => _appConfig;
static Future<SettingsRepository> ensureInitialized(Drift db) async {
if (_instance == null) {
final instance = SettingsRepository._(db);
@@ -30,21 +32,7 @@ class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig
return _instance!;
}
@override
List<SettingsKey> get keys => SettingsKey.values;
@override
Object decodeValue(SettingsKey key, String raw) => key.decode(raw);
@override
AppConfig buildSnapshot(Map<SettingsKey, Object?> overrides) => AppConfig.fromEntries(overrides);
@override
@protected
Selectable<({String key, String? value})> selectable() =>
_db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value));
AppConfig get appConfig => snapshot;
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
Future<void> clear(Iterable<SettingsKey> keys) async {
if (keys.isEmpty) {
@@ -54,15 +42,13 @@ class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go();
var config = snapshot;
for (final key in keys) {
config = config.write(key, defaultConfig.read(key));
_appConfig = _appConfig.write(key, defaultConfig.read(key));
}
snapshot = config;
}
Future<void> write<T, U extends T>(SettingsKey<T> key, U value) async {
if (value == snapshot.read(key)) {
Future<void> write<T extends Object, U extends T>(SettingsKey<T> key, U value) async {
if (value == _appConfig.read(key)) {
return;
}
@@ -70,18 +56,29 @@ class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig
return clear([key]);
}
String? resolvedValue;
if (value != null) {
resolvedValue = key.encode(value);
}
await _db
.into(_db.settingsEntity)
.insertOnConflictUpdate(
SettingsEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
SettingsEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
snapshot = snapshot.write(key, value);
_appConfig = _appConfig.write(key, value);
}
Stream<AppConfig> watch() => watchSnapshot();
Stream<AppConfig> watchConfig() => _db.select(_db.settingsEntity).watch().map((rows) {
_applyOverrides(rows);
return _appConfig;
});
void _applyOverrides(List<SettingsEntityData> rows) {
_appConfig = AppConfig.fromEntries(
rows.fold({}, (overrides, row) {
final metadataKey = SettingsKey.values.firstWhereOrNull((key) => key.name == row.key);
if (metadataKey == null) {
return overrides;
}
return {...overrides, metadataKey: metadataKey.decode(row.value)};
}),
);
}
}
@@ -1,7 +1,9 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
class DriftStoreRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -61,6 +63,8 @@ class DriftStoreRepository extends DriftDatabaseRepository {
const (String) => entity.stringValue,
const (bool) => entity.intValue == 1,
const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
const (UserDto) =>
entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!),
_ => null,
}
as T?;
@@ -71,6 +75,7 @@ class DriftStoreRepository extends DriftDatabaseRepository {
const (String) => (null, value as String),
const (bool) => ((value as bool) ? 1 : 0, null),
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id),
_ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"),
};
return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue));
@@ -17,15 +17,16 @@ class DriftAuthUserRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftAuthUserRepository(super.db) : _db = db;
Selectable<UserDto?> get _authUserQuery => (_db.authUserEntity.select()..limit(1)).asyncMap(_toDto);
Future<UserDto?> get(String id) async {
final user = await _db.managers.authUserEntity.filter((user) => user.id.equals(id)).getSingleOrNull();
Future<UserDto?> get() => _authUserQuery.getSingleOrNull();
if (user == null) {
return null;
}
Stream<UserDto?> watch() => _authUserQuery.watchSingleOrNull();
Future<UserDto> _toDto(AuthUserEntityData user) async {
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(user.id));
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(id));
final metadata = await query.map((row) => row.toDto()).get();
return user.toDto(metadata);
}
@@ -5,6 +5,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
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/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
@@ -83,7 +85,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
final backupSyncManager = ref.read(backgroundSyncProvider);
Future<void> startBackup() async {
final currentUser = ref.read(currentUserProvider);
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser == null) {
return;
}
@@ -19,6 +19,11 @@ import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:logging/logging.dart';
final backupAlbumCountProvider = FutureProvider.autoDispose<int>((ref) async {
await ref.read(backupAlbumProvider.notifier).getAll();
return ref.read(backupAlbumProvider).length;
});
@RoutePage()
class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
const DriftBackupAlbumSelectionPage({super.key});
@@ -44,7 +49,6 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
_searchFocusNode = FocusNode();
_enableSyncUploadAlbum.value = ref.read(appConfigProvider).backup.syncAlbums;
ref.read(backupAlbumProvider.notifier).getAll();
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
}
@@ -79,6 +83,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
@override
Widget build(BuildContext context) {
final isLoading = ref.watch(backupAlbumCountProvider).isLoading;
final albums = ref.watch(backupAlbumProvider);
final albumCount = albums.length;
// Filter albums based on search query
@@ -249,9 +254,17 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
return _AlbumSelectionGrid(
filteredAlbums: filteredAlbums,
searchQuery: _searchQuery,
isLoading: isLoading,
);
} else {
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
return _AlbumSelectionList(
filteredAlbums: filteredAlbums,
searchQuery: _searchQuery,
isLoading: isLoading,
);
}
},
),
@@ -292,8 +305,9 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
class _AlbumSelectionList extends StatelessWidget {
final List<LocalAlbum> filteredAlbums;
final String searchQuery;
final bool isLoading;
const _AlbumSelectionList({required this.filteredAlbums, required this.searchQuery});
const _AlbumSelectionList({required this.filteredAlbums, required this.searchQuery, required this.isLoading});
@override
Widget build(BuildContext context) {
@@ -309,7 +323,18 @@ class _AlbumSelectionList extends StatelessWidget {
}
if (filteredAlbums.isEmpty) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
if (isLoading) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
}
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text('no_albums_found'.t(context: context)),
),
),
);
}
return SliverPadding(
@@ -326,8 +351,9 @@ class _AlbumSelectionList extends StatelessWidget {
class _AlbumSelectionGrid extends StatelessWidget {
final List<LocalAlbum> filteredAlbums;
final String searchQuery;
final bool isLoading;
const _AlbumSelectionGrid({required this.filteredAlbums, required this.searchQuery});
const _AlbumSelectionGrid({required this.filteredAlbums, required this.searchQuery, required this.isLoading});
@override
Widget build(BuildContext context) {
@@ -343,7 +369,18 @@ class _AlbumSelectionGrid extends StatelessWidget {
}
if (filteredAlbums.isEmpty) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
if (isLoading) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
}
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text('no_albums_found'.t(context: context)),
),
),
);
}
return SliverPadding(
@@ -8,15 +8,14 @@ import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/providers/auth.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/infrastructure/session.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -307,10 +306,9 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
}
void resumeSession() async {
final session = ref.read(sessionProvider);
final serverUrl = session.serverUrl;
final endpoint = session.serverEndpoint;
final accessToken = session.accessToken;
final serverUrl = Store.tryGet(StoreKey.serverUrl);
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && serverUrl != null && endpoint != null) {
final infoProvider = ref.read(serverInfoProvider.notifier);
@@ -318,7 +316,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
final authUserRepository = ref.read(authUserRepositoryProvider);
unawaited(
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
@@ -338,9 +335,9 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
if (syncSuccess) {
await Future.wait([
backgroundManager.hashAssets().then((_) {
_resumeBackup(backupProvider, authUserRepository);
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider, authUserRepository),
_resumeBackup(backupProvider),
// TODO: Bring back when the soft freeze issue is addressed
// backgroundManager.syncCloudIds(),
]);
@@ -376,11 +373,11 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
}
}
Future<void> _resumeBackup(DriftBackupNotifier notifier, DriftAuthUserRepository authUserRepository) async {
Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
final isEnableBackup = SettingsRepository.instance.appConfig.backup.enabled;
if (isEnableBackup) {
final currentUser = await authUserRepository.get();
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
unawaited(notifier.startForegroundBackup(currentUser.id));
}
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -21,7 +22,7 @@ class OpenInBrowserActionButton extends ConsumerWidget {
});
void _onTap() async {
final serverEndpoint = SessionRepository.instance.session.serverEndpoint!.replaceFirst('/api', '');
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
String originPath = '';
switch (origin) {
@@ -4,6 +4,8 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -11,7 +13,6 @@ import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.pro
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
@@ -142,7 +143,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = ref.read(sessionProvider).serverEndpoint!;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
@@ -1,18 +1,18 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
class PartnerUserAvatar extends ConsumerWidget {
class PartnerUserAvatar extends StatelessWidget {
const PartnerUserAvatar({super.key, required this.userId, required this.name});
final String userId;
final String name;
@override
Widget build(BuildContext context, WidgetRef ref) {
final url = "${ref.read(sessionProvider).serverEndpoint}/users/$userId/profile-image";
Widget build(BuildContext context) {
final url = "${Store.get(StoreKey.serverEndpoint)}/users/$userId/profile-image";
final nameFirstLetter = name.isNotEmpty ? name[0] : "";
return CircleAvatar(
radius: 16,
@@ -1,7 +1,9 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
@@ -11,7 +13,6 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/permission.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:logging/logging.dart';
@@ -139,7 +140,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
final isEnableBackup = _ref.read(appConfigProvider).backup.enabled;
if (isEnableBackup) {
final currentUser = _ref.read(currentUserProvider);
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
await _safeRun(
_ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),
+5 -7
View File
@@ -3,7 +3,6 @@ import 'dart:convert';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
@@ -11,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
@@ -127,10 +125,10 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<bool> saveAuthInfo({required String accessToken}) async {
await _ref.read(sessionRepository).write(SessionKey.accessToken, accessToken);
await Store.put(StoreKey.accessToken, accessToken);
await _apiService.updateHeaders();
final serverEndpoint = _ref.read(sessionProvider).serverEndpoint!;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final headerMap = _ref.read(appConfigProvider).network.customHeaders;
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
@@ -138,7 +136,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
// Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
UserDto? user = await _userService.tryGetMyUser();
UserDto? user = _userService.tryGetMyUser();
try {
final serverUser = await _userService.refreshMyUser().timeout(_timeoutDuration);
@@ -195,9 +193,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
return _ref.read(appConfigProvider).network.localEndpoint;
}
/// Returns the current server endpoint (with /api) URL from the session
/// Returns the current server endpoint (with /api) URL from the store
String? getServerEndpoint() {
return _ref.read(sessionProvider).serverEndpoint;
return Store.tryGet(StoreKey.serverEndpoint);
}
Future<String?> setOpenApiServiceEndpoint() {
@@ -1,12 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
final sessionRepository = Provider.autoDispose<SessionRepository>((_) => SessionRepository.instance);
final sessionProvider = Provider.autoDispose<Session>((ref) {
final repo = ref.watch(sessionRepository);
final subscription = repo.watch().listen((event) => ref.state = event);
ref.onDispose(subscription.cancel);
return repo.session;
});
@@ -6,7 +6,7 @@ final settingsProvider = Provider.autoDispose<SettingsRepository>((_) => Setting
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
final repo = ref.watch(settingsProvider);
final subscription = repo.watch().listen((event) => ref.state = event);
final subscription = repo.watchConfig().listen((event) => ref.state = event);
ref.onDispose(subscription.cancel);
return repo.appConfig;
});
@@ -6,18 +6,17 @@ import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(driftProvider)));
final authUserRepositoryProvider = Provider((ref) => DriftAuthUserRepository(ref.watch(driftProvider)));
final userApiRepositoryProvider = Provider((ref) => UserApiRepository(ref.watch(apiServiceProvider).usersApi));
final userServiceProvider = Provider(
(ref) => UserService(
userApiRepository: ref.watch(userApiRepositoryProvider),
authUserRepository: ref.watch(authUserRepositoryProvider),
storeService: ref.watch(storeServiceProvider),
),
);
+1 -1
View File
@@ -7,7 +7,7 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
class CurrentUserProvider extends StateNotifier<UserDto?> {
CurrentUserProvider(this._userService) : super(null) {
_userService.tryGetMyUser().then((user) => state = user ?? state);
state = _userService.tryGetMyUser();
streamSub = _userService.watchMyUser().listen((user) => state = user ?? state);
}
+3 -2
View File
@@ -1,11 +1,12 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
@@ -67,7 +68,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
if (authenticationState.isAuthenticated) {
try {
final endpoint = Uri.parse(_ref.read(sessionProvider).serverEndpoint!);
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
dPrint(() => "Attempting to connect to websocket");
// Configure socket transports must be specified
Socket socket = io(
@@ -5,8 +5,9 @@ import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:logging/logging.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -95,7 +96,7 @@ class UploadRepository {
void Function(int bytes, int totalBytes)? onProgress,
required String logContext,
}) async {
final String savedEndpoint = SessionRepository.instance.session.serverEndpoint!;
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
final baseRequest = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
+10 -6
View File
@@ -2,7 +2,9 @@ import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
@@ -21,8 +23,10 @@ class AuthGuard extends AutoRouteGuard {
// guards, so we keep this function fully sync and validate the token in
// the background — otherwise a slow validateAccessToken() request would
// block the route transition for as long as the OS-level HTTP timeout.
if (SessionRepository.instance.session.accessToken == null) {
_log.warning('No access token in the session.');
try {
Store.get(StoreKey.accessToken);
} on StoreKeyNotFoundException catch (_) {
_log.warning('No access token in the store.');
resolver.next(false);
unawaited(router.replaceAll([const LoginRoute()]));
return;
@@ -36,7 +40,7 @@ class AuthGuard extends AutoRouteGuard {
if (_validateInFlight) {
return;
}
final token = SessionRepository.instance.session.accessToken;
final token = Store.tryGet(StoreKey.accessToken);
if (token == null) {
return;
}
@@ -46,7 +50,7 @@ class AuthGuard extends AutoRouteGuard {
if (res == null || res.authStatus != true) {
// Token may have changed during validation (user logged out + logged in
// again); only act if it still applies to the current session.
if (SessionRepository.instance.session.accessToken != token) {
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
_log.fine('User token is invalid. Redirecting to login');
@@ -57,7 +61,7 @@ class AuthGuard extends AutoRouteGuard {
if (e.code != HttpStatus.unauthorized) {
return;
}
if (SessionRepository.instance.session.accessToken != token) {
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
_log.warning("Unauthorized access token.");
+7 -7
View File
@@ -3,10 +3,10 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
@@ -41,7 +41,7 @@ class ApiService {
// The below line ensures that the api clients are initialized when the service is instantiated
// This is required to avoid late initialization errors when the clients are access before the endpoint is resolved
setEndpoint('');
final endpoint = SessionRepository.instance.session.serverEndpoint;
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
if (endpoint != null && endpoint.isNotEmpty) {
setEndpoint(endpoint);
}
@@ -84,7 +84,7 @@ class ApiService {
setEndpoint(endpoint);
// Save in local database for next startup
await SessionRepository.instance.write(SessionKey.serverEndpoint, endpoint);
await Store.put(StoreKey.serverEndpoint, endpoint);
return endpoint;
}
@@ -173,13 +173,13 @@ class ApiService {
static List<String> getServerUrls() {
final urls = <String>[];
final serverEndpoint = SessionRepository.instance.session.serverEndpoint;
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final network = SettingsRepository.instance.appConfig.network;
final localEndpoint = network.localEndpoint;
if (localEndpoint != null && localEndpoint.isNotEmpty) {
if (localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
}
for (final url in network.externalEndpointList) {
+6 -5
View File
@@ -1,12 +1,12 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
@@ -55,7 +55,7 @@ class AuthService {
Future<String> validateServerUrl(String url) async {
final validUrl = await _apiService.resolveAndSetEndpoint(url);
await _apiService.setDeviceInfoHeader();
await SessionRepository.instance.write(SessionKey.serverUrl, validUrl);
await Store.put(StoreKey.serverUrl, validUrl);
return validUrl;
}
@@ -118,7 +118,8 @@ class AuthService {
await _backgroundSyncManager.cancel();
await Future.wait([
_authRepository.clearLocalData(),
SessionRepository.instance.clear([SessionKey.accessToken]),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
SettingsRepository.instance.clear(const [
.networkAutoEndpointSwitching,
.networkPreferredWifiName,
@@ -13,7 +13,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@@ -387,7 +386,7 @@ class BackgroundUploadService {
String? latitude,
String? longitude,
}) async {
final serverEndpoint = SessionRepository.instance.session.serverEndpoint!;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString();
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
-3
View File
@@ -6,7 +6,6 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
@@ -52,8 +51,6 @@ abstract final class Bootstrap {
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
await SessionRepository.ensureInitialized(drift);
final settingsRepo = await SettingsRepository.ensureInitialized(drift);
await LogService.init(
+6 -6
View File
@@ -1,8 +1,9 @@
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:openapi/api.dart';
String getOriginalUrlForRemoteId(final String id, {bool edited = true}) {
return '${SessionRepository.instance.session.serverEndpoint!}/assets/$id/original?edited=$edited';
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original?edited=$edited';
}
String getThumbnailUrlForRemoteId(
@@ -11,15 +12,14 @@ String getThumbnailUrlForRemoteId(
bool edited = true,
String? thumbhash,
}) {
final url =
'${SessionRepository.instance.session.serverEndpoint!}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url;
}
String getPlaybackUrlForRemoteId(final String id) {
return '${SessionRepository.instance.session.serverEndpoint!}/assets/$id/video/playback?';
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
}
String getFaceThumbnailUrl(final String personId) {
return '${SessionRepository.instance.session.serverEndpoint!}/people/$personId/thumbnail';
return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail';
}
+32 -74
View File
@@ -8,20 +8,17 @@ import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 27;
const int targetVersion = 26;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int version = Store.get(StoreKey.version, targetVersion);
@@ -34,22 +31,18 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
if (version < 27) {
await _migrateTo27(drift);
}
await Store.put(StoreKey.version, targetVersion);
return;
}
Future<void> _migrateTo25() async {
final accessToken = Store.tryGet(StoreKey.legacyAccessToken);
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken == null || accessToken.isEmpty) {
return;
}
final urls = <String>[];
final serverEndpoint = Store.tryGet(StoreKey.legacyServerEndpoint);
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
@@ -80,7 +73,7 @@ Future<void> _migrateTo25() async {
}
Future<void> _migrateTo26(Drift drift) async {
final migrator = _StoreMigrator.settings(drift);
final migrator = _StoreMigrator(drift);
await migrator.migrateEnumIndex(StoreKey.legacyLogLevel, SettingsKey.logLevel, LogLevel.values);
// Theme
await migrator.migrateEnumName(StoreKey.legacyThemeMode, SettingsKey.themeMode, ThemeMode.values);
@@ -125,10 +118,8 @@ Future<void> _migrateTo26(Drift drift) async {
await migrator.migrateBool(StoreKey.legacyTapToNavigate, SettingsKey.viewerTapToNavigate);
// Network
await migrator.migrateBool(StoreKey.legacyAutoEndpointSwitching, SettingsKey.networkAutoEndpointSwitching);
final preferredWifiName = await migrator.readLegacyStoreString(StoreKey.legacyPreferredWifiName.id);
migrator.stage(StoreKey.legacyPreferredWifiName, SettingsKey.networkPreferredWifiName, preferredWifiName);
final localEndpoint = await migrator.readLegacyStoreString(StoreKey.legacyLocalEndpoint.id);
migrator.stage(StoreKey.legacyLocalEndpoint, SettingsKey.networkLocalEndpoint, localEndpoint);
await migrator.migrateString(StoreKey.legacyPreferredWifiName, SettingsKey.networkPreferredWifiName);
await migrator.migrateString(StoreKey.legacyLocalEndpoint, SettingsKey.networkLocalEndpoint);
await _migrateExternalEndpointList(migrator);
await _migrateCustomHeaders(migrator);
// Album
@@ -145,17 +136,7 @@ Future<void> _migrateTo26(Drift drift) async {
await migrator.complete();
}
Future<void> _migrateTo27(Drift drift) async {
final migrator = _StoreMigrator.session(drift);
await migrator.migrateString(StoreKey.legacyServerUrl, SessionKey.serverUrl);
await migrator.migrateString(StoreKey.legacyAccessToken, SessionKey.accessToken);
await migrator.migrateString(StoreKey.legacyServerEndpoint, SessionKey.serverEndpoint);
await migrator.complete();
await SessionRepository.instance.refresh();
}
Future<void> _migrateAlbumSortMode(_StoreMigrator<SettingsKey> migrator) async {
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
if (mode == null) {
@@ -165,7 +146,7 @@ Future<void> _migrateAlbumSortMode(_StoreMigrator<SettingsKey> migrator) async {
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, SettingsKey.albumSortMode, mode);
}
Future<void> _migrateExternalEndpointList(_StoreMigrator<SettingsKey> migrator) async {
Future<void> _migrateExternalEndpointList(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyExternalEndpointList.id);
if (raw == null) {
return;
@@ -189,7 +170,7 @@ Future<void> _migrateExternalEndpointList(_StoreMigrator<SettingsKey> migrator)
migrator.stage(StoreKey.legacyExternalEndpointList, SettingsKey.networkExternalEndpointList, urls);
}
Future<void> _migrateCustomHeaders(_StoreMigrator<SettingsKey> migrator) async {
Future<void> _migrateCustomHeaders(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreString(StoreKey.legacyCustomHeaders.id);
if (raw == null) {
return;
@@ -212,39 +193,14 @@ Future<void> _migrateCustomHeaders(_StoreMigrator<SettingsKey> migrator) async {
migrator.stage(StoreKey.legacyCustomHeaders, SettingsKey.networkCustomHeaders, headers);
}
class _StoreMigrator<K extends Enum> {
_StoreMigrator._(this._db, {required this.encode, required this.readDefault, required this.insertRow});
static _StoreMigrator<SettingsKey> settings(Drift db) => _StoreMigrator<SettingsKey>._(
db,
encode: (key, value) => key.encode(value),
readDefault: (key) => defaultConfig.read(key),
insertRow: (batch, name, value) => batch.insert(
db.settingsEntity,
SettingsEntityCompanion(key: Value(name), value: Value(value)),
mode: InsertMode.insertOrReplace,
),
);
static _StoreMigrator<SessionKey> session(Drift db) => _StoreMigrator<SessionKey>._(
db,
encode: (key, value) => key.encode(value),
readDefault: (key) => defaultSession.read(key),
insertRow: (batch, name, value) => batch.insert(
db.sessionEntity,
SessionEntityCompanion(key: Value(name), value: Value(value)),
mode: InsertMode.insertOrReplace,
),
);
class _StoreMigrator {
final Drift _db;
final String Function(K key, Object value) encode;
final Object? Function(K key) readDefault;
final void Function(Batch batch, String name, String? value) insertRow;
final Map<K, Object?> _cache = {};
final Map<SettingsKey<Object>, Object> _cache = {};
final List<int> _migratedStoreIds = [];
Future<void> migrateEnumIndex<T extends Enum>(StoreKey<int> legacyKey, K newKey, List<T> values) async {
_StoreMigrator(this._db);
Future<void> migrateEnumIndex<T extends Enum>(StoreKey<int> legacyKey, SettingsKey<T> newKey, List<T> values) async {
final index = await readLegacyStoreInt(legacyKey.id);
if (index == null) {
return;
@@ -259,7 +215,11 @@ class _StoreMigrator<K extends Enum> {
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateEnumName<T extends Enum>(StoreKey<String> legacyKey, K newKey, List<T> values) async {
Future<void> migrateEnumName<T extends Enum>(
StoreKey<String> legacyKey,
SettingsKey<T> newKey,
List<T> values,
) async {
final name = await readLegacyStoreString(legacyKey.id);
if (name == null) {
return;
@@ -274,17 +234,18 @@ class _StoreMigrator<K extends Enum> {
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateBool(StoreKey<bool> legacyKey, K newKey) async {
Future<void> migrateBool(StoreKey<bool> legacyKey, SettingsKey<bool> newKey) async {
final intValue = await readLegacyStoreInt(legacyKey.id);
if (intValue == null) {
return;
}
_cache[newKey] = intValue != 0;
final boolValue = intValue != 0;
_cache[newKey] = boolValue;
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateInt(StoreKey<int> legacyKey, K newKey) async {
Future<void> migrateInt(StoreKey<int> legacyKey, SettingsKey<int> newKey) async {
final intValue = await readLegacyStoreInt(legacyKey.id);
if (intValue == null) {
return;
@@ -294,9 +255,9 @@ class _StoreMigrator<K extends Enum> {
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateString(StoreKey<String> legacyKey, K newKey) async {
Future<void> migrateString(StoreKey<String> legacyKey, SettingsKey<String> newKey) async {
final value = await readLegacyStoreString(legacyKey.id);
if (value == null || value.isEmpty) {
if (value == null) {
return;
}
@@ -304,12 +265,7 @@ class _StoreMigrator<K extends Enum> {
_migratedStoreIds.add(legacyKey.id);
}
Future<void> migrateNullableString(StoreKey<String> legacyKey, K newKey) async {
_cache[newKey] = await readLegacyStoreString(legacyKey.id);
_migratedStoreIds.add(legacyKey.id);
}
void stage(StoreKey legacyKey, K newKey, Object? value) {
void stage<T extends Object>(StoreKey legacyKey, SettingsKey<T> newKey, T value) {
_cache[newKey] = value;
_migratedStoreIds.add(legacyKey.id);
}
@@ -317,12 +273,14 @@ class _StoreMigrator<K extends Enum> {
Future<void> complete() async {
await _db.batch((batch) {
for (final entry in _cache.entries) {
if (entry.value == readDefault(entry.key)) {
if (entry.value == defaultConfig.read(entry.key)) {
continue;
}
final value = entry.value;
insertRow(batch, entry.key.name, value == null ? null : encode(entry.key, value));
batch.insert(
_db.settingsEntity,
SettingsEntityCompanion(key: Value(entry.key.name), value: Value(entry.key.encode(entry.value))),
mode: InsertMode.insertOrReplace,
);
}
});
await deleteLegacyStoreRows(_migratedStoreIds);
+2 -2
View File
@@ -55,8 +55,8 @@ final class None<T> extends Option<T> {
int get hashCode => 0;
}
extension NullableOptionExtension<T> on Option<T>? {
T? patch(T? current) => this == null ? current : this!.unwrapOrNull;
extension ObjectOptionExtension<T> on T? {
Option<T> toOption() => Option.fromNullable(this);
}
extension OptionToOptional<T> on Option<T> {
+3 -2
View File
@@ -1,4 +1,5 @@
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:punycode/punycode.dart';
String sanitizeUrl(String url) {
@@ -10,7 +11,7 @@ String sanitizeUrl(String url) {
}
String? getServerUrl() {
final serverUrl = punycodeDecodeUrl(SessionRepository.instance.session.serverEndpoint);
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null) {
return null;
+3 -2
View File
@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
Widget userAvatar(BuildContext context, UserDto u, {double? radius}) {
final url = "${SessionRepository.instance.session.serverEndpoint!}/users/${u.id}/profile-image";
final url = "${Store.get(StoreKey.serverEndpoint)}/users/${u.id}/profile-image";
final nameFirstLetter = u.name.isNotEmpty ? u.name[0] : "";
return CircleAvatar(
radius: radius,
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/session.provider.dart';
// ignore: must_be_immutable
class UserCircleAvatar extends ConsumerWidget {
@@ -17,7 +18,7 @@ class UserCircleAvatar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity);
final profileImageUrl =
'${ref.read(sessionProvider).serverEndpoint}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
final textColor = (user.avatarColor.toColor().computeLuminance() > 0.5 ? Colors.black : Colors.white).withValues(
alpha: opacity,
@@ -21,13 +21,13 @@ void main() {
controller = StreamController<List<StoreDto<Object>>>.broadcast();
mockDriftStoreRepo = MockDriftStoreRepository();
// For generics, we need to provide fallback to each concrete type to avoid runtime errors
registerFallbackValue(StoreKey.legacyAccessToken);
registerFallbackValue(StoreKey.accessToken);
registerFallbackValue(StoreKey.version);
registerFallbackValue(StoreKey.advancedTroubleshooting);
when(() => mockDriftStoreRepo.getAll()).thenAnswer(
(_) async => [
const StoreDto(StoreKey.legacyAccessToken, _kAccessToken),
const StoreDto(StoreKey.accessToken, _kAccessToken),
const StoreDto(StoreKey.advancedTroubleshooting, _kAdvancedTroubleshooting),
const StoreDto(StoreKey.version, _kVersion),
],
@@ -45,35 +45,35 @@ void main() {
group("Store Service Init:", () {
test('Populates the internal cache on init', () {
verify(() => mockDriftStoreRepo.getAll()).called(1);
expect(sut.tryGet(StoreKey.legacyAccessToken), _kAccessToken);
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken);
expect(sut.tryGet(StoreKey.advancedTroubleshooting), _kAdvancedTroubleshooting);
expect(sut.tryGet(StoreKey.version), _kVersion);
// Other keys should be null
expect(sut.tryGet(StoreKey.deviceId), isNull);
expect(sut.tryGet(StoreKey.currentUser), isNull);
});
test('Listens to stream of store updates', () async {
final event = StoreDto(StoreKey.legacyAccessToken, _kAccessToken.toUpperCase());
final event = StoreDto(StoreKey.accessToken, _kAccessToken.toUpperCase());
controller.add([event]);
await pumpEventQueue();
verify(() => mockDriftStoreRepo.watchAll()).called(1);
expect(sut.tryGet(StoreKey.legacyAccessToken), _kAccessToken.toUpperCase());
expect(sut.tryGet(StoreKey.accessToken), _kAccessToken.toUpperCase());
});
});
group('Store Service get:', () {
test('Returns the stored value for the given key', () {
expect(sut.get(StoreKey.legacyAccessToken), _kAccessToken);
expect(sut.get(StoreKey.accessToken), _kAccessToken);
});
test('Throws StoreKeyNotFoundException for nonexistent keys', () {
expect(() => sut.get(StoreKey.deviceId), throwsA(isA<StoreKeyNotFoundException>()));
expect(() => sut.get(StoreKey.currentUser), throwsA(isA<StoreKeyNotFoundException>()));
});
test('Returns the stored value for the given key or the defaultValue', () {
expect(sut.get(StoreKey.legacyBackupTriggerDelay, 5), 5);
expect(sut.get(StoreKey.currentUser, 5), 5);
});
});
@@ -83,15 +83,15 @@ void main() {
});
test('Skip insert when value is not modified', () async {
await sut.put(StoreKey.legacyAccessToken, _kAccessToken);
verifyNever(() => mockDriftStoreRepo.upsert<String>(StoreKey.legacyAccessToken, any()));
await sut.put(StoreKey.accessToken, _kAccessToken);
verifyNever(() => mockDriftStoreRepo.upsert<String>(StoreKey.accessToken, any()));
});
test('Insert value when modified', () async {
final newAccessToken = _kAccessToken.toUpperCase();
await sut.put(StoreKey.legacyAccessToken, newAccessToken);
verify(() => mockDriftStoreRepo.upsert<String>(StoreKey.legacyAccessToken, newAccessToken)).called(1);
expect(sut.tryGet(StoreKey.legacyAccessToken), newAccessToken);
await sut.put(StoreKey.accessToken, newAccessToken);
verify(() => mockDriftStoreRepo.upsert<String>(StoreKey.accessToken, newAccessToken)).called(1);
expect(sut.tryGet(StoreKey.accessToken), newAccessToken);
});
});
@@ -108,7 +108,7 @@ void main() {
});
test('Watches a specific key for changes', () async {
final stream = sut.watch(StoreKey.legacyAccessToken);
final stream = sut.watch(StoreKey.accessToken);
final events = <String?>[_kAccessToken, _kAccessToken.toUpperCase(), null, _kAccessToken.toLowerCase()];
unawaited(expectLater(stream, emitsInOrder(events)));
@@ -118,7 +118,7 @@ void main() {
}
await pumpEventQueue();
verify(() => mockDriftStoreRepo.watch<String>(StoreKey.legacyAccessToken)).called(1);
verify(() => mockDriftStoreRepo.watch<String>(StoreKey.accessToken)).called(1);
});
});
@@ -128,13 +128,13 @@ void main() {
});
test('Removes the value from the DB', () async {
await sut.delete(StoreKey.legacyAccessToken);
verify(() => mockDriftStoreRepo.delete<String>(StoreKey.legacyAccessToken)).called(1);
await sut.delete(StoreKey.accessToken);
verify(() => mockDriftStoreRepo.delete<String>(StoreKey.accessToken)).called(1);
});
test('Removes the value from the cache', () async {
await sut.delete(StoreKey.legacyAccessToken);
expect(sut.tryGet(StoreKey.legacyAccessToken), isNull);
await sut.delete(StoreKey.accessToken);
expect(sut.tryGet(StoreKey.accessToken), isNull);
});
});
@@ -146,7 +146,7 @@ void main() {
test('Clears all values from the store', () async {
await sut.clear();
verify(() => mockDriftStoreRepo.deleteAll()).called(1);
expect(sut.tryGet(StoreKey.legacyAccessToken), isNull);
expect(sut.tryGet(StoreKey.accessToken), isNull);
expect(sut.tryGet(StoreKey.advancedTroubleshooting), isNull);
expect(sut.tryGet(StoreKey.version), isNull);
});
@@ -1,62 +1,78 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../fixtures/user.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../service.mock.dart';
void main() {
late UserService sut;
late UserApiRepository mockUserApiRepo;
late DriftAuthUserRepository mockAuthUserRepo;
late StoreService mockStoreService;
setUp(() {
mockUserApiRepo = MockUserApiRepository();
mockAuthUserRepo = MockDriftAuthUserRepository();
sut = UserService(userApiRepository: mockUserApiRepo, authUserRepository: mockAuthUserRepo);
mockStoreService = MockStoreService();
sut = UserService(userApiRepository: mockUserApiRepo, storeService: mockStoreService);
registerFallbackValue(UserStub.admin);
when(() => mockAuthUserRepo.get()).thenAnswer((_) async => UserStub.admin);
when(() => mockAuthUserRepo.upsert(any())).thenAnswer((_) async => UserStub.admin);
when(() => mockStoreService.get(StoreKey.currentUser)).thenReturn(UserStub.admin);
when(() => mockStoreService.tryGet(StoreKey.currentUser)).thenReturn(UserStub.admin);
});
group('tryGetMyUser', () {
test('should return the current user from the auth user repository', () async {
final result = await sut.tryGetMyUser();
group('getMyUser', () {
test('should return user from store', () {
final result = sut.getMyUser();
expect(result, UserStub.admin);
});
test('should return null if no user is logged in', () async {
when(() => mockAuthUserRepo.get()).thenAnswer((_) async => null);
final result = await sut.tryGetMyUser();
test('should handle user not found scenario', () {
when(() => mockStoreService.get(StoreKey.currentUser)).thenThrow(Exception('User not found'));
expect(() => sut.getMyUser(), throwsA(isA<Exception>()));
});
});
group('tryGetMyUser', () {
test('should return user from store', () {
final result = sut.tryGetMyUser();
expect(result, UserStub.admin);
});
test('should return null if user not found', () {
when(() => mockStoreService.tryGet(StoreKey.currentUser)).thenReturn(null);
final result = sut.tryGetMyUser();
expect(result, isNull);
});
});
group('watchMyUser', () {
test('should return the current user stream from the auth user repository', () {
when(() => mockAuthUserRepo.watch()).thenAnswer((_) => Stream.value(UserStub.admin));
test('should return user stream from store', () {
when(() => mockStoreService.watch(StoreKey.currentUser)).thenAnswer((_) => Stream.value(UserStub.admin));
final result = sut.watchMyUser();
expect(result, emits(UserStub.admin));
});
test('should return an empty stream if no user is logged in', () {
when(() => mockAuthUserRepo.watch()).thenAnswer((_) => const Stream.empty());
test('should return an empty stream if user not found', () {
when(() => mockStoreService.watch(StoreKey.currentUser)).thenAnswer((_) => const Stream.empty());
final result = sut.watchMyUser();
expect(result, emitsInOrder([]));
});
});
group('refreshMyUser', () {
test('should return user from api and persist it', () async {
test('should return user from api and store it', () async {
when(() => mockUserApiRepo.getMyUser()).thenAnswer((_) async => UserStub.admin);
when(() => mockStoreService.put(StoreKey.currentUser, UserStub.admin)).thenAnswer((_) async => true);
final result = await sut.refreshMyUser();
verify(() => mockAuthUserRepo.upsert(UserStub.admin)).called(1);
verify(() => mockStoreService.put(StoreKey.currentUser, UserStub.admin)).called(1);
expect(result, UserStub.admin);
});
@@ -64,7 +80,7 @@ void main() {
when(() => mockUserApiRepo.getMyUser()).thenAnswer((_) async => null);
final result = await sut.refreshMyUser();
verifyNever(() => mockAuthUserRepo.upsert(any()));
verifyNever(() => mockStoreService.put(StoreKey.currentUser, UserStub.admin));
expect(result, isNull);
});
});
@@ -72,26 +88,29 @@ void main() {
group('createProfileImage', () {
test('should return profile image path', () async {
const profileImagePath = 'profile.jpg';
final updatedUser = UserStub.admin;
when(
() => mockUserApiRepo.createProfileImage(name: profileImagePath, data: Uint8List(0)),
).thenAnswer((_) async => profileImagePath);
when(() => mockStoreService.put(StoreKey.currentUser, updatedUser)).thenAnswer((_) async => true);
final result = await sut.createProfileImage(profileImagePath, Uint8List(0));
verify(() => mockAuthUserRepo.upsert(UserStub.admin)).called(1);
verify(() => mockStoreService.put(StoreKey.currentUser, updatedUser)).called(1);
expect(result, profileImagePath);
});
test('should return null if profile image creation fails', () async {
const profileImagePath = 'profile.jpg';
final updatedUser = UserStub.admin;
when(
() => mockUserApiRepo.createProfileImage(name: profileImagePath, data: Uint8List(0)),
).thenThrow(Exception('Failed to create profile image'));
final result = await sut.createProfileImage(profileImagePath, Uint8List(0));
verifyNever(() => mockAuthUserRepo.upsert(any()));
verifyNever(() => mockStoreService.put(StoreKey.currentUser, updatedUser));
expect(result, isNull);
});
});
-8
View File
@@ -33,8 +33,6 @@ import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
import 'schema_v29.dart' as v29;
import 'schema_v30.dart' as v30;
import 'schema_v31.dart' as v31;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -98,10 +96,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v28.DatabaseAtV28(db);
case 29:
return v29.DatabaseAtV29(db);
case 30:
return v30.DatabaseAtV30(db);
case 31:
return v31.DatabaseAtV31(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -137,7 +131,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
27,
28,
29,
30,
31,
];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,13 +4,17 @@ import 'package:drift/drift.dart' hide isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import '../../fixtures/user.stub.dart';
const _kTestAccessToken = "#TestToken";
const _kTestVersion = 10;
const _kTestAdvancedTroubleshooting = false;
final _kTestUser = UserStub.admin;
Future<void> _populateStore(Drift db) async {
await db.batch((batch) async {
@@ -25,7 +29,7 @@ Future<void> _populateStore(Drift db) async {
batch.insert(
db.storeEntity,
StoreEntityCompanion(
id: Value(StoreKey.legacyAccessToken.id),
id: Value(StoreKey.accessToken.id),
intValue: const Value(null),
stringValue: const Value(_kTestAccessToken),
),
@@ -64,10 +68,10 @@ void main() {
});
test('converts string', () async {
String? accessToken = await sut.tryGet(StoreKey.legacyAccessToken);
String? accessToken = await sut.tryGet(StoreKey.accessToken);
expect(accessToken, isNull);
await sut.upsert(StoreKey.legacyAccessToken, _kTestAccessToken);
accessToken = await sut.tryGet(StoreKey.legacyAccessToken);
await sut.upsert(StoreKey.accessToken, _kTestAccessToken);
accessToken = await sut.tryGet(StoreKey.accessToken);
expect(accessToken, _kTestAccessToken);
});
@@ -78,6 +82,14 @@ void main() {
advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting);
expect(advancedTroubleshooting, _kTestAdvancedTroubleshooting);
});
test('converts user', () async {
UserDto? user = await sut.tryGet(StoreKey.currentUser);
expect(user, isNull);
await sut.upsert(StoreKey.currentUser, _kTestUser);
user = await sut.tryGet(StoreKey.currentUser);
expect(user, _kTestUser);
});
});
group('Store Repository Deletes:', () {
@@ -135,12 +147,12 @@ void main() {
emitsInOrder([
[
const StoreDto<Object>(StoreKey.version, _kTestVersion),
const StoreDto<Object>(StoreKey.legacyAccessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting),
],
[
const StoreDto<Object>(StoreKey.version, _kTestVersion + 10),
const StoreDto<Object>(StoreKey.legacyAccessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.accessToken, _kTestAccessToken),
const StoreDto<Object>(StoreKey.advancedTroubleshooting, _kTestAdvancedTroubleshooting),
],
]),
@@ -48,8 +48,6 @@ class MockSyncMigrationRepository extends Mock implements SyncMigrationRepositor
class MockUserRepository extends Mock implements UserRepository {}
class MockDriftAuthUserRepository extends Mock implements DriftAuthUserRepository {}
class MockPartnerRepository extends Mock implements PartnerRepository {}
// API Repos
@@ -456,7 +456,7 @@ void main() {
test('does not update when longitude does not match', () async {
final remoteAsset = await ctx.newRemoteAsset(ownerId: userId);
final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id, longitude: .fromNullable((-74.006)));
final cloudIdAsset = await ctx.newRemoteAssetCloudId(id: remoteAsset.id, longitude: (-74.006).toOption());
final localAsset = await ctx.newLocalAsset(
checksumOption: const Option.none(),
iCloudId: cloudIdAsset.cloudId,
@@ -44,6 +44,94 @@ void main() {
});
});
group('getAll', () {
test('returns album when all of its assets are trashed', () async {
final user = await ctx.newUser();
final album = await ctx.newRemoteAlbum(ownerId: user.id);
final asset1 = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: DateTime(2025, 1, 1));
final asset2 = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: DateTime(2025, 1, 1));
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: asset1.id);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: asset2.id);
final albums = await sut.getAll();
expect(albums, hasLength(1));
expect(albums.first.id, album.id);
expect(albums.first.assetCount, 0);
});
test('excludes trashed assets from assetCount', () async {
final user = await ctx.newUser();
final album = await ctx.newRemoteAlbum(ownerId: user.id);
final active1 = await ctx.newRemoteAsset(ownerId: user.id);
final active2 = await ctx.newRemoteAsset(ownerId: user.id);
final trashed = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: DateTime(2025, 1, 1));
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: active1.id);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: active2.id);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: trashed.id);
final albums = await sut.getAll();
expect(albums, hasLength(1));
expect(albums.first.assetCount, 2);
});
test('returns album without assets', () async {
final user = await ctx.newUser();
final album = await ctx.newRemoteAlbum(ownerId: user.id);
final albums = await sut.getAll();
expect(albums, hasLength(1));
expect(albums.first.id, album.id);
expect(albums.first.assetCount, 0);
});
});
group('get', () {
test('returns the album when all of its assets are trashed', () async {
final user = await ctx.newUser();
final album = await ctx.newRemoteAlbum(ownerId: user.id);
final asset = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: DateTime(2025, 1, 1));
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.get(album.id);
expect(result, isNotNull);
expect(result?.id, album.id);
expect(result?.assetCount, 0);
});
});
group('getAlbumsContainingAsset', () {
test('excludes trashed assets from assetCount', () async {
final user = await ctx.newUser();
final album = await ctx.newRemoteAlbum(ownerId: user.id);
final asset = await ctx.newRemoteAsset(ownerId: user.id);
final trashed = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: DateTime(2025, 1, 1));
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: asset.id);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: trashed.id);
final albums = await sut.getAlbumsContainingAsset(asset.id);
expect(albums, hasLength(1));
expect(albums.first.id, album.id);
expect(albums.first.assetCount, 1);
});
test('returns albums for a trashed asset', () async {
final user = await ctx.newUser();
final album = await ctx.newRemoteAlbum(ownerId: user.id);
final trashed = await ctx.newRemoteAsset(ownerId: user.id, deletedAt: DateTime(2025, 1, 1));
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: trashed.id);
final albums = await sut.getAlbumsContainingAsset(trashed.id);
expect(albums, hasLength(1));
expect(albums.first.assetCount, 0);
});
});
group('getSortedAlbumIds', () {
late String userId;
@@ -1,118 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/infrastructure/entities/session.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late SessionRepository sut;
setUpAll(() async {
ctx = MediumRepositoryContext();
sut = await SessionRepository.ensureInitialized(ctx.db);
});
tearDownAll(() async {
await ctx.dispose();
});
setUp(() async {
await ctx.db.delete(ctx.db.sessionEntity).go();
await SessionRepository.instance.refresh();
});
group('defaults', () {
test('session returns null fields when DB is empty', () {
expect(sut.session.serverUrl, isNull);
expect(sut.session.accessToken, isNull);
expect(sut.session.serverEndpoint, isNull);
});
});
group('write', () {
test('persists a value and reflects it in the composed view', () async {
await sut.write(.serverEndpoint, 'https://demo.immich.app/api');
expect(sut.session.serverEndpoint, 'https://demo.immich.app/api');
});
test('persists across keys independently', () async {
await sut.write(.serverUrl, 'https://demo.immich.app');
await sut.write(.accessToken, 'token-123');
expect(sut.session.serverUrl, 'https://demo.immich.app');
expect(sut.session.accessToken, 'token-123');
expect(sut.session.serverEndpoint, isNull);
});
});
group('null values', () {
test('a stored NULL value column decodes to null on refresh', () async {
await ctx.db
.into(ctx.db.sessionEntity)
.insert(
SessionEntityCompanion.insert(
key: SessionKey.accessToken.name,
value: const .new(null),
updatedAt: .new(DateTime.now()),
),
);
await SessionRepository.instance.refresh();
expect(sut.session.accessToken, isNull);
});
});
group('sync', () {
test('picks up rows that were inserted directly into the DB', () async {
await ctx.db
.into(ctx.db.sessionEntity)
.insert(
SessionEntityCompanion.insert(
key: SessionKey.serverEndpoint.name,
value: const .new('https://demo.immich.app/api'),
updatedAt: .new(DateTime.now()),
),
);
expect(sut.session.serverEndpoint, isNull);
await SessionRepository.instance.refresh();
expect(sut.session.serverEndpoint, 'https://demo.immich.app/api');
});
test('drops cached values for rows that were deleted out from under the repo', () async {
await sut.write(.serverEndpoint, 'https://demo.immich.app/api');
await ctx.db.delete(ctx.db.sessionEntity).go();
expect(sut.session.serverEndpoint, 'https://demo.immich.app/api');
await SessionRepository.instance.refresh();
expect(sut.session.serverEndpoint, isNull);
});
test('skips rows whose key is unknown to SessionKey', () async {
await ctx.db
.into(ctx.db.sessionEntity)
.insert(
SessionEntityCompanion.insert(
key: 'session.unknown.future-key',
value: const .new('unknown'),
updatedAt: .new(DateTime.now()),
),
);
await SessionRepository.instance.refresh();
expect(sut.session.serverEndpoint, isNull);
});
});
group('watch', () {
test('watchSession emits the new value after a write', () async {
final expectation = expectLater(
sut.watch().map((s) => s.serverEndpoint),
emitsThrough('https://demo.immich.app/api/watch'),
);
await sut.write(SessionKey.serverEndpoint, 'https://demo.immich.app/api/watch');
await expectation;
});
});
}
@@ -1,3 +1,4 @@
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
@@ -60,39 +61,6 @@ void main() {
});
});
group('null values', () {
test('writing null to a nullable key clears the row and reverts the cache to null', () async {
await sut.write(SettingsKey.networkPreferredWifiName, 'home-wifi');
expect(sut.appConfig.network.preferredWifiName, 'home-wifi');
expect(await ctx.db.select(ctx.db.settingsEntity).get(), hasLength(1));
await sut.write(SettingsKey.networkPreferredWifiName, null);
expect(await ctx.db.select(ctx.db.settingsEntity).get(), isEmpty);
expect(sut.appConfig.network.preferredWifiName, isNull);
});
test('writing null to an already-null key is a no-op', () async {
await sut.write(SettingsKey.networkPreferredWifiName, null);
expect(await ctx.db.select(ctx.db.settingsEntity).get(), isEmpty);
});
test('a stored NULL value column decodes to null on refresh', () async {
await ctx.db
.into(ctx.db.settingsEntity)
.insert(
SettingsEntityCompanion.insert(
key: SettingsKey.networkPreferredWifiName.name,
value: const .new(null),
updatedAt: .new(DateTime.now()),
),
);
await SettingsRepository.instance.refresh();
expect(sut.appConfig.network.preferredWifiName, isNull);
});
});
group('delete', () {});
group('sync', () {
@@ -102,8 +70,8 @@ void main() {
.insert(
SettingsEntityCompanion.insert(
key: SettingsKey.themeMode.name,
value: .new(ThemeMode.dark.name),
updatedAt: .new(DateTime.now()),
value: ThemeMode.dark.name,
updatedAt: Value(DateTime.now()),
),
);
@@ -130,8 +98,8 @@ void main() {
.insert(
SettingsEntityCompanion.insert(
key: 'app-config.unknown.future-key',
value: const .new('unknown'),
updatedAt: .new(DateTime.now()),
value: 'whatever',
updatedAt: Value(DateTime.now()),
),
);
@@ -142,13 +110,13 @@ void main() {
group('watch', () {
test('watchAppConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watch().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
final expectation = expectLater(sut.watchConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
await sut.write(SettingsKey.themeMode, ThemeMode.dark);
await expectation;
});
test('watchConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watch().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
final expectation = expectLater(sut.watchConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
await sut.write(SettingsKey.logLevel, LogLevel.warning);
await expectation;
});
@@ -60,7 +60,7 @@ void main() {
when(() => actionService.editDateTime(any(), any())).thenAnswer((_) async => true);
when(() => assetService.watchAsset(any())).thenAnswer((_) => const Stream.empty());
when(() => assetService.getExif(any())).thenAnswer((_) async => null);
when(() => userService.tryGetMyUser()).thenAnswer((_) async => _user);
when(() => userService.tryGetMyUser()).thenReturn(_user);
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
container = ProviderContainer(
@@ -4,7 +4,6 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/services/auth.service.dart';
@@ -45,7 +44,6 @@ void main() {
WidgetsFlutterBinding.ensureInitialized();
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await SessionRepository.ensureInitialized(db);
});
tearDownAll(() async {
@@ -7,12 +7,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
@@ -41,8 +39,8 @@ void main() {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await SettingsRepository.ensureInitialized(db);
await SessionRepository.ensureInitialized(db);
await SessionRepository.instance.write(SessionKey.serverEndpoint, 'https://demo.immich.app');
await Store.put(StoreKey.serverEndpoint, 'http://test-server.com');
await Store.put(StoreKey.deviceId, 'test-device-id');
});
+2 -4
View File
@@ -5,11 +5,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/session.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/session.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import '../test_utils.dart';
@@ -26,8 +25,7 @@ class PresentationContext {
if (_db == null) {
final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false);
await SessionRepository.ensureInitialized(db);
await SessionRepository.instance.write(SessionKey.serverEndpoint, serverEndpoint);
await StoreService.I.put(StoreKey.serverEndpoint, serverEndpoint);
_db = db;
}
return const PresentationContext._();
@@ -5,12 +5,8 @@ import 'package:immich_mobile/domain/models/settings_key.dart';
void main() {
group('SettingsKey', () {
for (final key in SettingsKey.values) {
final defaultValue = defaultConfig.read(key);
// null is a valid value for some keys but we don't use the codec in that case
if (defaultValue == null) {
continue;
}
test('verify codec for $key', () {
final defaultValue = defaultConfig.read(key);
final encoded = key.encode(defaultValue);
final decoded = key.decode(encoded);
expect(decoded, defaultValue, reason: 'round-trip failed for ${key.name}');
+9 -15
View File
@@ -100,23 +100,17 @@ void main() {
});
});
group('NullableOptionExtension', () {
test('patch returns the current value when the option is null', () {
const Option<String>? omitted = null;
expect(omitted.patch('existing'), 'existing');
group('ObjectOptionExtension', () {
test('non-null value.toOption() returns Some', () {
final option = 'hello'.toOption();
expect(option, isA<Some<String>>());
expect((option as Some).value, 'hello');
});
test('patch preserves a null current when the option is null', () {
const Option<String>? omitted = null;
expect(omitted.patch(null), isNull);
});
test('patch returns the wrapped value for Some, overriding current', () {
expect(const Option.some('new').patch('existing'), 'new');
});
test('patch returns null for None, clearing current', () {
expect(const Option<String>.none().patch('existing'), isNull);
test('null value.toOption() returns None', () {
const String? value = null;
final option = value.toOption();
expect(option, isA<None<String>>());
});
});
}
@@ -1,99 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/value_codec.dart';
enum _Fruit { apple, banana, cherry }
void main() {
group('MapCodec', () {
group('encode', () {
test('serializes an empty map to an empty JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.encode({}), '{}');
});
test('encodes a string-to-string map as a JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.encode({'a': '1', 'b': '2'}), '{"a":"1","b":"2"}');
});
test('stringifies non-string values via the value codec', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.encode({'x': 10, 'y': 20}), '{"x":"10","y":"20"}');
});
test('stringifies non-string keys via the key codec', () {
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
expect(codec.encode({1: 'one', 2: 'two'}), '{"1":"one","2":"two"}');
});
});
group('decode', () {
test('reconstructs a string-to-string map', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{"a":"1","b":"2"}'), {'a': '1', 'b': '2'});
});
test('parses values back to their domain type', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":"10","y":"20"}'), {'x': 10, 'y': 20});
});
test('parses keys back to their domain type', () {
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
expect(codec.decode('{"1":"one","2":"two"}'), {1: 'one', 2: 'two'});
});
test('returns an empty map for an empty JSON object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{}'), isEmpty);
});
test('returns an empty map when the payload is not valid JSON', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('not json'), isEmpty);
});
test('returns an empty map when the JSON root is not an object', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('[]'), isEmpty);
expect(codec.decode('"a string"'), isEmpty);
expect(codec.decode('42'), isEmpty);
});
test('skips entries whose value is not a JSON string, keeping the rest', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":1,"y":"20"}'), {'y': 20});
});
test('skips entries whose value is a nested object, keeping the rest', () {
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
expect(codec.decode('{"a":{"nested":"value"},"b":"ok"}'), {'b': 'ok'});
});
test('returns an empty map when every entry is malformed', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
expect(codec.decode('{"x":1,"y":2}'), isEmpty);
});
});
group('round trip', () {
test('preserves a primitive map through encode then decode', () {
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
const original = {'one': 1, 'two': 2, 'three': 3};
expect(codec.decode(codec.encode(original)), original);
});
test('preserves an enum-valued map by composing with EnumCodec', () {
const codec = MapCodec<String, _Fruit>(PrimitiveCodec.string, EnumCodec(_Fruit.values));
const original = {'breakfast': _Fruit.banana, 'snack': _Fruit.apple};
expect(codec.decode(codec.encode(original)), original);
});
test('preserves a DateTime-valued map by composing with DateTimeCodec', () {
const codec = MapCodec<String, DateTime>(PrimitiveCodec.string, DateTimeCodec());
final original = {'created': DateTime.utc(2024, 1, 1, 12, 30)};
expect(codec.decode(codec.encode(original)), original);
});
});
});
}
+1 -1
View File
@@ -11,7 +11,7 @@
"release": "./misc/release/pump-version.sh",
"pump": "node ./misc/release/pump-wrapper.js"
},
"packageManager": "pnpm@11.4.0",
"packageManager": "pnpm@11.5.2",
"engines": {
"pnpm": ">=10.0.0"
},
+1 -1
View File
@@ -29,7 +29,7 @@
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"commander": "^15.0.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
+1 -1
View File
@@ -13,5 +13,5 @@
"oidc-provider": "^9.0.0",
"tsx": "^4.20.6"
},
"packageManager": "pnpm@11.4.0"
"packageManager": "pnpm@11.5.2"
}
+1 -1
View File
@@ -24,7 +24,7 @@
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
"packageManager": "pnpm@11.4.0",
"packageManager": "pnpm@11.5.2",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
+1230 -1135
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -105,7 +105,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^4.0.0",
"rollup-plugin-visualizer": "^7.0.0",
"svelte": "5.55.8",
"svelte": "5.56.2",
"svelte-check": "^4.4.6",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.2.4",
+34 -43
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import type { CalendarHeatmapResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { Text } from '@immich/ui';
import { DateTime, Info } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
@@ -12,20 +13,8 @@
const { data, itemLabel, totalLabel }: Props = $props();
const { rows } = $derived.by(() => {
const weeks = Array.from({ length: Math.ceil(data.series.length / 7) }, (_, index) =>
data.series.slice(index * 7, index * 7 + 7),
);
const rows = Array.from({ length: 7 }, (_, dayIndex) => weeks.map((week) => week[dayIndex]).filter(Boolean));
const endDate = DateTime.fromISO(data.to, { zone: 'utc' });
const months = Array.from({ length: 4 }, (_, index) =>
endDate.minus({ months: 11 - index * 4 }).toLocaleString({ month: 'short' }, { locale: $locale }),
);
return { rows, months };
});
const startDate = $derived(DateTime.fromISO(data.from, { zone: 'utc' }));
const padding = $derived(startDate.diff(startDate.startOf('week', { useLocaleWeeks: true })).as('days'));
const maxCount = $derived(Math.max(...data.series.map((item) => item.count), 0));
@@ -49,45 +38,47 @@
return 'bg-immich-primary';
};
// const dayLabels = $derived([
// '',
// dayOfWeek('monday', { locale: $locale, style: 'short' }),
// '',
// dayOfWeek('wednesday', { locale: $locale, style: 'short' }),
// '',
// dayOfWeek('friday', { locale: $locale, style: 'short' }),
// '',
// ]);
const weekdays = $derived([
Info.weekdays('short', { locale: $locale })[0],
Info.weekdays('short', { locale: $locale })[2],
Info.weekdays('short', { locale: $locale })[4],
Info.weekdays('short', { locale: $locale })[6],
]);
</script>
<div class="mt-4 w-full">
<div class="relative w-full">
<!-- TODO -->
<!-- <div class="absolute top-4 left-0 flex flex-col gap-0.5">
{#each dayLabels as dayLabel, i (i)}
<div class="relative flex h-3 w-6 items-center text-xs text-gray-500 dark:text-gray-400">
{dayLabel}
</div>
{/each}
</div> -->
<!-- <div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
{#each getUploadActivityMonths() as month (month)}
<div>{month}</div>
{/each}
</div> -->
<div class="grid grid-rows-7 gap-0.5">
{#each rows as row, dayIndex (dayIndex)}
<div class="grid grid-cols-52 gap-0.5">
{#each row as day (day.date)}
<div
class="aspect-square w-full min-w-0 rounded-sm {itemColors(day.count)}"
title={itemLabel({ date: day.date, count: day.count })}
aria-label={itemLabel({ date: day.date, count: day.count })}
></div>
{/each}
</div>
<div class="grid grid-flow-col grid-rows-7 gap-0.5">
<div class="row-span-7 grid grid-rows-subgrid">
{#if Info.getStartOfWeek({ locale: $locale }) === 7}
<div></div>
{/if}
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[0]}</Text></div>
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[1]}</Text></div>
<div class="row-span-2 -mt-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[2]}</Text></div>
{#if Info.getStartOfWeek({ locale: $locale }) === 1}
<div class="-my-1"><Text size="tiny" class="mr-0.5 font-mono">{weekdays[3]}</Text></div>
{/if}
</div>
{#each data.series as day, idx (day.date)}
{@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString(
{ month: 'short', day: 'numeric' },
{ locale: $locale },
)}
<div
class="aspect-square size-full rounded-sm {itemColors(day.count)} row-start-(--heatmap-row-start)"
style:--heatmap-row-start={idx === 0 ? padding + 1 : undefined}
title={itemLabel({ date, count: day.count })}
aria-label={itemLabel({ date, count: day.count })}
></div>
{/each}
</div>
+6
View File
@@ -9,6 +9,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
// case WorkflowTrigger.PersonRecognized: {
// return $t('trigger_person_recognized');
// }
case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction');
}
default: {
return type;
}
@@ -23,6 +26,9 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
// case WorkflowTrigger.PersonRecognized: {
// return $t('trigger_person_recognized_description');
// }
case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction_description');
}
default: {
return type;
}