Compare commits

..

3 Commits

Author SHA1 Message Date
shenlong-tanwen
b597c9521d cleanup 2026-04-29 07:54:06 +07:00
shenlong-tanwen
620ca2d78d refactor to per row store 2026-04-29 02:11:46 +07:00
shenlong-tanwen
af0f4e3cc8 refactor: app metadata 2026-04-27 21:09:34 +05:30
40 changed files with 14295 additions and 219 deletions

View File

@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \

View File

@@ -9,12 +9,12 @@ dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"gunicorn>=21.1.0",
"huggingface-hub>=1.0,<2.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",
"numpy<2.4.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=12.2,<13",
"pillow>=12.2,<12.3",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import 'package:immich_mobile/domain/models/config/theme_config.dart';
class AppConfig {
final ThemeConfig theme;
const AppConfig({this.theme = const ThemeConfig()});
AppConfig copyWith({ThemeConfig? theme}) => .new(theme: theme ?? this.theme);
@override
bool operator ==(Object other) => identical(this, other) || (other is AppConfig && other.theme == theme);
@override
int get hashCode => theme.hashCode;
@override
String toString() => 'AppConfig(theme: $theme)';
}

View File

@@ -0,0 +1,18 @@
import 'package:immich_mobile/domain/models/log.model.dart';
class SystemConfig {
final LogLevel logLevel;
const SystemConfig({this.logLevel = .info});
SystemConfig copyWith({LogLevel? logLevel}) => SystemConfig(logLevel: logLevel ?? this.logLevel);
@override
bool operator ==(Object other) => identical(this, other) || (other is SystemConfig && other.logLevel == logLevel);
@override
int get hashCode => logLevel.hashCode;
@override
String toString() => 'SystemConfig(logLevel: $logLevel)';
}

View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
class ThemeConfig {
final ThemeMode mode;
const ThemeConfig({this.mode = .system});
ThemeConfig copyWith({ThemeMode? mode}) => .new(mode: mode ?? this.mode);
@override
bool operator ==(Object other) => identical(this, other) || (other is ThemeConfig && other.mode == mode);
@override
int get hashCode => mode.hashCode;
@override
String toString() => 'ThemeConfig(mode: $mode)';
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
enum MetadataDomain {
appConfig('app-config'),
systemConfig('system-config');
final String prefix;
const MetadataDomain(this.prefix);
}
enum MetadataKey<T extends Object> {
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, ThemeMode.values),
logLevel<LogLevel>(.systemConfig, 'log.level', .info, LogLevel.values);
final MetadataDomain domain;
final String name;
final T defaultValue;
final List<T>? enumValues;
const MetadataKey(this.domain, this.name, this.defaultValue, [this.enumValues]);
String get key => '${domain.prefix}.$name';
static MetadataKey<Object>? fromKey(String key) {
for (final m in MetadataKey.values) {
if (m.key == key) return m;
}
return null;
}
}

View File

