mirror of
https://github.com/immich-app/immich.git
synced 2026-05-27 18:12:31 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 480b115426 |
@@ -1,39 +1,39 @@
|
||||
dev:
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
dev-down:
|
||||
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
dev-update:
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
dev-scale:
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
dev-docs:
|
||||
npm --prefix docs run start
|
||||
|
||||
.PHONY: e2e
|
||||
e2e:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
e2e-dev:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
e2e-update:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
e2e-down:
|
||||
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
prod:
|
||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
prod-down:
|
||||
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
prod-scale:
|
||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n"\n\n >&2 && exit 1
|
||||
|
||||
.PHONY: open-api
|
||||
open-api:
|
||||
|
||||
@@ -84,6 +84,72 @@ run = [
|
||||
dir = "server"
|
||||
run = "node ./dist/bin/sync-sql.js"
|
||||
|
||||
# TODO dev, prod, and e2e should be de-duplicated by using env but for some reason I ran into issues
|
||||
[tasks.dev]
|
||||
depends = "//:plugins"
|
||||
dir = "docker"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
|
||||
depends_post = "//:dev-down"
|
||||
|
||||
[tasks.dev-update]
|
||||
run = { task = "//:dev", args = ["--build", "-V"] }
|
||||
|
||||
[tasks.dev-scale]
|
||||
run = { task = "//:dev", args = ["--build", "-V", "--scale immich-server=3"] }
|
||||
|
||||
[tasks.dev-down]
|
||||
dir = "docker"
|
||||
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
|
||||
|
||||
[tasks.prod]
|
||||
depends = "//:plugins"
|
||||
dir = "docker"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.prod.yml up --remove-orphans"
|
||||
depends_post = "//:prod-down"
|
||||
|
||||
[tasks.prod-scale]
|
||||
run = { task = "//:prod", args = [
|
||||
"--build",
|
||||
"-V",
|
||||
"--scale immich-server=3",
|
||||
"--scale immich-microservices",
|
||||
] }
|
||||
|
||||
[tasks.prod-down]
|
||||
dir = "docker"
|
||||
run = "docker compose -f ./docker-compose.prod.yml down --remove-orphans"
|
||||
|
||||
[tasks.e2e]
|
||||
depends = "//:plugins"
|
||||
dir = "e2e"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.yml up --remove-orphans"
|
||||
depends_post = "//:e2e-down"
|
||||
|
||||
[tasks.e2e-dev]
|
||||
depends = "//:plugins"
|
||||
dir = "e2e"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
|
||||
depends_post = "//:e2e-dev-down"
|
||||
|
||||
[tasks.e2e-update]
|
||||
run = { task = "//:e2e", args = ["--build", '-V'] }
|
||||
|
||||
[tasks.e2e-down]
|
||||
dir = "e2e"
|
||||
run = "docker compose -f ./docker-compose.yml down --remove-orphans"
|
||||
|
||||
[tasks.e2e-dev-down]
|
||||
dir = "e2e"
|
||||
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
|
||||
|
||||
# SDK tasks
|
||||
[tasks."sdk:install"]
|
||||
dir = "packages/sdk"
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
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/config/album_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/backup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/image_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/map_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/slideshow_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/theme_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/timeline_config.dart';
|
||||
import 'package:immich_mobile/domain/models/config/viewer_config.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/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
const defaultConfig = AppConfig();
|
||||
|
||||
class AppConfig {
|
||||
final LogLevel logLevel;
|
||||
final ThemeConfig theme;
|
||||
final CleanupConfig cleanup;
|
||||
final MapConfig map;
|
||||
@@ -29,10 +18,8 @@ class AppConfig {
|
||||
final SlideshowConfig slideshow;
|
||||
final AlbumConfig album;
|
||||
final BackupConfig backup;
|
||||
final NetworkConfig network;
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
this.theme = const .new(),
|
||||
this.cleanup = const .new(),
|
||||
this.map = const .new(),
|
||||
@@ -42,11 +29,9 @@ class AppConfig {
|
||||
this.slideshow = const .new(),
|
||||
this.album = const .new(),
|
||||
this.backup = const .new(),
|
||||
this.network = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
LogLevel? logLevel,
|
||||
ThemeConfig? theme,
|
||||
CleanupConfig? cleanup,
|
||||
MapConfig? map,
|
||||
@@ -56,9 +41,7 @@ class AppConfig {
|
||||
SlideshowConfig? slideshow,
|
||||
AlbumConfig? album,
|
||||
BackupConfig? backup,
|
||||
NetworkConfig? network,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
theme: theme ?? this.theme,
|
||||
cleanup: cleanup ?? this.cleanup,
|
||||
map: map ?? this.map,
|
||||
@@ -68,14 +51,12 @@ class AppConfig {
|
||||
slideshow: slideshow ?? this.slideshow,
|
||||
album: album ?? this.album,
|
||||
backup: backup ?? this.backup,
|
||||
network: network ?? this.network,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is AppConfig &&
|
||||
other.logLevel == logLevel &&
|
||||
other.theme == theme &&
|
||||
other.cleanup == cleanup &&
|
||||
other.map == map &&
|
||||
@@ -84,118 +65,12 @@ class AppConfig {
|
||||
other.viewer == viewer &&
|
||||
other.slideshow == slideshow &&
|
||||
other.album == album &&
|
||||
other.backup == backup &&
|
||||
other.network == network);
|
||||
other.backup == backup);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
|
||||
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
|
||||
|
||||
@override
|
||||
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)';
|
||||
|
||||
T read<T extends Object>(MetadataKey<T> key) =>
|
||||
(switch (key) {
|
||||
.logLevel => logLevel,
|
||||
.themePrimaryColor => theme.primaryColor,
|
||||
.themeMode => theme.mode,
|
||||
.themeDynamic => theme.dynamicTheme,
|
||||
.themeColorfulInterface => theme.colorfulInterface,
|
||||
.imagePreferRemote => image.preferRemote,
|
||||
.imageLoadOriginal => image.loadOriginal,
|
||||
.viewerLoopVideo => viewer.loopVideo,
|
||||
.viewerLoadOriginalVideo => viewer.loadOriginalVideo,
|
||||
.viewerAutoPlayVideo => viewer.autoPlayVideo,
|
||||
.viewerTapToNavigate => viewer.tapToNavigate,
|
||||
.networkAutoEndpointSwitching => network.autoEndpointSwitching,
|
||||
.networkPreferredWifiName => network.preferredWifiName,
|
||||
.networkLocalEndpoint => network.localEndpoint,
|
||||
.networkExternalEndpointList => network.externalEndpointList,
|
||||
.networkCustomHeaders => network.customHeaders,
|
||||
.albumSortMode => album.sortMode,
|
||||
.albumIsReverse => album.isReverse,
|
||||
.albumIsGrid => album.isGrid,
|
||||
.backupEnabled => backup.enabled,
|
||||
.backupUseCellularForVideos => backup.useCellularForVideos,
|
||||
.backupUseCellularForPhotos => backup.useCellularForPhotos,
|
||||
.backupRequireCharging => backup.requireCharging,
|
||||
.backupTriggerDelay => backup.triggerDelay,
|
||||
.backupSyncAlbums => backup.syncAlbums,
|
||||
.timelineTilesPerRow => timeline.tilesPerRow,
|
||||
.timelineGroupAssetsBy => timeline.groupAssetsBy,
|
||||
.timelineStorageIndicator => timeline.storageIndicator,
|
||||
.mapShowFavoriteOnly => map.favoritesOnly,
|
||||
.mapRelativeDate => map.relativeDays,
|
||||
.mapIncludeArchived => map.includeArchived,
|
||||
.mapThemeMode => map.themeMode,
|
||||
.mapWithPartners => map.withPartners,
|
||||
.cleanupKeepFavorites => cleanup.keepFavorites,
|
||||
.cleanupKeepMediaType => cleanup.keepMediaType,
|
||||
.cleanupKeepAlbumIds => cleanup.keepAlbumIds,
|
||||
.cleanupCutoffDaysAgo => cleanup.cutoffDaysAgo,
|
||||
.cleanupDefaultsInitialized => cleanup.defaultsInitialized,
|
||||
.slideshowTransition => slideshow.transition,
|
||||
.slideshowRepeat => slideshow.repeat,
|
||||
.slideshowDuration => slideshow.duration,
|
||||
.slideshowLook => slideshow.look,
|
||||
.slideshowDirection => slideshow.direction,
|
||||
})
|
||||
as T;
|
||||
|
||||
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) {
|
||||
var config = const AppConfig();
|
||||
for (final MapEntry(key: key, value: value) in entries.entries) {
|
||||
config = config.write(key, value);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
|
||||
return switch (key) {
|
||||
.logLevel => copyWith(logLevel: value as LogLevel),
|
||||
.themePrimaryColor => copyWith(theme: theme.copyWith(primaryColor: value as ImmichColorPreset)),
|
||||
.themeMode => copyWith(theme: theme.copyWith(mode: value as ThemeMode)),
|
||||
.themeDynamic => copyWith(theme: theme.copyWith(dynamicTheme: value as bool)),
|
||||
.themeColorfulInterface => copyWith(theme: theme.copyWith(colorfulInterface: value as bool)),
|
||||
.imagePreferRemote => copyWith(image: image.copyWith(preferRemote: value as bool)),
|
||||
.imageLoadOriginal => copyWith(image: image.copyWith(loadOriginal: value as bool)),
|
||||
.viewerLoopVideo => copyWith(viewer: viewer.copyWith(loopVideo: value as bool)),
|
||||
.viewerLoadOriginalVideo => copyWith(viewer: viewer.copyWith(loadOriginalVideo: value as bool)),
|
||||
.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: (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)),
|
||||
.albumIsReverse => copyWith(album: album.copyWith(isReverse: value as bool)),
|
||||
.albumIsGrid => copyWith(album: album.copyWith(isGrid: value as bool)),
|
||||
.backupEnabled => copyWith(backup: backup.copyWith(enabled: value as bool)),
|
||||
.backupUseCellularForVideos => copyWith(backup: backup.copyWith(useCellularForVideos: value as bool)),
|
||||
.backupUseCellularForPhotos => copyWith(backup: backup.copyWith(useCellularForPhotos: value as bool)),
|
||||
.backupRequireCharging => copyWith(backup: backup.copyWith(requireCharging: value as bool)),
|
||||
.backupTriggerDelay => copyWith(backup: backup.copyWith(triggerDelay: value as int)),
|
||||
.backupSyncAlbums => copyWith(backup: backup.copyWith(syncAlbums: value as bool)),
|
||||
.timelineTilesPerRow => copyWith(timeline: timeline.copyWith(tilesPerRow: value as int)),
|
||||
.timelineGroupAssetsBy => copyWith(timeline: timeline.copyWith(groupAssetsBy: value as GroupAssetsBy)),
|
||||
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
|
||||
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
|
||||
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
||||
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
|
||||
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
|
||||
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
|
||||
.cleanupKeepFavorites => copyWith(cleanup: cleanup.copyWith(keepFavorites: value as bool)),
|
||||
.cleanupKeepMediaType => copyWith(cleanup: cleanup.copyWith(keepMediaType: value as AssetKeepType)),
|
||||
.cleanupKeepAlbumIds => copyWith(cleanup: cleanup.copyWith(keepAlbumIds: value as List<String>)),
|
||||
.cleanupCutoffDaysAgo => copyWith(cleanup: cleanup.copyWith(cutoffDaysAgo: value as int)),
|
||||
.cleanupDefaultsInitialized => copyWith(cleanup: cleanup.copyWith(defaultsInitialized: value as bool)),
|
||||
.slideshowTransition => copyWith(slideshow: slideshow.copyWith(transition: value as bool)),
|
||||
.slideshowRepeat => copyWith(slideshow: slideshow.copyWith(repeat: value as bool)),
|
||||
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
|
||||
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
|
||||
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
|
||||
};
|
||||
}
|
||||
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import 'package:flutter/foundation.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 {},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
|
||||
class SystemConfig {
|
||||
final LogLevel logLevel;
|
||||
final NetworkConfig network;
|
||||
|
||||
const SystemConfig({this.logLevel = .info, this.network = const .new()});
|
||||
|
||||
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) =>
|
||||
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(logLevel, network);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)';
|
||||
}
|
||||
@@ -1,105 +1,142 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
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/config/app_config.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/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
enum MetadataScope {
|
||||
user, // keys with this scope are deleted on logout
|
||||
system;
|
||||
enum MetadataDomain<T extends Object> {
|
||||
appConfig<AppConfig>('config.app'),
|
||||
systemConfig<SystemConfig>('config.system');
|
||||
|
||||
const MetadataScope();
|
||||
final String prefix;
|
||||
const MetadataDomain(this.prefix);
|
||||
}
|
||||
|
||||
enum MetadataKey<T extends Object> {
|
||||
// Theme
|
||||
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
themeDynamic<bool>(),
|
||||
themeColorfulInterface<bool>(),
|
||||
themePrimaryColor<ImmichColorPreset>(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)),
|
||||
themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
|
||||
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
|
||||
|
||||
// Image
|
||||
imagePreferRemote<bool>(),
|
||||
imageLoadOriginal<bool>(),
|
||||
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false),
|
||||
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false),
|
||||
|
||||
// Viewer
|
||||
viewerLoopVideo<bool>(),
|
||||
viewerLoadOriginalVideo<bool>(),
|
||||
viewerAutoPlayVideo<bool>(),
|
||||
viewerTapToNavigate<bool>(),
|
||||
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true),
|
||||
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false),
|
||||
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
|
||||
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
|
||||
|
||||
// Network
|
||||
networkAutoEndpointSwitching<bool>(scope: .system),
|
||||
networkPreferredWifiName<String>(scope: .system),
|
||||
networkLocalEndpoint<String>(scope: .system),
|
||||
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
|
||||
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
|
||||
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
|
||||
networkExternalEndpointList<List<String>>(
|
||||
.systemConfig,
|
||||
'network.externalEndpointList',
|
||||
[],
|
||||
_ListCodec(_PrimitiveCodec.string),
|
||||
),
|
||||
networkCustomHeaders<Map<String, String>>(
|
||||
scope: .system,
|
||||
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
.systemConfig,
|
||||
'network.customHeaders',
|
||||
{},
|
||||
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
|
||||
albumIsReverse<bool>(),
|
||||
albumIsGrid<bool>(),
|
||||
albumSortMode<AlbumSortMode>(
|
||||
.appConfig,
|
||||
'album.sortMode',
|
||||
AlbumSortMode.mostRecent,
|
||||
_EnumCodec(AlbumSortMode.values),
|
||||
),
|
||||
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
|
||||
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
|
||||
|
||||
// Backup
|
||||
backupEnabled<bool>(),
|
||||
backupUseCellularForVideos<bool>(),
|
||||
backupUseCellularForPhotos<bool>(),
|
||||
backupRequireCharging<bool>(),
|
||||
backupTriggerDelay<int>(),
|
||||
backupSyncAlbums<bool>(),
|
||||
backupEnabled<bool>(.appConfig, 'backup.enabled', false),
|
||||
backupUseCellularForVideos<bool>(.appConfig, 'backup.useCellularForVideos', false),
|
||||
backupUseCellularForPhotos<bool>(.appConfig, 'backup.useCellularForPhotos', false),
|
||||
backupRequireCharging<bool>(.appConfig, 'backup.requireCharging', false),
|
||||
backupTriggerDelay<int>(.appConfig, 'backup.triggerDelay', 30),
|
||||
backupSyncAlbums<bool>(.appConfig, 'backup.syncAlbums', false),
|
||||
|
||||
// Timeline
|
||||
timelineTilesPerRow<int>(),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
|
||||
timelineStorageIndicator<bool>(),
|
||||
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(
|
||||
.appConfig,
|
||||
'timeline.groupAssetsBy',
|
||||
GroupAssetsBy.day,
|
||||
_EnumCodec(GroupAssetsBy.values),
|
||||
),
|
||||
timelineStorageIndicator<bool>(.appConfig, 'timeline.storageIndicator', true),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
|
||||
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)),
|
||||
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
mapRelativeDate<int>(),
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
mapShowFavoriteOnly<bool>(.appConfig, 'map.showFavoriteOnly', false),
|
||||
mapRelativeDate<int>(.appConfig, 'map.relativeDate', 0),
|
||||
mapIncludeArchived<bool>(.appConfig, 'map.includeArchived', false),
|
||||
mapThemeMode<ThemeMode>(.appConfig, 'map.themeMode', .system, _EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(.appConfig, 'map.withPartners', false),
|
||||
|
||||
// Cleanup
|
||||
cleanupKeepFavorites<bool>(),
|
||||
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
|
||||
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(),
|
||||
cleanupDefaultsInitialized<bool>(),
|
||||
cleanupKeepFavorites<bool>(.appConfig, 'cleanup.keepFavorites', true),
|
||||
cleanupKeepMediaType<AssetKeepType>(
|
||||
.appConfig,
|
||||
'cleanup.keepMediaType',
|
||||
AssetKeepType.none,
|
||||
_EnumCodec(AssetKeepType.values),
|
||||
),
|
||||
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
|
||||
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
|
||||
|
||||
// Slideshow
|
||||
slideshowTransition<bool>(),
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
|
||||
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
|
||||
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
|
||||
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(
|
||||
.appConfig,
|
||||
'slideshow.direction',
|
||||
SlideshowDirection.forward,
|
||||
_EnumCodec(SlideshowDirection.values),
|
||||
);
|
||||
|
||||
final MetadataScope scope;
|
||||
final MetadataDomain domain;
|
||||
final String name;
|
||||
final T defaultValue;
|
||||
final _MetadataCodec<T>? _codecOverride;
|
||||
|
||||
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
|
||||
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]);
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
|
||||
String get key => '${domain.prefix}.$name';
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw);
|
||||
T decode(String raw) => _codec.decode(raw) ?? defaultValue;
|
||||
|
||||
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
|
||||
}
|
||||
|
||||
sealed class _MetadataCodec<T extends Object> {
|
||||
const _MetadataCodec();
|
||||
|
||||
String encode(T value);
|
||||
T decode(String raw);
|
||||
T? decode(String raw);
|
||||
|
||||
static const Map<Type, _MetadataCodec<Object>> _primitives = {
|
||||
int: _PrimitiveCodec.integer,
|
||||
@@ -109,10 +146,12 @@ sealed class _MetadataCodec<T extends Object> {
|
||||
DateTime: _DateTimeCodec(),
|
||||
};
|
||||
|
||||
static _MetadataCodec<T> forType<T extends Object>(Type runtimeType) {
|
||||
final codec = _primitives[runtimeType];
|
||||
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) {
|
||||
final codec = _primitives[sample.runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the MetadataKey.');
|
||||
throw StateError(
|
||||
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
|
||||
);
|
||||
}
|
||||
return codec as _MetadataCodec<T>;
|
||||
}
|
||||
@@ -127,7 +166,7 @@ final class _EnumCodec<T extends Enum> extends _MetadataCodec<T> {
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T decode(String raw) => values.firstWhere((v) => v.name == raw);
|
||||
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
@@ -137,7 +176,7 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
DateTime? decode(String raw) => DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
|
||||
@@ -154,26 +193,29 @@ final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V> decode(String raw) {
|
||||
Map<K, V>? decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return {};
|
||||
return null;
|
||||
}
|
||||
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 {};
|
||||
return null;
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
if (k == null || v == null) {
|
||||
return null;
|
||||
}
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return {};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,29 +229,32 @@ final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
|
||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
||||
|
||||
@override
|
||||
List<T> decode(String raw) {
|
||||
List<T>? decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
final result = <T>[];
|
||||
for (final item in decoded) {
|
||||
if (item is! String) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
final element = _elementCodec.decode(item);
|
||||
if (element == null) {
|
||||
return null;
|
||||
}
|
||||
result.add(element);
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
|
||||
final T Function(String) _parse;
|
||||
final T? Function(String) _parse;
|
||||
|
||||
const _PrimitiveCodec._(this._parse);
|
||||
|
||||
@@ -217,12 +262,12 @@ final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T decode(String raw) => _parse(raw);
|
||||
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 integer = _PrimitiveCodec<int>._(int.tryParse);
|
||||
static const real = _PrimitiveCodec<double>._(double.tryParse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse);
|
||||
static const string = _PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String _identity(String s) => s;
|
||||
static String? _identity(String s) => s;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class LogService {
|
||||
}) async {
|
||||
final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
|
||||
await logRepository.truncate(limit: kLogTruncateLimit);
|
||||
final level = instance._metadataRepository.appConfig.logLevel;
|
||||
final level = instance._metadataRepository.systemConfig.logLevel;
|
||||
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:collection/collection.dart';
|
||||
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/metadata_key.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.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);
|
||||
|
||||
@@ -23,50 +25,153 @@ class MetadataRepository extends DriftDatabaseRepository {
|
||||
AppConfig _appConfig = const .new();
|
||||
AppConfig get appConfig => _appConfig;
|
||||
|
||||
SystemConfig _systemConfig = const .new();
|
||||
SystemConfig get systemConfig => _systemConfig;
|
||||
|
||||
static Future<MetadataRepository> ensureInitialized(Drift db) async {
|
||||
if (_instance == null) {
|
||||
final instance = MetadataRepository._(db);
|
||||
await instance.refresh();
|
||||
await instance._hydrate();
|
||||
_instance = instance;
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
|
||||
static Future<void> refresh() async {
|
||||
instance._cache.clear();
|
||||
instance._appConfig = const .new();
|
||||
instance._systemConfig = const .new();
|
||||
await instance._hydrate();
|
||||
}
|
||||
|
||||
Future<void> _hydrate() async => _hydrateCache(await _db.select(_db.metadataEntity).get());
|
||||
|
||||
T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue;
|
||||
|
||||
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
|
||||
if (value == _appConfig.read(key)) {
|
||||
if (_read(key) == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == defaultConfig.read(key)) {
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
|
||||
} else {
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
}
|
||||
|
||||
_appConfig = _appConfig.write(key, value);
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.key, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
_updateCache(key, value);
|
||||
}
|
||||
|
||||
Stream<AppConfig> watchConfig() => _db.select(_db.metadataEntity).watch().map((rows) {
|
||||
_applyOverrides(rows);
|
||||
return _appConfig;
|
||||
});
|
||||
Future<void> delete<T extends Object>(MetadataKey<T> key) async {
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.key))).go();
|
||||
_updateCache(key, key.defaultValue);
|
||||
}
|
||||
|
||||
void _applyOverrides(List<MetadataEntityData> rows) {
|
||||
_appConfig = AppConfig.fromEntries(
|
||||
rows.fold({}, (overrides, row) {
|
||||
final metadataKey = MetadataKey.values.firstWhereOrNull((key) => key.name == row.key);
|
||||
if (metadataKey == null) {
|
||||
return overrides;
|
||||
}
|
||||
Stream<AppConfig> watchAppConfig() => _watchDomain(.appConfig).distinct();
|
||||
|
||||
return {...overrides, metadataKey: metadataKey.decode(row.value)};
|
||||
}),
|
||||
);
|
||||
Stream<SystemConfig> watchSystemConfig() => _watchDomain(.systemConfig).distinct();
|
||||
|
||||
Stream<T> _watchDomain<T extends Object>(MetadataDomain<T> domain) {
|
||||
final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'));
|
||||
return query.watch().map((rows) {
|
||||
_hydrateCache(rows);
|
||||
return domain.config(this);
|
||||
});
|
||||
}
|
||||
|
||||
void _hydrateCache(List<MetadataEntityData> rows) {
|
||||
final keyMap = MetadataKey.asKeyMap();
|
||||
for (final row in rows) {
|
||||
final key = keyMap[row.key];
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
_updateCache(key, key.decode(row.value));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateCache<T extends Object>(MetadataKey<T> key, T value) {
|
||||
if (_cache[key] == value) {
|
||||
return;
|
||||
}
|
||||
_cache[key] = value;
|
||||
key.domain.rebuild(this);
|
||||
}
|
||||
}
|
||||
|
||||
extension<T extends Object> on MetadataDomain<T> {
|
||||
T config(MetadataRepository repo) => switch (this) {
|
||||
.appConfig => repo._appConfig as T,
|
||||
.systemConfig => repo._systemConfig as T,
|
||||
};
|
||||
|
||||
void rebuild(MetadataRepository repo) {
|
||||
switch (this) {
|
||||
case .appConfig:
|
||||
repo._appConfig = .new(
|
||||
theme: .new(
|
||||
mode: repo._read(.themeMode),
|
||||
primaryColor: repo._read(.themePrimaryColor),
|
||||
dynamicTheme: repo._read(.themeDynamic),
|
||||
colorfulInterface: repo._read(.themeColorfulInterface),
|
||||
),
|
||||
cleanup: .new(
|
||||
keepFavorites: repo._read(.cleanupKeepFavorites),
|
||||
keepMediaType: repo._read(.cleanupKeepMediaType),
|
||||
keepAlbumIds: repo._read(.cleanupKeepAlbumIds),
|
||||
cutoffDaysAgo: repo._read(.cleanupCutoffDaysAgo),
|
||||
defaultsInitialized: repo._read(.cleanupDefaultsInitialized),
|
||||
),
|
||||
map: .new(
|
||||
relativeDays: repo._read(.mapRelativeDate),
|
||||
favoritesOnly: repo._read(.mapShowFavoriteOnly),
|
||||
includeArchived: repo._read(.mapIncludeArchived),
|
||||
themeMode: repo._read(.mapThemeMode),
|
||||
withPartners: repo._read(.mapWithPartners),
|
||||
),
|
||||
timeline: .new(
|
||||
tilesPerRow: repo._read(.timelineTilesPerRow),
|
||||
groupAssetsBy: repo._read(.timelineGroupAssetsBy),
|
||||
storageIndicator: repo._read(.timelineStorageIndicator),
|
||||
),
|
||||
image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)),
|
||||
viewer: .new(
|
||||
loopVideo: repo._read(.viewerLoopVideo),
|
||||
loadOriginalVideo: repo._read(.viewerLoadOriginalVideo),
|
||||
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
|
||||
tapToNavigate: repo._read(.viewerTapToNavigate),
|
||||
),
|
||||
slideshow: .new(
|
||||
transition: repo._read(.slideshowTransition),
|
||||
repeat: repo._read(.slideshowRepeat),
|
||||
duration: repo._read(.slideshowDuration),
|
||||
look: repo._read(.slideshowLook),
|
||||
direction: repo._read(.slideshowDirection),
|
||||
),
|
||||
album: .new(
|
||||
sortMode: repo._read(.albumSortMode),
|
||||
isReverse: repo._read(.albumIsReverse),
|
||||
isGrid: repo._read(.albumIsGrid),
|
||||
),
|
||||
backup: .new(
|
||||
enabled: repo._read(.backupEnabled),
|
||||
useCellularForVideos: repo._read(.backupUseCellularForVideos),
|
||||
useCellularForPhotos: repo._read(.backupUseCellularForPhotos),
|
||||
requireCharging: repo._read(.backupRequireCharging),
|
||||
triggerDelay: repo._read(.backupTriggerDelay),
|
||||
syncAlbums: repo._read(.backupSyncAlbums),
|
||||
),
|
||||
);
|
||||
case .systemConfig:
|
||||
repo._systemConfig = .new(
|
||||
logLevel: repo._read(.logLevel),
|
||||
network: .new(
|
||||
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
|
||||
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
|
||||
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
|
||||
externalEndpointList: repo._read(.networkExternalEndpointList),
|
||||
customHeaders: repo._read(.networkCustomHeaders),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
_searchController = TextEditingController();
|
||||
_searchFocusNode = FocusNode();
|
||||
|
||||
_enableSyncUploadAlbum.value = ref.read(appConfigProvider).backup.syncAlbums;
|
||||
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
ref.read(backupAlbumProvider.notifier).getAll();
|
||||
|
||||
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
|
||||
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
||||
return;
|
||||
}
|
||||
|
||||
final enableSyncUploadAlbum = ref.read(appConfigProvider).backup.syncAlbums;
|
||||
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.backup.syncAlbums;
|
||||
final selectedAlbums = ref
|
||||
.read(backupAlbumProvider)
|
||||
.where((a) => a.backupSelection == BackupSelection.selected)
|
||||
|
||||
@@ -19,7 +19,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
bool hasPopped = false;
|
||||
final previousBackup = ref.read(appConfigProvider).backup;
|
||||
final previousBackup = ref.read(metadataProvider).appConfig.backup;
|
||||
final previousCellularForVideos = previousBackup.useCellularForVideos;
|
||||
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
|
||||
return PopScope(
|
||||
|
||||
@@ -22,7 +22,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
|
||||
final headers = useState<List<SettingsHeader>>([]);
|
||||
final setInitialHeaders = useState(false);
|
||||
|
||||
final storedHeaders = ref.read(metadataProvider).appConfig.network.customHeaders;
|
||||
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
if (!setInitialHeaders.value) {
|
||||
storedHeaders.forEach((k, v) {
|
||||
final header = SettingsHeader();
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
||||
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/metadata_key.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';
|
||||
@@ -36,7 +36,7 @@ class BootstrapErrorWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext _) {
|
||||
final immichTheme = defaultConfig.theme.primaryColor.themeOfPreset;
|
||||
final immichTheme = MetadataKey.themePrimaryColor.defaultValue.themeOfPreset;
|
||||
|
||||
return EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
/// - Prompt to delete the asset locally
|
||||
@@ -40,8 +39,6 @@ class DeleteLocalActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.invalidate(localAlbumProvider);
|
||||
|
||||
final successMessage = 'delete_local_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -130,7 +130,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
await _apiService.updateHeaders();
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final headerMap = _ref.read(metadataProvider).appConfig.network.customHeaders;
|
||||
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders;
|
||||
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
|
||||
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
|
||||
|
||||
@@ -187,11 +187,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
String? getSavedWifiName() {
|
||||
return _ref.read(metadataProvider).appConfig.network.preferredWifiName;
|
||||
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName;
|
||||
}
|
||||
|
||||
String? getSavedLocalEndpoint() {
|
||||
return _ref.read(metadataProvider).appConfig.network.localEndpoint;
|
||||
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
|
||||
}
|
||||
|
||||
/// Returns the current server endpoint (with /api) URL from the store
|
||||
|
||||
@@ -1,12 +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.autoDispose<MetadataRepository>((_) => MetadataRepository.instance);
|
||||
|
||||
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
|
||||
final repo = ref.watch(metadataProvider);
|
||||
final subscription = repo.watchConfig().listen((event) => ref.state = event);
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.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/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>(
|
||||
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(appConfigProvider)),
|
||||
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
|
||||
);
|
||||
|
||||
class AuthRepository {
|
||||
final Drift _drift;
|
||||
final AppConfig _config;
|
||||
final MetadataRepository _metadata;
|
||||
|
||||
const AuthRepository(this._drift, this._config);
|
||||
const AuthRepository(this._drift, this._metadata);
|
||||
|
||||
Future<void> clearLocalData() async {
|
||||
await SyncStreamRepository(_drift).reset();
|
||||
}
|
||||
|
||||
bool getEndpointSwitchingFeature() {
|
||||
return _config.network.autoEndpointSwitching;
|
||||
return _metadata.systemConfig.network.autoEndpointSwitching;
|
||||
}
|
||||
|
||||
String? getPreferredWifiName() {
|
||||
return _config.network.preferredWifiName;
|
||||
return _metadata.systemConfig.network.preferredWifiName;
|
||||
}
|
||||
|
||||
String? getLocalEndpoint() {
|
||||
return _config.network.localEndpoint;
|
||||
return _metadata.systemConfig.network.localEndpoint;
|
||||
}
|
||||
|
||||
List<AuxilaryEndpoint> getExternalEndpointList() {
|
||||
return _config.network.externalEndpointList.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
|
||||
return _metadata.systemConfig.network.externalEndpointList
|
||||
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,9 +177,9 @@ class ApiService {
|
||||
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
|
||||
urls.add(serverEndpoint);
|
||||
}
|
||||
final network = MetadataRepository.instance.appConfig.network;
|
||||
final network = MetadataRepository.instance.systemConfig.network;
|
||||
final localEndpoint = network.localEndpoint;
|
||||
if (localEndpoint.isNotEmpty) {
|
||||
if (localEndpoint != null) {
|
||||
urls.add(localEndpoint);
|
||||
}
|
||||
for (final url in network.externalEndpointList) {
|
||||
@@ -191,7 +191,7 @@ class ApiService {
|
||||
}
|
||||
|
||||
static Map<String, String> getRequestHeaders() {
|
||||
return MetadataRepository.instance.appConfig.network.customHeaders;
|
||||
return MetadataRepository.instance.systemConfig.network.customHeaders;
|
||||
}
|
||||
|
||||
ApiClient get apiClient => _apiClient;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
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/config/app_config.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';
|
||||
@@ -138,11 +136,15 @@ Future<void> _migrateTo26(Drift drift) 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) {
|
||||
if (raw == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final mode = AlbumSortMode.values.firstWhere(
|
||||
(e) => e.storeIndex == raw,
|
||||
orElse: () => MetadataKey.albumSortMode.defaultValue,
|
||||
);
|
||||
|
||||
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
|
||||
}
|
||||
|
||||
@@ -206,11 +208,7 @@ class _StoreMigrator {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.elementAtOrNull(index);
|
||||
if (enumValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.elementAtOrNull(index) ?? newKey.defaultValue;
|
||||
_cache[newKey] = enumValue;
|
||||
_migratedStoreIds.add(legacyKey.id);
|
||||
}
|
||||
@@ -225,11 +223,7 @@ class _StoreMigrator {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.firstWhereOrNull((e) => e.name == name);
|
||||
if (enumValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final enumValue = values.firstWhere((e) => e.name == name, orElse: () => newKey.defaultValue);
|
||||
_cache[newKey] = enumValue;
|
||||
_migratedStoreIds.add(legacyKey.id);
|
||||
}
|
||||
@@ -273,12 +267,9 @@ class _StoreMigrator {
|
||||
Future<void> complete() async {
|
||||
await _db.batch((batch) {
|
||||
for (final entry in _cache.entries) {
|
||||
if (entry.value == defaultConfig.read(entry.key)) {
|
||||
continue;
|
||||
}
|
||||
batch.insert(
|
||||
_db.metadataEntity,
|
||||
MetadataEntityCompanion(key: Value(entry.key.name), value: Value(entry.key.encode(entry.value))),
|
||||
MetadataEntityCompanion(key: Value(entry.key.key), value: Value(entry.key.encode(entry.value))),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final isManageMediaSupported = useState(false);
|
||||
final manageMediaAndroidPermission = useState(false);
|
||||
final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
|
||||
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index);
|
||||
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
|
||||
useValueChanged(
|
||||
preferRemote.value,
|
||||
|
||||
@@ -64,7 +64,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
final urls = ref.read(appConfigProvider).network.externalEndpointList;
|
||||
final urls = ref.read(metadataProvider).systemConfig.network.externalEndpointList;
|
||||
|
||||
if (urls.isEmpty) {
|
||||
return null;
|
||||
|
||||
@@ -19,7 +19,7 @@ class NetworkingSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentEndpoint = getServerUrl();
|
||||
final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching);
|
||||
final featureEnabled = useState(ref.read(systemConfigProvider).network.autoEndpointSwitching);
|
||||
useValueChanged<bool, void>(featureEnabled.value, (_, __) {
|
||||
ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value);
|
||||
});
|
||||
|
||||
@@ -10,16 +10,9 @@ class ImmichFormController extends ChangeNotifier {
|
||||
FutureOr<void> Function()? onSubmit;
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
bool _isDisposed = false;
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> submit() async {
|
||||
if (_isLoading) {
|
||||
return;
|
||||
@@ -34,9 +27,7 @@ class ImmichFormController extends ChangeNotifier {
|
||||
await onSubmit?.call();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
if (!_isDisposed) {
|
||||
notifyListeners();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +38,13 @@ class ImmichForm extends StatefulWidget {
|
||||
final String? submitText;
|
||||
final IconData? submitIcon;
|
||||
|
||||
const ImmichForm({super.key, this.onSubmit, this.submitText, this.submitIcon, required this.builder});
|
||||
const ImmichForm({
|
||||
super.key,
|
||||
this.onSubmit,
|
||||
this.submitText,
|
||||
this.submitIcon,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichForm> createState() => _ImmichFormState();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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/app_config.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/metadata_key.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
@@ -39,7 +39,7 @@ void main() {
|
||||
registerFallbackValue(LogLevel.info);
|
||||
|
||||
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
|
||||
when(() => mockMetadataRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.fine));
|
||||
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
|
||||
when(() => mockMetadataRepository.write<LogLevel, LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
|
||||
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
|
||||
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
|
||||
@@ -59,7 +59,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('Sets log level based on the metadata repository', () {
|
||||
verify(() => mockMetadataRepository.appConfig).called(1);
|
||||
verify(() => mockMetadataRepository.systemConfig).called(1);
|
||||
expect(Logger.root.level, Level.FINE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ void main() {
|
||||
|
||||
setUp(() async {
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
await MetadataRepository.instance.refresh();
|
||||
await MetadataRepository.refresh();
|
||||
});
|
||||
|
||||
group('defaults', () {
|
||||
@@ -31,8 +31,8 @@ void main() {
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
test('appConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.appConfig.logLevel, LogLevel.info);
|
||||
test('systemConfig returns key defaults when DB is empty', () {
|
||||
expect(sut.systemConfig.logLevel, LogLevel.info);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,14 +46,16 @@ void main() {
|
||||
await sut.write(.themeMode, ThemeMode.light);
|
||||
await sut.write(.logLevel, LogLevel.severe);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.light);
|
||||
expect(sut.appConfig.logLevel, LogLevel.severe);
|
||||
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.write(.themeMode, ThemeMode.system);
|
||||
await sut.delete(.themeMode);
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
final rows = await ctx.db.select(ctx.db.metadataEntity).get();
|
||||
@@ -61,15 +63,13 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('delete', () {});
|
||||
|
||||
group('sync', () {
|
||||
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.name,
|
||||
key: MetadataKey.themeMode.key,
|
||||
value: ThemeMode.dark.name,
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
@@ -78,7 +78,7 @@ void main() {
|
||||
// Cache hasn't seen this row yet — view still returns the default.
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
|
||||
await MetadataRepository.instance.refresh();
|
||||
await MetadataRepository.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ void main() {
|
||||
await ctx.db.delete(ctx.db.metadataEntity).go();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.dark);
|
||||
|
||||
await MetadataRepository.instance.refresh();
|
||||
await MetadataRepository.refresh();
|
||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||
});
|
||||
|
||||
@@ -103,20 +103,32 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await MetadataRepository.instance.refresh();
|
||||
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.watchConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
||||
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
||||
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('watchConfig emits the new value after a write', () async {
|
||||
final expectation = expectLater(sut.watchConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||
|
||||
void main() {
|
||||
group('MetadataKey', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
test('verify codec for $key', () {
|
||||
final defaultValue = defaultConfig.read(key);
|
||||
final encoded = key.encode(defaultValue);
|
||||
test('every key round-trips its default value losslessly', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
final encoded = key.encode(key.defaultValue);
|
||||
final decoded = key.decode(encoded);
|
||||
expect(decoded, defaultValue, reason: 'round-trip failed for ${key.name}');
|
||||
});
|
||||
}
|
||||
expect(decoded, key.defaultValue, reason: 'round-trip failed for ${key.name}');
|
||||
}
|
||||
});
|
||||
|
||||
test('decode falls back to the default value when the raw input is unparseable', () {
|
||||
for (final key in MetadataKey.values) {
|
||||
// String keys can decode any string. So skip them
|
||||
if (key.defaultValue is String) {
|
||||
continue;
|
||||
}
|
||||
expect(
|
||||
key.decode('not a valid encoding for any key'),
|
||||
key.defaultValue,
|
||||
reason: 'fallback failed for ${key.name}',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
|
||||
<div
|
||||
class={[
|
||||
'group flex overflow-hidden focus-visible:outline-none',
|
||||
'group flex overflow-hidden transition-[background-color,border-radius] focus-visible:outline-none',
|
||||
backgroundColorClass,
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
@@ -250,12 +250,12 @@
|
||||
>
|
||||
<ImageThumbnail
|
||||
class={[
|
||||
'absolute group-focus-visible:rounded-lg',
|
||||
'absolute transition-[border-radius] group-focus-visible:rounded-lg',
|
||||
{ 'rounded-xl': selected },
|
||||
imageClass,
|
||||
]}
|
||||
brokenAssetClass={[
|
||||
'z-1 absolute group-focus-visible:rounded-lg',
|
||||
'z-1 absolute group-focus-visible:rounded-lg transition-[border-radius]',
|
||||
{ 'rounded-xl': selected },
|
||||
brokenAssetClass,
|
||||
]}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
mdiPlus,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { mount } from 'svelte';
|
||||
import { flushSync, mount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
const configEntries = $derived(
|
||||
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
|
||||
);
|
||||
let dragImage = $state<Element>();
|
||||
let isDragging = $state(false);
|
||||
let isDropTarget = $state(false);
|
||||
let hoverDrag = $state(false);
|
||||
|
||||
@@ -68,11 +68,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(index));
|
||||
|
||||
const dragElement = document.createElement('div');
|
||||
document.body.append(dragElement);
|
||||
|
||||
mount(WorkflowStepDragImage, {
|
||||
target: document.body,
|
||||
target: dragElement,
|
||||
props: {
|
||||
description: method?.description,
|
||||
isFilter: method?.uiHints?.includes('filter') ?? false,
|
||||
@@ -80,9 +84,9 @@
|
||||
stepNumber: index + 1,
|
||||
},
|
||||
});
|
||||
flushSync();
|
||||
|
||||
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
|
||||
event.dataTransfer.setDragImage(dragImage, 16, 22);
|
||||
event.dataTransfer.setDragImage(dragElement, 16, 22);
|
||||
};
|
||||
|
||||
const handleDrop = (index: number, event: DragEvent) => {
|
||||
@@ -105,8 +109,7 @@
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dragImage?.remove();
|
||||
dragImage = undefined;
|
||||
isDragging = false;
|
||||
isDropTarget = false;
|
||||
};
|
||||
</script>
|
||||
@@ -129,8 +132,8 @@
|
||||
|
||||
<div
|
||||
class="w-full transition-all"
|
||||
class:opacity-40={!!dragImage}
|
||||
class:scale-[0.99]={!!dragImage}
|
||||
class:opacity-40={isDragging}
|
||||
class:scale-[0.99]={isDragging}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (isDropTarget = false)}
|
||||
ondrop={(event) => handleDrop(index, event)}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
id="workflow-step-drag-image"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed top-[-1000px] left-0 flex w-80 items-center gap-2.5 rounded-lg border border-light-200 bg-light px-3 py-2.5 text-sm/5 text-dark shadow-2xl"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user