@@ -22,7 +22,7 @@ enum StoreKey<T> {
// user settings from [AppSettingsEnum] below:
loadPreview<bool>._(100),
loadOriginal<bool>._(101),
themeMode<String>._(102),
// id 102 (themeMode) moved to user_config.theme-mode
tilesPerRow<int>._(103),
dynamicLayout<bool>._(104),
groupAssetsBy<int>._(105),
@@ -35,7 +35,7 @@ enum StoreKey<T> {
albumThumbnailCacheSize<int>._(112),
selectedAlbumSortOrder<int>._(113),
advancedTroubleshooting<bool>._(114),
logLevel<int>._(115),
// id 115 (logLevel) moved to app_metadata.log-level
preferRemoteImage<bool>._(116),
loopVideo<bool>._(117),
// map related settings

View File

@@ -2,20 +2,20 @@ import 'dart:async';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
/// Service responsible for handling application logging.
///
/// It listens to Dart's [Logger.root], buffers logs in memory (optionally),
/// writes them to a persistent [ILogRepository], and manages log levels
/// via [IStoreRepository]
/// writes them to a persistent [LogRepository], and manages log levels via
/// [MetadataRepository].
class LogService {
final LogRepository _logRepository;
final DriftStoreRepository _storeRepository;
final MetadataRepository _metadataRepository;
final List<LogMessage> _msgBuffer = [];
@@ -38,12 +38,12 @@ class LogService {
static Future<LogService> init({
required LogRepository logRepository,
required DriftStoreRepository storeRepository,
required MetadataRepository metadataRepository,
bool shouldBuffer = true,
}) async {
_instance ??= await create(
logRepository: logRepository,
storeRepository: storeRepository,
metadataRepository: metadataRepository,
shouldBuffer: shouldBuffer,
);
return _instance!;
@@ -51,17 +51,17 @@ class LogService {
static Future<LogService> create({
required LogRepository logRepository,
required DriftStoreRepository storeRepository,
required MetadataRepository metadataRepository,
bool shouldBuffer = true,
}) async {
final instance = LogService._(logRepository, storeRepository, shouldBuffer);
final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
await logRepository.truncate(limit: kLogTruncateLimit);
final level = await instance._storeRepository.tryGet(StoreKey.logLevel) ?? LogLevel.info.index;
Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO;
final level = instance._metadataRepository.systemConfig.logLevel;
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
return instance;
}
LogService._(this._logRepository, this._storeRepository, this._shouldBuffer) {
LogService._(this._logRepository, this._metadataRepository, this._shouldBuffer) {
_logSubscription = Logger.root.onRecord.listen(_handleLogRecord);
}
@@ -91,7 +91,7 @@ class LogService {
}
Future<void> setLogLevel(LogLevel level) async {
await _storeRepository.upsert(StoreKey.logLevel, level.index);
await _metadataRepository.write(MetadataKey.logLevel, level);
Logger.root.level = level.toLevel();
}

View File

@@ -0,0 +1,18 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class MetadataEntity extends Table with DriftDefaultsMixin {
const MetadataEntity();
TextColumn get key => text()();
TextColumn get value => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {key};
@override
String get tableName => "metadata";
}

View File

@@ -0,0 +1,429 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/metadata.entity.dart'
as i2;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
typedef $$MetadataEntityTableCreateCompanionBuilder =
i1.MetadataEntityCompanion Function({
required String key,
required String value,
i0.Value<DateTime> updatedAt,
});
typedef $$MetadataEntityTableUpdateCompanionBuilder =
i1.MetadataEntityCompanion Function({
i0.Value<String> key,
i0.Value<String> value,
i0.Value<DateTime> updatedAt,
});
class $$MetadataEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$MetadataEntityTable> {
$$MetadataEntityTableFilterComposer({
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 $$MetadataEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$MetadataEntityTable> {
$$MetadataEntityTableOrderingComposer({
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 $$MetadataEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$MetadataEntityTable> {
$$MetadataEntityTableAnnotationComposer({
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 $$MetadataEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$MetadataEntityTable,
i1.MetadataEntityData,
i1.$$MetadataEntityTableFilterComposer,
i1.$$MetadataEntityTableOrderingComposer,
i1.$$MetadataEntityTableAnnotationComposer,
$$MetadataEntityTableCreateCompanionBuilder,
$$MetadataEntityTableUpdateCompanionBuilder,
(
i1.MetadataEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$MetadataEntityTable,
i1.MetadataEntityData
>,
),
i1.MetadataEntityData,
i0.PrefetchHooks Function()
> {
$$MetadataEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$MetadataEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$MetadataEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$MetadataEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () => i1
.$$MetadataEntityTableAnnotationComposer($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.MetadataEntityCompanion(
key: key,
value: value,
updatedAt: updatedAt,
),
createCompanionCallback:
({
required String key,
required String value,
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.MetadataEntityCompanion.insert(
key: key,
value: value,
updatedAt: updatedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$MetadataEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$MetadataEntityTable,
i1.MetadataEntityData,
i1.$$MetadataEntityTableFilterComposer,
i1.$$MetadataEntityTableOrderingComposer,
i1.$$MetadataEntityTableAnnotationComposer,
$$MetadataEntityTableCreateCompanionBuilder,
$$MetadataEntityTableUpdateCompanionBuilder,
(
i1.MetadataEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$MetadataEntityTable,
i1.MetadataEntityData
>,
),
i1.MetadataEntityData,
i0.PrefetchHooks Function()
>;
class $MetadataEntityTable extends i2.MetadataEntity
with i0.TableInfo<$MetadataEntityTable, i1.MetadataEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$MetadataEntityTable(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,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
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 = 'metadata';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.MetadataEntityData> 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),
);
} else if (isInserting) {
context.missing(_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.MetadataEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.MetadataEntityData(
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
$MetadataEntityTable createAlias(String alias) {
return $MetadataEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class MetadataEntityData extends i0.DataClass
implements i0.Insertable<i1.MetadataEntityData> {
final String key;
final String value;
final DateTime updatedAt;
const MetadataEntityData({
required this.key,
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);
map['value'] = i0.Variable<String>(value);
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
return map;
}
factory MetadataEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return MetadataEntityData(
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.MetadataEntityData copyWith({
String? key,
String? value,
DateTime? updatedAt,
}) => i1.MetadataEntityData(
key: key ?? this.key,
value: value ?? this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
MetadataEntityData copyWithCompanion(i1.MetadataEntityCompanion data) {
return MetadataEntityData(
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('MetadataEntityData(')
..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.MetadataEntityData &&
other.key == this.key &&
other.value == this.value &&
other.updatedAt == this.updatedAt);
}
class MetadataEntityCompanion
extends i0.UpdateCompanion<i1.MetadataEntityData> {
final i0.Value<String> key;
final i0.Value<String> value;
final i0.Value<DateTime> updatedAt;
const MetadataEntityCompanion({
this.key = const i0.Value.absent(),
this.value = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
});
MetadataEntityCompanion.insert({
required String key,
required String value,
this.updatedAt = const i0.Value.absent(),
}) : key = i0.Value(key),
value = i0.Value(value);
static i0.Insertable<i1.MetadataEntityData> 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.MetadataEntityCompanion copyWith({
i0.Value<String>? key,
i0.Value<String>? value,
i0.Value<DateTime>? updatedAt,
}) {
return i1.MetadataEntityCompanion(
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('MetadataEntityCompanion(')
..write('key: $key, ')
..write('value: $value, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
}

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
@@ -53,6 +54,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.da
StoreEntity,
TrashedLocalAssetEntity,
AssetEditEntity,
MetadataEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -84,7 +86,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 24;
int get schemaVersion => 25;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -250,6 +252,9 @@ class Drift extends $Drift {
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
await m.alterTable(TableMigration(v24.remoteAlbumEntity));
},
from24To25: (m, v25) async {
await m.createTable(v25.metadata);
},
),
);

View File

@@ -43,9 +43,11 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity
as i20;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i21;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'
as i22;
import 'package:drift/internal/modular.dart' as i23;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i23;
import 'package:drift/internal/modular.dart' as i24;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -89,9 +91,12 @@ abstract class $Drift extends i0.GeneratedDatabase {
.$TrashedLocalAssetEntityTable(this);
late final i21.$AssetEditEntityTable assetEditEntity = i21
.$AssetEditEntityTable(this);
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
late final i22.$MetadataEntityTable metadataEntity = i22.$MetadataEntityTable(
this,
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
);
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
this,
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -129,6 +134,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
metadataEntity,
i10.idxPartnerSharedWithId,
i11.idxLatLng,
i12.idxRemoteAlbumAssetAlbumAsset,
@@ -389,4 +395,6 @@ class $DriftManager {
);
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
i22.$$MetadataEntityTableTableManager get metadataEntity =>
i22.$$MetadataEntityTableTableManager(_db, _db.metadataEntity);
}

View File

@@ -12375,6 +12375,574 @@ class Shape48 extends i0.VersionedTable {
columnsByName['order']! as i1.GeneratedColumn<int>;
}
final class Schema25 extends i0.VersionedSchema {
Schema25({required super.database}) : super(version: 25);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetLocalDateTimeDay,
idxRemoteAssetLocalDateTimeMonth,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
metadata,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape34 remoteAssetEntity = Shape34(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
'idx_remote_asset_local_date_time_day',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
);
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
'idx_remote_asset_local_date_time_month',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 metadata = Shape49(
source: i0.VersionedTable(
entityName: 'metadata',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_211, _column_115],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
}
class Shape49 extends i0.VersionedTable {
Shape49({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get key =>
columnsByName['key']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get value =>
columnsByName['value']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_210(String aliasedName) =>
i1.GeneratedColumn<String>(
'key',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_211(String aliasedName) =>
i1.GeneratedColumn<String>(
'value',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -12399,6 +12967,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -12517,6 +13086,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from23To24(migrator, schema);
return 24;
case 24:
final schema = Schema25(database: database);
final migrator = i1.Migrator(database, schema);
await from24To25(migrator, schema);
return 25;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -12547,6 +13121,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -12572,5 +13147,6 @@ i1.OnUpgrade stepByStep({
from21To22: from21To22,
from22To23: from22To23,
from23To24: from23To24,
from24To25: from24To25,
),
);

View File

@@ -0,0 +1,101 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/config/theme_config.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class MetadataRepository extends DriftDatabaseRepository {
final Drift _db;
final Map<MetadataKey, Object> _cache = {};
MetadataRepository._(this._db) : super(_db);
static MetadataRepository? _instance;
static MetadataRepository get instance {
final instance = _instance;
if (instance == null) {
throw StateError('MetadataRepository not initialized. Call ensureInitialized() first');
}
return instance;
}
static Future<MetadataRepository> ensureInitialized(Drift db) async {
if (_instance == null) {
final instance = MetadataRepository._(db);
await instance._hydrate();
_instance = instance;
}
return _instance!;
}
static Future<void> refresh() async {
instance._cache.clear();
await instance._hydrate();
}
Future<void> _hydrate() async {
final rows = await _db.select(_db.metadataEntity).get();
for (final row in rows) {
final key = MetadataKey.fromKey(row.key);
if (key != null) _cache[key] = _decode(key, row.value);
}
}
T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue;
Future<void> write<T extends Object>(MetadataKey<T> key, T value) async {
if (_read(key) == value) return;
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.key, value: _encode(value), updatedAt: Value(DateTime.now())),
);
_cache[key] = value;
}
String _encode<T extends Object>(T value) => switch (value) {
Enum() => value.name,
DateTime() => value.toIso8601String(),
_ => throw ArgumentError('Unsupported metadata value type: ${value.runtimeType}'),
};
T _decode<T extends Object>(MetadataKey<T> key, String raw) {
final enumValues = key.enumValues;
if (enumValues != null) {
return enumValues.where((v) => (v as Enum).name == raw).firstOrNull ?? key.defaultValue;
}
return switch (key.defaultValue) {
DateTime() => (DateTime.tryParse(raw) ?? key.defaultValue) as T,
_ => throw ArgumentError('Unsupported metadata value type: ${key.defaultValue.runtimeType}'),
};
}
Future<void> delete<T extends Object>(MetadataKey<T> key) async {
_cache[key] = key.defaultValue;
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.key))).go();
}
AppConfig get appConfig => AppConfig(theme: ThemeConfig(mode: _read(MetadataKey.themeMode)));
SystemConfig get systemConfig => SystemConfig(logLevel: _read(MetadataKey.logLevel));
Stream<AppConfig> watchAppConfig() => _watchDomain(MetadataDomain.appConfig).map((_) => appConfig).distinct();
Stream<SystemConfig> watchSystemConfig() =>
_watchDomain(MetadataDomain.systemConfig).map((_) => systemConfig).distinct();
Stream<void> _watchDomain(MetadataDomain domain) {
final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'));
return query.watch().map((rows) => rows.forEach(_updateCacheForRow));
}
void _updateCacheForRow(MetadataEntityData row) {
final key = MetadataKey.fromKey(row.key);
if (key == null) return;
_cache[key] = _decode(key, row.value);
}
}

View File

@@ -53,7 +53,7 @@ void main() async {
await initApp();
// Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded();
await migrateDatabaseIfNeeded(drift);
runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget()));
} catch (error, stack) {

View File

@@ -2,21 +2,17 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class MapBottomSheet extends StatelessWidget {
final Key? sheetKey;
const MapBottomSheet({super.key, this.sheetKey});
const MapBottomSheet({super.key});
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
key: sheetKey,
initialChildSize: 0.25,
maxChildSize: 0.75,
shouldCloseOnMinExtent: false,
@@ -53,7 +49,7 @@ class _ScopedMapTimeline extends StatelessWidget {
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: GeneralBottomSheet(minChildSize: 0.23), withScrubber: false),
child: const Timeline(appBar: null, bottomSheet: null, withScrubber: false),
);
}
}

View File

@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
@@ -54,7 +53,6 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
final GlobalKey _bottomSheetKey = GlobalKey();
StreamSubscription? _eventSubscription;
@override
@@ -186,7 +184,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack(
children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset, sheetKey: _bottomSheetKey),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
],
);
@@ -226,9 +224,8 @@ class _Map extends StatelessWidget {
class _DynamicBottomSheet extends StatefulWidget {
final ValueNotifier<double> bottomSheetOffset;
final GlobalKey sheetKey;
const _DynamicBottomSheet({required this.bottomSheetOffset, required this.sheetKey});
const _DynamicBottomSheet({required this.bottomSheetOffset});
@override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
@@ -239,13 +236,10 @@ class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
Widget build(BuildContext context) {
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
final sheet = notification.context.findAncestorWidgetOfExactType<BaseBottomSheet>();
if (sheet?.key == widget.sheetKey) {
widget.bottomSheetOffset.value = notification.extent;
}
return false;
widget.bottomSheetOffset.value = notification.extent;
return true;
},
child: MapBottomSheet(sheetKey: widget.sheetKey),
child: const MapBottomSheet(),
);
}
}

View File

@@ -469,7 +469,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
if (isBottomWidgetVisible)

View File

@@ -0,0 +1,20 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
final metadataProvider = Provider<MetadataRepository>((_) => MetadataRepository.instance);
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
final repo = ref.watch(metadataProvider);
final subscription = repo.watchAppConfig().listen((event) => ref.state = event);
ref.onDispose(subscription.cancel);
return repo.appConfig;
});
final systemConfigProvider = Provider.autoDispose<SystemConfig>((ref) {
final repo = ref.watch(metadataProvider);
final subscription = repo.watchSystemConfig().listen((event) => ref.state = event);
ref.onDispose(subscription.cancel);
return repo.systemConfig;
});

View File

@@ -1,27 +1,15 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) {
final themeMode = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.themeMode);
dPrint(() => "Current themeMode $themeMode");
if (themeMode == ThemeMode.light.name) {
return ThemeMode.light;
} else if (themeMode == ThemeMode.dark.name) {
return ThemeMode.dark;
} else {
return ThemeMode.system;
}
});
final immichThemeModeProvider = StateProvider<ThemeMode>((ref) => ref.watch(appConfigProvider).theme.mode);
final immichThemePresetProvider = StateProvider<ImmichColorPreset>((ref) {
final appSettingsProvider = ref.watch(appSettingsServiceProvider);

View File

@@ -5,7 +5,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
enum AppSettingsEnum<T> {
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
themeMode<String>(StoreKey.themeMode, "themeMode", "system"), // "light","dark","system"
primaryColor<String>(StoreKey.primaryColor, "primaryColor", defaultColorPresetName),
dynamicTheme<bool>(StoreKey.dynamicTheme, "dynamicTheme", false),
colorfulInterface<bool>(StoreKey.colorfulInterface, "colorfulInterface", true),
@@ -30,7 +29,6 @@ enum AppSettingsEnum<T> {
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, "loadOriginalVideo", false),

View File

@@ -6,6 +6,7 @@ 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/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -48,9 +49,11 @@ abstract final class Bootstrap {
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
final metadataRepo = await MetadataRepository.ensureInitialized(drift);
await LogService.init(
logRepository: LogRepository(logDb),
storeRepository: storeRepo,
metadataRepository: metadataRepo,
shouldBuffer: shouldBufferLogs,
);

View File

@@ -1,7 +1,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
ValueNotifier<T> useAppSettingsState<T>(AppSettingsEnum<T> key) {
final notifier = useState<T>(Store.get(key.storeKey, key.defaultValue));

View File

@@ -1,25 +1,79 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.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/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
const int targetVersion = 25;
const int targetVersion = 26;
Future<void> migrateDatabaseIfNeeded() async {
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int version = Store.get(StoreKey.version, targetVersion);
if (version < 25) {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken != null && accessToken.isNotEmpty) {
final serverUrls = ApiService.getServerUrls();
if (serverUrls.isNotEmpty) {
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
}
}
await _migrateTo25();
}
if (version < 26) {
await _migrateTo26(drift);
}
await Store.put(StoreKey.version, targetVersion);
return;
}
Future<void> _migrateTo25() async {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken == null || accessToken.isEmpty) return;
final serverUrls = ApiService.getServerUrls();
if (serverUrls.isEmpty) return;
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
}
Future<void> _migrateTo26(Drift drift) async {
const int themeModeKey = 102;
const int logLevelKey = 115;
final repo = MetadataRepository.instance;
final migrated = <int>[];
final themeMode = await _readLegacyStoreString(drift, themeModeKey);
if (themeMode != null) {
final mode = ThemeMode.values.firstWhere((m) => m.name == themeMode, orElse: () => ThemeMode.system);
await repo.write(MetadataKey.themeMode, mode);
migrated.add(themeModeKey);
}
final logLevelIndex = await _readLegacyStoreInt(drift, logLevelKey);
if (logLevelIndex != null) {
final logLevel = LogLevel.values.elementAtOrNull(logLevelIndex) ?? LogLevel.info;
await LogService.I.setLogLevel(logLevel);
migrated.add(logLevelKey);
}
await _deleteLegacyStoreRows(drift, migrated);
}
Future<String?> _readLegacyStoreString(Drift drift, int id) async {
final row = await (drift.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
return row?.stringValue;
}
Future<int?> _readLegacyStoreInt(Drift drift, int id) async {
final row = await (drift.storeEntity.select()..where((t) => t.id.equals(id))).getSingleOrNull();
return row?.intValue;
}
Future<void> _deleteLegacyStoreRows(Drift drift, List<int> ids) async {
if (ids.isEmpty) return;
await (drift.storeEntity.delete()..where((t) => t.id.isIn(ids))).go();
}

View File

@@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
@@ -30,7 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);

View File

@@ -1,37 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class ThemeSetting extends HookConsumerWidget {
const ThemeSetting({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentThemeString = useAppSettingsState(AppSettingsEnum.themeMode);
final currentTheme = useValueNotifier(ref.read(immichThemeModeProvider));
final currentTheme = useState(ref.read(immichThemeModeProvider));
final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark);
final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system);
final applyThemeToBackgroundSetting = useAppSettingsState(AppSettingsEnum.colorfulInterface);
final applyThemeToBackgroundProvider = useValueNotifier(ref.read(colorfulInterfaceSettingProvider));
useValueChanged(
currentThemeString.value,
(_, __) => currentTheme.value = switch (currentThemeString.value) {
"light" => ThemeMode.light,
"dark" => ThemeMode.dark,
_ => ThemeMode.system,
},
);
useValueChanged(
applyThemeToBackgroundSetting.value,
(_, __) => applyThemeToBackgroundProvider.value = applyThemeToBackgroundSetting.value,
@@ -40,16 +32,17 @@ class ThemeSetting extends HookConsumerWidget {
void onThemeChange(bool isDark) {
if (isDark) {
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark;
currentThemeString.value = "dark";
currentTheme.value = ThemeMode.dark;
} else {
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light;
currentThemeString.value = "light";
currentTheme.value = ThemeMode.light;
}
ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value);
}
void onSystemThemeChange(bool isSystem) {
if (isSystem) {
currentThemeString.value = "system";
currentTheme.value = ThemeMode.system;
isSystemTheme.value = true;
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.system;
} else {
@@ -57,13 +50,14 @@ class ThemeSetting extends HookConsumerWidget {
isSystemTheme.value = false;
isDarkTheme.value = currentSystemBrightness == Brightness.dark;
if (currentSystemBrightness == Brightness.light) {
currentThemeString.value = "light";
currentTheme.value = ThemeMode.light;
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light;
} else if (currentSystemBrightness == Brightness.dark) {
currentThemeString.value = "dark";
currentTheme.value = ThemeMode.dark;
ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark;
}
}
ref.read(metadataProvider).write(MetadataKey.themeMode, currentTheme.value);
}
void onSurfaceColorSettingChange(bool useColorfulInterface) {

View File

@@ -1,11 +1,11 @@
import 'package:collection/collection.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:logging/logging.dart';
import 'package:mocktail/mocktail.dart';
@@ -29,21 +29,23 @@ final _kWarnLog = LogMessage(
void main() {
late LogService sut;
late LogRepository mockLogRepo;
late DriftStoreRepository mockStoreRepo;
late MockMetadataRepository mockMetadataRepository;
setUp(() async {
mockLogRepo = MockLogRepository();
mockStoreRepo = MockDriftStoreRepository();
mockMetadataRepository = MockMetadataRepository();
registerFallbackValue(_kInfoLog);
registerFallbackValue(LogLevel.info);
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
when(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel)).thenAnswer((_) async => LogLevel.fine.index);
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
when(() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true);
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo);
sut = await LogService.create(logRepository: mockLogRepo, metadataRepository: mockMetadataRepository);
});
tearDown(() async {
@@ -56,21 +58,22 @@ void main() {
expect(limit, kLogTruncateLimit);
});
test('Sets log level based on the store setting', () {
verify(() => mockStoreRepo.tryGet<int>(StoreKey.logLevel)).called(1);
test('Sets log level based on the metadata repository', () {
verify(() => mockMetadataRepository.systemConfig).called(1);
expect(Logger.root.level, Level.FINE);
});
});
group("Log Service Set Level:", () {
setUp(() async {
when(() => mockStoreRepo.upsert<int>(StoreKey.logLevel, any())).thenAnswer((_) async => true);
await sut.setLogLevel(LogLevel.shout);
});
test('Updates the log level in store', () {
final index = verify(() => mockStoreRepo.upsert<int>(StoreKey.logLevel, captureAny())).captured.firstOrNull;
expect(index, LogLevel.shout.index);
test('Updates the log level via metadata repository', () {
final captured = verify(
() => mockMetadataRepository.write<LogLevel>(MetadataKey.logLevel, captureAny()),
).captured.firstOrNull;
expect(captured, LogLevel.shout);
});
test('Sets log level on logger', () {
@@ -81,7 +84,11 @@ void main() {
group("Log Service Buffer:", () {
test('Buffers logs until timer elapses', () {
TestUtils.fakeAsync((time) async {
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
sut = await LogService.create(
logRepository: mockLogRepo,
metadataRepository: mockMetadataRepository,
shouldBuffer: true,
);
final logger = Logger(_kInfoLog.logger!);
logger.info(_kInfoLog.message);
@@ -95,7 +102,11 @@ void main() {
test('Batch inserts all logs on timer', () {
TestUtils.fakeAsync((time) async {
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
sut = await LogService.create(
logRepository: mockLogRepo,
metadataRepository: mockMetadataRepository,
shouldBuffer: true,
);
final logger = Logger(_kInfoLog.logger!);
logger.info(_kInfoLog.message);
@@ -112,7 +123,11 @@ void main() {
test('Does not buffer when off', () {
TestUtils.fakeAsync((time) async {
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: false);
sut = await LogService.create(
logRepository: mockLogRepo,
metadataRepository: mockMetadataRepository,
shouldBuffer: false,
);
final logger = Logger(_kInfoLog.logger!);
logger.info(_kInfoLog.message);
@@ -142,7 +157,11 @@ void main() {
test('Combines result from both DB + Buffer', () {
TestUtils.fakeAsync((time) async {
sut = await LogService.create(logRepository: mockLogRepo, storeRepository: mockStoreRepo, shouldBuffer: true);
sut = await LogService.create(
logRepository: mockLogRepo,
metadataRepository: mockMetadataRepository,
shouldBuffer: true,
);
final logger = Logger(_kWarnLog.logger!);
logger.warning(_kWarnLog.message);

View File

@@ -28,6 +28,7 @@ import 'schema_v21.dart' as v21;
import 'schema_v22.dart' as v22;
import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24;
import 'schema_v25.dart' as v25;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -81,6 +82,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v23.DatabaseAtV23(db);
case 24:
return v24.DatabaseAtV24(db);
case 25:
return v25.DatabaseAtV25(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -111,5 +114,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
22,
23,
24,
25,
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
@@ -17,6 +18,8 @@ import 'package:mocktail/mocktail.dart';
class MockDriftStoreRepository extends Mock implements DriftStoreRepository {}
class MockMetadataRepository extends Mock implements MetadataRepository {}
class MockLogRepository extends Mock implements LogRepository {}
class MockSyncStreamRepository extends Mock implements SyncStreamRepository {}

View File

@@ -0,0 +1,138 @@
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';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late MetadataRepository sut;
setUpAll(() async {
ctx = MediumRepositoryContext();
sut = await MetadataRepository.ensureInitialized(ctx.db);
});
tearDownAll(() async {
await ctx.dispose();
});
setUp(() async {
await ctx.db.delete(ctx.db.metadataEntity).go();
await MetadataRepository.refresh();
});
group('defaults', () {
test('appConfig returns key defaults when DB is empty', () {
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
test('systemConfig returns key defaults when DB is empty', () {
expect(sut.systemConfig.logLevel, LogLevel.info);
});
});
group('write', () {
test('persists a value and reflects it in the composed view', () async {
await sut.write(.themeMode, ThemeMode.dark);
expect(sut.appConfig.theme.mode, ThemeMode.dark);
});
test('persists across domains independently', () async {
await sut.write(.themeMode, ThemeMode.light);
await sut.write(.logLevel, LogLevel.severe);
expect(sut.appConfig.theme.mode, ThemeMode.light);
expect(sut.systemConfig.logLevel, LogLevel.severe);
});
});
group('delete', () {
test('removes the row and reverts to default', () async {
await sut.write(.themeMode, ThemeMode.dark);
expect(sut.appConfig.theme.mode, ThemeMode.dark);
await sut.delete(.themeMode);
expect(sut.appConfig.theme.mode, ThemeMode.system);
final rows = await ctx.db.select(ctx.db.metadataEntity).get();
expect(rows, isEmpty);
});
});
group('refresh', () {
test('picks up rows that were inserted directly into the DB', () async {
await ctx.db
.into(ctx.db.metadataEntity)
.insert(
MetadataEntityCompanion.insert(
key: MetadataKey.themeMode.key,
value: ThemeMode.dark.name,
updatedAt: Value(DateTime.now()),
),
);
// Cache hasn't seen this row yet — view still returns the default.
expect(sut.appConfig.theme.mode, ThemeMode.system);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.dark);
});
test('drops cached values for rows that were deleted out from under the repo', () async {
await sut.write(.themeMode, ThemeMode.dark);
// Wipe the row directly. Cache still holds the old value.
await ctx.db.delete(ctx.db.metadataEntity).go();
expect(sut.appConfig.theme.mode, ThemeMode.dark);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
test('skips rows whose key is unknown to MetadataKey', () async {
await ctx.db
.into(ctx.db.metadataEntity)
.insert(
MetadataEntityCompanion.insert(
key: 'app-config.unknown.future-key',
value: 'whatever',
updatedAt: Value(DateTime.now()),
),
);
await MetadataRepository.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system);
});
});
group('watch', () {
test('watchAppConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
await expectation;
});
test('watchAppConfig does not emit when only system-config rows change', () async {
final emissions = <ThemeMode>[];
// skip(1) drops the on-subscribe replay so we only capture emissions caused by the write below.
final sub = sut.watchAppConfig().skip(1).listen((c) => emissions.add(c.theme.mode));
await sut.write(MetadataKey.logLevel, LogLevel.severe);
await pumpEventQueue();
await sub.cancel();
expect(emissions, isEmpty);
});
test('watchSystemConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchSystemConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
await sut.write(MetadataKey.logLevel, LogLevel.warning);
await expectation;
});
});
}

View File

@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/db.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/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
@@ -109,36 +108,6 @@ void main() {
});
});
group('logout', () {
test('Should logout user', () async {
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
when(
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
).thenAnswer((_) => Future.value(null));
await sut.logout();
verify(() => authApiRepository.logout()).called(1);
verify(() => backgroundSyncManager.cancel()).called(1);
verify(() => authRepository.clearLocalData()).called(1);
});
test('Should clear local data even on server error', () async {
when(() => authApiRepository.logout()).thenThrow(Exception('Server error'));
when(() => backgroundSyncManager.cancel()).thenAnswer((_) async => {});
when(() => authRepository.clearLocalData()).thenAnswer((_) => Future.value(null));
when(
() => appSettingsService.setSetting(AppSettingsEnum.enableBackup, false),
).thenAnswer((_) => Future.value(null));
await sut.logout();
verify(() => authApiRepository.logout()).called(1);
verify(() => backgroundSyncManager.cancel()).called(1);
verify(() => authRepository.clearLocalData()).called(1);
});
});
group('setOpenApiServiceEndpoint', () {
setUp(() {
when(() => networkService.getWifiName()).thenAnswer((_) async => 'TestWifi');

10
pnpm-lock.yaml generated
View File

@@ -570,8 +570,8 @@ importers:
specifier: ^2.0.0
version: 2.0.9
uuid:
specifier: ^14.0.0
version: 14.0.0
specifier: ^11.1.0
version: 11.1.0
validator:
specifier: ^13.12.0
version: 13.15.35
@@ -12110,10 +12110,6 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@@ -25783,8 +25779,6 @@ snapshots:
uuid@11.1.0: {}
uuid@14.0.0: {}
uuid@8.3.2: {}
validator@13.15.35: {}

View File

@@ -114,7 +114,7 @@
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^14.0.0",
"uuid": "^11.1.0",
"validator": "^13.12.0",
"zod": "^4.3.6"
},

View File

@@ -9,7 +9,6 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
@@ -100,10 +99,9 @@
const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
const asset = $derived(previewStackedAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink();
@@ -116,29 +114,17 @@
playOriginalVideo = value;
};
const selectStackedAsset = async (id: string) => {
ocrManager.clear();
selectedStackAsset = await assetCacheManager.getAsset({ id });
if (!sharedLink) {
await ocrManager.getAssetOcr(id);
}
};
const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) {
return;
}
if (!cursor.current.stack) {
stack = undefined;
selectedStackAsset = undefined;
return;
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
}
stack = await getStack({ id: cursor.current.stack.id });
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
if (primaryAsset) {
await selectStackedAsset(primaryAsset.id);
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
};
@@ -196,21 +182,11 @@
onClose?.(asset);
};
const refreshPreservingSelection = async () => {
const id = asset.id;
assetCacheManager.invalidateAsset(id);
if (selectedStackAsset) {
await selectStackedAsset(id);
} else {
const refreshedAsset = await assetCacheManager.getAsset({ id });
assetViewerManager.setAsset(refreshedAsset);
}
onAssetChange?.(asset);
};
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
await refreshPreservingSelection();
const refreshedAsset = await getAssetInfo({ id: asset.id });
onAssetChange?.(refreshedAsset);
assetViewerManager.setAsset(refreshedAsset);
}
assetViewerManager.closeEditor();
};
@@ -309,6 +285,10 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
@@ -321,7 +301,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack ?? undefined;
stack = action.stack;
if (stack) {
cursor.current = stack.assets[0];
}
@@ -329,7 +309,7 @@
}
case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack ?? undefined;
stack = action.stack;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
@@ -388,7 +368,7 @@
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
cursor.current;
asset;
untrack(() => handlePromiseError(refresh()));
});
@@ -553,12 +533,7 @@
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
onTagFace={refreshPreservingSelection}
/>
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{asset}
@@ -610,7 +585,7 @@
translate="yes"
>
{#if showDetailPanel}
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
<DetailPanel {asset} currentAlbum={album} />
{:else if assetViewerManager.isShowEditor}
<EditorPanel {asset} onClose={closeEditor} />
{/if}
@@ -622,24 +597,27 @@
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
{@const isSelected = stackedAsset.id === (selectedStackAsset?.id ?? cursor.current.id)}
<div
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
style:bottom={isSelected ? '0' : '-10px'}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
imageClass={{ 'border-2 border-white': isSelected }}
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs"
dimmed={!isSelected}
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={() => selectStackedAsset(stackedAsset.id)}
onClick={() => {
cursor.current = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
readonly
thumbnailSize={isSelected ? stackSelectedThumbnailSize : stackThumbnailSize}
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
disableLinkMouseOver
/>
{#if isSelected}
{#if stackedAsset.id === asset.id}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
</div>

View File

@@ -17,7 +17,13 @@
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import {
mdiCamera,
@@ -43,10 +49,9 @@
interface Props {
asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null;
onRefreshPeople?: () => Promise<void>;
}
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
@@ -104,6 +109,11 @@
return undefined;
};
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
assetViewerManager.closeEditFacesPanel();
};
const getAssetFolderHref = (asset: AssetResponseDto) => {
// Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) });
@@ -491,6 +501,6 @@
assetId={asset.id}
assetType={asset.type}
onClose={() => assetViewerManager.closeEditFacesPanel()}
onRefresh={() => void onRefreshPeople?.()}
onRefresh={handleRefreshPeople}
/>
{/if}

View File

@@ -31,10 +31,9 @@
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
onTagFace?: () => Promise<void>;
};
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
@@ -286,12 +285,6 @@
</AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
<FaceEditor
htmlElement={assetViewerManager.imgRef}
{containerWidth}
{containerHeight}
assetId={asset.id}
{onTagFace}
/>
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
</div>

View File

@@ -18,10 +18,9 @@
containerWidth: number;
containerHeight: number;
assetId: string;
onTagFace?: () => Promise<void>;
};
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state();
@@ -326,7 +325,7 @@
},
});
await onTagFace?.();
await assetViewerManager.setAssetId(assetId);
} catch (error) {
handleError(error, 'Error tagging face');
} finally {

View File

@@ -179,10 +179,7 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
onRefresh();
if (peopleWithFaces.length === 0) {
onClose();
}
await assetViewerManager.setAssetId(assetId);
} catch (error) {
handleError(error, $t('error_delete_face'));
}