Compare commits

..

11 Commits

Author SHA1 Message Date
Daniel Dietzler 6bc445a03b chore: move workflows components to different folder 2026-05-28 12:23:27 +02:00
Min Idzelis 1bd367bd51 refactor(web): replace per-asset viewport proximity with day-tier active indices (#28597) 2026-05-28 11:44:18 +02:00
Daniel Dietzler 725f266b81 chore: migrate more make targets to mise (#28651) 2026-05-28 11:31:02 +02:00
Daniel Dietzler d08e3de207 fix: e2e linting (#28659) 2026-05-28 11:12:26 +02:00
Timon 26714f6bfe fix(server): prevent locked assets from leaking to partners (#28652)
* fix(server): prevent locked assets from leaking to partners

* fix tests
2026-05-27 17:33:49 -04:00
Lauritz Tieste a5ce3fc927 fix: Refresh local album overview page after asset deletion (#28586)
fix: invalidate local album provider on asset delete
2026-05-28 01:20:32 +05:30
shenlong 3b23f71a3f refactor: cleanup metadata (#28485)
jason-ify metadata

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 01:19:25 +05:30
shenlong dec33cadd9 fix: verify form disposal before notifyListeners (#28578)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-27 19:45:48 +00:00
Daniel Dietzler 80c15a5e27 fix: workflow drag and drop (#28650) 2026-05-27 14:05:38 -05:00
Yaros 936c28a40b feat(mobile): improve downloading algorithm for sharing (#27312)
* feat(mobile): better downloading while sharing

* chore: separate download group

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-27 17:00:49 +00:00
Spencer Stingley 1a837a28ac fix: dev container properly builds @immich/plugin-sdk for import (#28620)
Co-authored-by: Spencer Stingley <accounts@blankcanvas.io>
2026-05-27 12:41:35 -04:00
50 changed files with 639 additions and 526 deletions
@@ -8,6 +8,8 @@ log "Preparing Immich Web Frontend"
log "" log ""
run_cmd pnpm --filter @immich/sdk install run_cmd pnpm --filter @immich/sdk install
run_cmd pnpm --filter @immich/sdk build run_cmd pnpm --filter @immich/sdk build
run_cmd pnpm --filter @immich/plugin-sdk install
run_cmd pnpm --filter @immich/plugin-sdk build
run_cmd pnpm --filter immich-web install run_cmd pnpm --filter immich-web install
log "Starting Immich Web Frontend" log "Starting Immich Web Frontend"
+1 -1
View File
@@ -1 +1 @@
24.16.0 24.15.0
+11 -11
View File
@@ -1,39 +1,39 @@
dev: 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: 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: 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: 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: dev-docs:
npm --prefix docs run start npm --prefix docs run start
.PHONY: e2e .PHONY: e2e
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: 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: 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: 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: 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: 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: 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 .PHONY: open-api
open-api: open-api:
@@ -55,8 +55,8 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
result.duration.push(asset.duration); result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType); result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId); result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city.push(asset.city); result.city?.push(asset.city);
result.country.push(asset.country); result.country?.push(asset.country);
result.visibility.push(asset.visibility); result.visibility.push(asset.visibility);
} }
+67 -1
View File
@@ -15,7 +15,7 @@ config_roots = [
] ]
[tools] [tools]
node = "24.16.0" node = "24.15.0"
"aqua:flutter/flutter" = "3.44.0" "aqua:flutter/flutter" = "3.44.0"
pnpm = "10.33.4" pnpm = "10.33.4"
terragrunt = "1.0.3" terragrunt = "1.0.3"
@@ -84,6 +84,72 @@ run = [
dir = "server" dir = "server"
run = "node ./dist/bin/sync-sql.js" 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 # SDK tasks
[tasks."sdk:install"] [tasks."sdk:install"]
dir = "packages/sdk" dir = "packages/sdk"
+1
View File
@@ -23,6 +23,7 @@ const String kBackupLivePhotoGroup = 'backup_live_photo_group';
const String kDownloadGroupImage = 'group_image'; const String kDownloadGroupImage = 'group_image';
const String kDownloadGroupVideo = 'group_video'; const String kDownloadGroupVideo = 'group_video';
const String kDownloadGroupLivePhoto = 'group_livephoto'; const String kDownloadGroupLivePhoto = 'group_livephoto';
const String kShareDownloadGroup = 'group_share';
// Timeline constants // Timeline constants
const int kTimelineNoneSegmentSize = 120; const int kTimelineNoneSegmentSize = 120;
+128 -3
View File
@@ -1,14 +1,25 @@
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/album_config.dart';
import 'package:immich_mobile/domain/models/config/backup_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/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/image_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/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/slideshow_config.dart';
import 'package:immich_mobile/domain/models/config/theme_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/timeline_config.dart';
import 'package:immich_mobile/domain/models/config/viewer_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 { class AppConfig {
final LogLevel logLevel;
final ThemeConfig theme; final ThemeConfig theme;
final CleanupConfig cleanup; final CleanupConfig cleanup;
final MapConfig map; final MapConfig map;
@@ -18,8 +29,10 @@ class AppConfig {
final SlideshowConfig slideshow; final SlideshowConfig slideshow;
final AlbumConfig album; final AlbumConfig album;
final BackupConfig backup; final BackupConfig backup;
final NetworkConfig network;
const AppConfig({ const AppConfig({
this.logLevel = .info,
this.theme = const .new(), this.theme = const .new(),
this.cleanup = const .new(), this.cleanup = const .new(),
this.map = const .new(), this.map = const .new(),
@@ -29,9 +42,11 @@ class AppConfig {
this.slideshow = const .new(), this.slideshow = const .new(),
this.album = const .new(), this.album = const .new(),
this.backup = const .new(), this.backup = const .new(),
this.network = const .new(),
}); });
AppConfig copyWith({ AppConfig copyWith({
LogLevel? logLevel,
ThemeConfig? theme, ThemeConfig? theme,
CleanupConfig? cleanup, CleanupConfig? cleanup,
MapConfig? map, MapConfig? map,
@@ -41,7 +56,9 @@ class AppConfig {
SlideshowConfig? slideshow, SlideshowConfig? slideshow,
AlbumConfig? album, AlbumConfig? album,
BackupConfig? backup, BackupConfig? backup,
NetworkConfig? network,
}) => .new( }) => .new(
logLevel: logLevel ?? this.logLevel,
theme: theme ?? this.theme, theme: theme ?? this.theme,
cleanup: cleanup ?? this.cleanup, cleanup: cleanup ?? this.cleanup,
map: map ?? this.map, map: map ?? this.map,
@@ -51,12 +68,14 @@ class AppConfig {
slideshow: slideshow ?? this.slideshow, slideshow: slideshow ?? this.slideshow,
album: album ?? this.album, album: album ?? this.album,
backup: backup ?? this.backup, backup: backup ?? this.backup,
network: network ?? this.network,
); );
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
(other is AppConfig && (other is AppConfig &&
other.logLevel == logLevel &&
other.theme == theme && other.theme == theme &&
other.cleanup == cleanup && other.cleanup == cleanup &&
other.map == map && other.map == map &&
@@ -65,12 +84,118 @@ class AppConfig {
other.viewer == viewer && other.viewer == viewer &&
other.slideshow == slideshow && other.slideshow == slideshow &&
other.album == album && other.album == album &&
other.backup == backup); other.backup == backup &&
other.network == network);
@override @override
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup); int get hashCode =>
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
@override @override
String toString() => String toString() =>
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)'; '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)),
};
}
} }
@@ -2,15 +2,15 @@ import 'package:flutter/foundation.dart';
class NetworkConfig { class NetworkConfig {
final bool autoEndpointSwitching; final bool autoEndpointSwitching;
final String? preferredWifiName; final String preferredWifiName;
final String? localEndpoint; final String localEndpoint;
final List<String> externalEndpointList; final List<String> externalEndpointList;
final Map<String, String> customHeaders; final Map<String, String> customHeaders;
const NetworkConfig({ const NetworkConfig({
this.autoEndpointSwitching = false, this.autoEndpointSwitching = false,
this.preferredWifiName, this.preferredWifiName = '',
this.localEndpoint, this.localEndpoint = '',
this.externalEndpointList = const [], this.externalEndpointList = const [],
this.customHeaders = const {}, this.customHeaders = const {},
}); });
@@ -1,22 +0,0 @@
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)';
}
+72 -117
View File
@@ -1,142 +1,105 @@
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.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/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataDomain<T extends Object> { enum MetadataScope {
appConfig<AppConfig>('config.app'), user, // keys with this scope are deleted on logout
systemConfig<SystemConfig>('config.system'); system;
final String prefix; const MetadataScope();
const MetadataDomain(this.prefix);
} }
enum MetadataKey<T extends Object> { enum MetadataKey<T extends Object> {
// Theme // Theme
themePrimaryColor<ImmichColorPreset>(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)), themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)), themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
themeDynamic<bool>(.appConfig, 'theme.dynamic', false), themeDynamic<bool>(),
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true), themeColorfulInterface<bool>(),
// Image // Image
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false), imagePreferRemote<bool>(),
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false), imageLoadOriginal<bool>(),
// Viewer // Viewer
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true), viewerLoopVideo<bool>(),
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false), viewerLoadOriginalVideo<bool>(),
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true), viewerAutoPlayVideo<bool>(),
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false), viewerTapToNavigate<bool>(),
// Network // Network
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false), networkAutoEndpointSwitching<bool>(scope: .system),
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''), networkPreferredWifiName<String>(scope: .system),
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''), networkLocalEndpoint<String>(scope: .system),
networkExternalEndpointList<List<String>>( networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
.systemConfig,
'network.externalEndpointList',
[],
_ListCodec(_PrimitiveCodec.string),
),
networkCustomHeaders<Map<String, String>>( networkCustomHeaders<Map<String, String>>(
.systemConfig, scope: .system,
'network.customHeaders', codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
{},
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
), ),
// Album // Album
albumSortMode<AlbumSortMode>( albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
.appConfig, albumIsReverse<bool>(),
'album.sortMode', albumIsGrid<bool>(),
AlbumSortMode.mostRecent,
_EnumCodec(AlbumSortMode.values),
),
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
// Backup // Backup
backupEnabled<bool>(.appConfig, 'backup.enabled', false), backupEnabled<bool>(),
backupUseCellularForVideos<bool>(.appConfig, 'backup.useCellularForVideos', false), backupUseCellularForVideos<bool>(),
backupUseCellularForPhotos<bool>(.appConfig, 'backup.useCellularForPhotos', false), backupUseCellularForPhotos<bool>(),
backupRequireCharging<bool>(.appConfig, 'backup.requireCharging', false), backupRequireCharging<bool>(),
backupTriggerDelay<int>(.appConfig, 'backup.triggerDelay', 30), backupTriggerDelay<int>(),
backupSyncAlbums<bool>(.appConfig, 'backup.syncAlbums', false), backupSyncAlbums<bool>(),
// Timeline // Timeline
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4), timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>( timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
.appConfig, timelineStorageIndicator<bool>(),
'timeline.groupAssetsBy',
GroupAssetsBy.day,
_EnumCodec(GroupAssetsBy.values),
),
timelineStorageIndicator<bool>(.appConfig, 'timeline.storageIndicator', true),
// Log // Log
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)), logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
// Map // Map
mapShowFavoriteOnly<bool>(.appConfig, 'map.showFavoriteOnly', false), mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(.appConfig, 'map.relativeDate', 0), mapRelativeDate<int>(),
mapIncludeArchived<bool>(.appConfig, 'map.includeArchived', false), mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(.appConfig, 'map.themeMode', .system, _EnumCodec(ThemeMode.values)), mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(.appConfig, 'map.withPartners', false), mapWithPartners<bool>(),
// Cleanup // Cleanup
cleanupKeepFavorites<bool>(.appConfig, 'cleanup.keepFavorites', true), cleanupKeepFavorites<bool>(),
cleanupKeepMediaType<AssetKeepType>( cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
.appConfig, cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
'cleanup.keepMediaType', cleanupCutoffDaysAgo<int>(),
AssetKeepType.none, cleanupDefaultsInitialized<bool>(),
_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 // Slideshow
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true), slideshowTransition<bool>(),
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true), slideshowRepeat<bool>(),
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5), slideshowDuration<int>(),
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)), slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>( slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
.appConfig,
'slideshow.direction',
SlideshowDirection.forward,
_EnumCodec(SlideshowDirection.values),
);
final MetadataDomain domain; final MetadataScope scope;
final String name;
final T defaultValue;
final _MetadataCodec<T>? _codecOverride; final _MetadataCodec<T>? _codecOverride;
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]); const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
String get key => '${domain.prefix}.$name'; _MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
String encode(T value) => _codec.encode(value); String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw) ?? defaultValue; T decode(String raw) => _codec.decode(raw);
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
} }
sealed class _MetadataCodec<T extends Object> { sealed class _MetadataCodec<T extends Object> {
const _MetadataCodec(); const _MetadataCodec();
String encode(T value); String encode(T value);
T? decode(String raw); T decode(String raw);
static const Map<Type, _MetadataCodec<Object>> _primitives = { static const Map<Type, _MetadataCodec<Object>> _primitives = {
int: _PrimitiveCodec.integer, int: _PrimitiveCodec.integer,
@@ -146,12 +109,10 @@ sealed class _MetadataCodec<T extends Object> {
DateTime: _DateTimeCodec(), DateTime: _DateTimeCodec(),
}; };
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) { static _MetadataCodec<T> forType<T extends Object>(Type runtimeType) {
final codec = _primitives[sample.runtimeType]; final codec = _primitives[runtimeType];
if (codec == null) { if (codec == null) {
throw StateError( throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the MetadataKey.');
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
);
} }
return codec as _MetadataCodec<T>; return codec as _MetadataCodec<T>;
} }
@@ -166,7 +127,7 @@ final class _EnumCodec<T extends Enum> extends _MetadataCodec<T> {
String encode(T value) => value.name; String encode(T value) => value.name;
@override @override
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw); T decode(String raw) => values.firstWhere((v) => v.name == raw);
} }
final class _DateTimeCodec extends _MetadataCodec<DateTime> { final class _DateTimeCodec extends _MetadataCodec<DateTime> {
@@ -176,7 +137,7 @@ final class _DateTimeCodec extends _MetadataCodec<DateTime> {
String encode(DateTime value) => value.toIso8601String(); String encode(DateTime value) => value.toIso8601String();
@override @override
DateTime? decode(String raw) => DateTime.tryParse(raw); DateTime decode(String raw) => DateTime.parse(raw);
} }
final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> { final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec<Map<K, V>> {
@@ -193,29 +154,26 @@ final class _MapCodec<K extends Object, V extends Object> extends _MetadataCodec
} }
@override @override
Map<K, V>? decode(String raw) { Map<K, V> decode(String raw) {
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is! Map) { if (decoded is! Map) {
return null; return {};
} }
final result = <K, V>{}; final result = <K, V>{};
for (final entry in decoded.entries) { for (final entry in decoded.entries) {
final rawKey = entry.key; final rawKey = entry.key;
final rawValue = entry.value; final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) { if (rawKey is! String || rawValue is! String) {
return null; return {};
} }
final k = _keyCodec.decode(rawKey); final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue); final v = _valueCodec.decode(rawValue);
if (k == null || v == null) {
return null;
}
result[k] = v; result[k] = v;
} }
return result; return result;
} on FormatException { } on FormatException {
return null; return {};
} }
} }
} }
@@ -229,32 +187,29 @@ final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList()); String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override @override
List<T>? decode(String raw) { List<T> decode(String raw) {
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is! List) { if (decoded is! List) {
return null; return [];
} }
final result = <T>[]; final result = <T>[];
for (final item in decoded) { for (final item in decoded) {
if (item is! String) { if (item is! String) {
return null; return [];
} }
final element = _elementCodec.decode(item); final element = _elementCodec.decode(item);
if (element == null) {
return null;
}
result.add(element); result.add(element);
} }
return result; return result;
} on FormatException { } on FormatException {
return null; return [];
} }
} }
} }
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> { final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
final T? Function(String) _parse; final T Function(String) _parse;
const _PrimitiveCodec._(this._parse); const _PrimitiveCodec._(this._parse);
@@ -262,12 +217,12 @@ final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
String encode(T value) => value.toString(); String encode(T value) => value.toString();
@override @override
T? decode(String raw) => _parse(raw); T decode(String raw) => _parse(raw);
static const integer = _PrimitiveCodec<int>._(int.tryParse); static const integer = _PrimitiveCodec<int>._(int.parse);
static const real = _PrimitiveCodec<double>._(double.tryParse); static const real = _PrimitiveCodec<double>._(double.parse);
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse); static const boolean = _PrimitiveCodec<bool>._(bool.parse);
static const string = _PrimitiveCodec<String>._(_identity); static const string = _PrimitiveCodec<String>._(_identity);
static String? _identity(String s) => s; static String _identity(String s) => s;
} }
+1 -1
View File
@@ -56,7 +56,7 @@ class LogService {
}) async { }) async {
final instance = LogService._(logRepository, metadataRepository, shouldBuffer); final instance = LogService._(logRepository, metadataRepository, shouldBuffer);
await logRepository.truncate(limit: kLogTruncateLimit); await logRepository.truncate(limit: kLogTruncateLimit);
final level = instance._metadataRepository.systemConfig.logLevel; final level = instance._metadataRepository.appConfig.logLevel;
Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO; Logger.root.level = Level.LEVELS.elementAtOrNull(level.index) ?? Level.INFO;
return instance; return instance;
} }
@@ -1,14 +1,12 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/config/app_config.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/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/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class MetadataRepository extends DriftDatabaseRepository { class MetadataRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
final Map<MetadataKey, Object> _cache = {};
MetadataRepository._(this._db) : super(_db); MetadataRepository._(this._db) : super(_db);
@@ -25,153 +23,50 @@ class MetadataRepository extends DriftDatabaseRepository {
AppConfig _appConfig = const .new(); AppConfig _appConfig = const .new();
AppConfig get appConfig => _appConfig; AppConfig get appConfig => _appConfig;
SystemConfig _systemConfig = const .new();
SystemConfig get systemConfig => _systemConfig;
static Future<MetadataRepository> ensureInitialized(Drift db) async { static Future<MetadataRepository> ensureInitialized(Drift db) async {
if (_instance == null) { if (_instance == null) {
final instance = MetadataRepository._(db); final instance = MetadataRepository._(db);
await instance._hydrate(); await instance.refresh();
_instance = instance; _instance = instance;
} }
return _instance!; return _instance!;
} }
static Future<void> refresh() async { Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
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 { Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (_read(key) == value) { if (value == _appConfig.read(key)) {
return; return;
} }
await _db if (value == defaultConfig.read(key)) {
.into(_db.metadataEntity) await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
.insertOnConflictUpdate( } else {
MetadataEntityCompanion.insert(key: key.key, value: key.encode(value), updatedAt: Value(DateTime.now())), await _db
); .into(_db.metadataEntity)
_updateCache(key, value); .insertOnConflictUpdate(
} MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
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);
}
Stream<AppConfig> watchAppConfig() => _watchDomain(.appConfig).distinct();
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));
} }
_appConfig = _appConfig.write(key, value);
} }
void _updateCache<T extends Object>(MetadataKey<T> key, T value) { Stream<AppConfig> watchConfig() => _db.select(_db.metadataEntity).watch().map((rows) {
if (_cache[key] == value) { _applyOverrides(rows);
return; return _appConfig;
} });
_cache[key] = value;
key.domain.rebuild(this); void _applyOverrides(List<MetadataEntityData> rows) {
} _appConfig = AppConfig.fromEntries(
} rows.fold({}, (overrides, row) {
final metadataKey = MetadataKey.values.firstWhereOrNull((key) => key.name == row.key);
extension<T extends Object> on MetadataDomain<T> { if (metadataKey == null) {
T config(MetadataRepository repo) => switch (this) { return overrides;
.appConfig => repo._appConfig as T, }
.systemConfig => repo._systemConfig as T,
}; return {...overrides, metadataKey: metadataKey.decode(row.value)};
}),
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(); _searchController = TextEditingController();
_searchFocusNode = FocusNode(); _searchFocusNode = FocusNode();
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.backup.syncAlbums; _enableSyncUploadAlbum.value = ref.read(appConfigProvider).backup.syncAlbums;
ref.read(backupAlbumProvider.notifier).getAll(); ref.read(backupAlbumProvider.notifier).getAll();
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount)); _initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
@@ -55,7 +55,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
return; return;
} }
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.backup.syncAlbums; final enableSyncUploadAlbum = ref.read(appConfigProvider).backup.syncAlbums;
final selectedAlbums = ref final selectedAlbums = ref
.read(backupAlbumProvider) .read(backupAlbumProvider)
.where((a) => a.backupSelection == BackupSelection.selected) .where((a) => a.backupSelection == BackupSelection.selected)
@@ -19,7 +19,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
bool hasPopped = false; bool hasPopped = false;
final previousBackup = ref.read(metadataProvider).appConfig.backup; final previousBackup = ref.read(appConfigProvider).backup;
final previousCellularForVideos = previousBackup.useCellularForVideos; final previousCellularForVideos = previousBackup.useCellularForVideos;
final previousCellularForPhotos = previousBackup.useCellularForPhotos; final previousCellularForPhotos = previousBackup.useCellularForPhotos;
return PopScope( return PopScope(
@@ -22,7 +22,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
final headers = useState<List<SettingsHeader>>([]); final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false); final setInitialHeaders = useState(false);
final storedHeaders = ref.read(metadataProvider).systemConfig.network.customHeaders; final storedHeaders = ref.read(metadataProvider).appConfig.network.customHeaders;
if (!setInitialHeaders.value) { if (!setInitialHeaders.value) {
storedHeaders.forEach((k, v) { storedHeaders.forEach((k, v) {
final header = SettingsHeader(); final header = SettingsHeader();
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
@@ -36,7 +36,7 @@ class BootstrapErrorWidget extends StatelessWidget {
@override @override
Widget build(BuildContext _) { Widget build(BuildContext _) {
final immichTheme = MetadataKey.themePrimaryColor.defaultValue.themeOfPreset; final immichTheme = defaultConfig.theme.primaryColor.themeOfPreset;
return EasyLocalization( return EasyLocalization(
supportedLocales: locales.values.toList(), supportedLocales: locales.values.toList(),
@@ -9,6 +9,7 @@ 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/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.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: /// This delete action has the following behavior:
/// - Prompt to delete the asset locally /// - Prompt to delete the asset locally
@@ -39,6 +40,8 @@ class DeleteLocalActionButton extends ConsumerWidget {
return; return;
} }
ref.invalidate(localAlbumProvider);
final successMessage = 'delete_local_action_prompt'.t(context: context, args: {'count': result.count.toString()}); final successMessage = 'delete_local_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) { if (context.mounted) {
@@ -14,7 +14,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class _SharePreparingDialog extends StatelessWidget { class _SharePreparingDialog extends StatelessWidget {
const _SharePreparingDialog(); final ValueNotifier<double?> progress;
const _SharePreparingDialog({required this.progress});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -22,8 +24,24 @@ class _SharePreparingDialog extends StatelessWidget {
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const CircularProgressIndicator(), Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()),
Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()), SizedBox(
width: 240,
child: ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (context, value, _) {
final percent = value == null ? null : (value * 100).clamp(0, 100);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
LinearProgressIndicator(value: value, minHeight: 8.0),
if (percent != null)
Container(margin: const EdgeInsets.only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
],
);
},
),
),
], ],
), ),
); );
@@ -43,32 +61,39 @@ class ShareActionButton extends ConsumerWidget {
} }
final cancelCompleter = Completer<void>(); final cancelCompleter = Completer<void>();
const preparingDialog = _SharePreparingDialog(); final progress = ValueNotifier<double?>(null);
final preparingDialog = _SharePreparingDialog(progress: progress);
await showDialog( await showDialog(
context: context, context: context,
builder: (BuildContext buildContext) { builder: (BuildContext buildContext) {
ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then(( ref
ActionResult result, .read(actionProvider.notifier)
) { .shareAssets(
if (cancelCompleter.isCompleted || !context.mounted) { source,
return; context,
} cancelCompleter: cancelCompleter,
onAssetDownloadProgress: (value) => progress.value = value,
)
.then((ActionResult result) {
if (cancelCompleter.isCompleted || !context.mounted) {
return;
}
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
if (!result.success) { if (!result.success) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'scaffold_body_error_occurred'.t(context: context), msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
toastType: ToastType.error, toastType: ToastType.error,
); );
} }
buildContext.pop(); buildContext.pop();
}); });
// show a loading spinner with a "Preparing" message // Show download progress with a "Preparing" message
return preparingDialog; return preparingDialog;
}, },
barrierDismissible: false, barrierDismissible: false,
@@ -77,6 +102,7 @@ class ShareActionButton extends ConsumerWidget {
if (!cancelCompleter.isCompleted) { if (!cancelCompleter.isCompleted) {
cancelCompleter.complete(); cancelCompleter.complete();
} }
progress.dispose();
}); });
} }
+3 -3
View File
@@ -130,7 +130,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
await _apiService.updateHeaders(); await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final headerMap = _ref.read(metadataProvider).systemConfig.network.customHeaders; final headerMap = _ref.read(metadataProvider).appConfig.network.customHeaders;
final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap); final customHeaders = headerMap.isEmpty ? null : jsonEncode(headerMap);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders); await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
@@ -187,11 +187,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
} }
String? getSavedWifiName() { String? getSavedWifiName() {
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName; return _ref.read(metadataProvider).appConfig.network.preferredWifiName;
} }
String? getSavedLocalEndpoint() { String? getSavedLocalEndpoint() {
return _ref.read(metadataProvider).systemConfig.network.localEndpoint; return _ref.read(metadataProvider).appConfig.network.localEndpoint;
} }
/// Returns the current server endpoint (with /api) URL from the store /// Returns the current server endpoint (with /api) URL from the store
@@ -465,11 +465,17 @@ class ActionNotifier extends Notifier<void> {
ActionSource source, ActionSource source,
BuildContext context, { BuildContext context, {
Completer<void>? cancelCompleter, Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async { }) async {
final ids = _getAssets(source).toList(growable: false); final ids = _getAssets(source).toList(growable: false);
try { try {
await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter); await _service.shareAssets(
ids,
context,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
return ActionResult(count: ids.length, success: true); return ActionResult(count: ids.length, success: true);
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Failed to share assets', error, stack); _logger.severe('Failed to share assets', error, stack);
@@ -1,20 +1,12 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/config/app_config.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'; import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
final metadataProvider = Provider.autoDispose<MetadataRepository>((_) => MetadataRepository.instance); final metadataProvider = Provider.autoDispose<MetadataRepository>((_) => MetadataRepository.instance);
final appConfigProvider = Provider.autoDispose<AppConfig>((ref) { final appConfigProvider = Provider.autoDispose<AppConfig>((ref) {
final repo = ref.watch(metadataProvider); final repo = ref.watch(metadataProvider);
final subscription = repo.watchAppConfig().listen((event) => ref.state = event); final subscription = repo.watchConfig().listen((event) => ref.state = event);
ref.onDispose(subscription.cancel); ref.onDispose(subscription.cancel);
return repo.appConfig; 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,31 +1,29 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider( final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider)));
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
);
class AssetMediaRepository { class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
static final Logger _log = Logger("AssetMediaRepository"); static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi); const AssetMediaRepository(this._nativeSyncApi);
Future<bool> _androidSupportsTrash() async { Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
@@ -107,10 +105,29 @@ class AssetMediaRepository {
); );
} }
// TODO: make this more efficient Future<int> shareAssets(
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async { List<BaseAsset> assets,
BuildContext context, {
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
final downloadedXFiles = <XFile>[]; final downloadedXFiles = <XFile>[];
final tempFiles = <File>[]; final tempFiles = <File>[];
final totalAssets = assets.length;
var processedAssets = 0;
void updateProgress([double currentAssetProgress = 0.0]) {
if (totalAssets <= 0) {
onAssetDownloadProgress?.call(1.0);
return;
}
final normalizedAssetProgress = currentAssetProgress.clamp(0.0, 1.0);
final overallProgress = ((processedAssets + normalizedAssetProgress) / totalAssets).clamp(0.0, 1.0);
onAssetDownloadProgress?.call(overallProgress);
}
updateProgress();
for (var asset in assets) { for (var asset in assets) {
if (cancelCompleter != null && cancelCompleter.isCompleted) { if (cancelCompleter != null && cancelCompleter.isCompleted) {
@@ -127,6 +144,8 @@ class AssetMediaRepository {
if (localId != null && !asset.isEdited) { if (localId != null && !asset.isEdited) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile; File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path)); downloadedXFiles.add(XFile(f!.path));
processedAssets++;
updateProgress();
if (CurrentPlatform.isIOS) { if (CurrentPlatform.isIOS) {
tempFiles.add(f); tempFiles.add(f);
} }
@@ -134,22 +153,50 @@ class AssetMediaRepository {
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId; final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
if (remoteId == null) { if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset"); _log.warning("Asset has no remote ID for sharing: $asset");
processedAssets++;
updateProgress();
continue; continue;
} }
final tempDir = await getTemporaryDirectory(); final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
final name = asset.name; final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
final tempFile = await File('${tempDir.path}/$name').create(); final task = DownloadTask(
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true); taskId: taskId,
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
headers: ApiService.getRequestHeaders(),
filename: sanitizedFilename,
baseDirectory: BaseDirectory.temporary,
group: kShareDownloadGroup,
updates: Updates.statusAndProgress,
);
final statusUpdate = await FileDownloader().download(
task,
onProgress: (value) {
if (cancelCompleter != null && cancelCompleter.isCompleted) {
unawaited(FileDownloader().cancelTaskWithId(taskId));
return;
}
updateProgress(value);
},
);
if (res.statusCode != 200) { if (cancelCompleter != null && cancelCompleter.isCompleted) {
_log.severe("Download for $name failed", res.toLoggerString()); await _cleanupTempFiles(tempFiles);
continue; return 0;
} }
await tempFile.writeAsBytes(res.bodyBytes); if (statusUpdate.status == TaskStatus.complete) {
downloadedXFiles.add(XFile(tempFile.path)); final filePath = await task.filePath();
tempFiles.add(tempFile); final file = File(filePath);
tempFiles.add(file);
downloadedXFiles.add(XFile(filePath));
processedAssets++;
updateProgress();
continue;
}
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
processedAssets++;
updateProgress();
} }
} }
+8 -10
View File
@@ -1,40 +1,38 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/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/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.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/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
final authRepositoryProvider = Provider<AuthRepository>( final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)), (ref) => AuthRepository(ref.watch(driftProvider), ref.watch(appConfigProvider)),
); );
class AuthRepository { class AuthRepository {
final Drift _drift; final Drift _drift;
final MetadataRepository _metadata; final AppConfig _config;
const AuthRepository(this._drift, this._metadata); const AuthRepository(this._drift, this._config);
Future<void> clearLocalData() async { Future<void> clearLocalData() async {
await SyncStreamRepository(_drift).reset(); await SyncStreamRepository(_drift).reset();
} }
bool getEndpointSwitchingFeature() { bool getEndpointSwitchingFeature() {
return _metadata.systemConfig.network.autoEndpointSwitching; return _config.network.autoEndpointSwitching;
} }
String? getPreferredWifiName() { String? getPreferredWifiName() {
return _metadata.systemConfig.network.preferredWifiName; return _config.network.preferredWifiName;
} }
String? getLocalEndpoint() { String? getLocalEndpoint() {
return _metadata.systemConfig.network.localEndpoint; return _config.network.localEndpoint;
} }
List<AuxilaryEndpoint> getExternalEndpointList() { List<AuxilaryEndpoint> getExternalEndpointList() {
return _metadata.systemConfig.network.externalEndpointList return _config.network.externalEndpointList.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
.toList();
} }
} }
+12 -2
View File
@@ -269,8 +269,18 @@ class ActionService {
await _assetApiRepository.unStack(stackIds); await _assetApiRepository.unStack(stackIds);
} }
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) { Future<int> shareAssets(
return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter); List<BaseAsset> assets,
BuildContext context, {
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) {
return _assetMediaRepository.shareAssets(
assets,
context,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
} }
Future<List<bool>> downloadAll(List<RemoteAsset> assets) { Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
+3 -3
View File
@@ -177,9 +177,9 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) { if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint); urls.add(serverEndpoint);
} }
final network = MetadataRepository.instance.systemConfig.network; final network = MetadataRepository.instance.appConfig.network;
final localEndpoint = network.localEndpoint; final localEndpoint = network.localEndpoint;
if (localEndpoint != null) { if (localEndpoint.isNotEmpty) {
urls.add(localEndpoint); urls.add(localEndpoint);
} }
for (final url in network.externalEndpointList) { for (final url in network.externalEndpointList) {
@@ -191,7 +191,7 @@ class ApiService {
} }
static Map<String, String> getRequestHeaders() { static Map<String, String> getRequestHeaders() {
return MetadataRepository.instance.systemConfig.network.customHeaders; return MetadataRepository.instance.appConfig.network.customHeaders;
} }
ApiClient get apiClient => _apiClient; ApiClient get apiClient => _apiClient;
+18 -9
View File
@@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.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/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
@@ -136,15 +138,11 @@ Future<void> _migrateTo26(Drift drift) async {
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async { Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id); final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
if (raw == null) { final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
if (mode == null) {
return; return;
} }
final mode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == raw,
orElse: () => MetadataKey.albumSortMode.defaultValue,
);
migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode); migrator.stage(StoreKey.legacySelectedAlbumSortOrder, MetadataKey.albumSortMode, mode);
} }
@@ -208,7 +206,11 @@ class _StoreMigrator {
return; return;
} }
final enumValue = values.elementAtOrNull(index) ?? newKey.defaultValue; final enumValue = values.elementAtOrNull(index);
if (enumValue == null) {
return;
}
_cache[newKey] = enumValue; _cache[newKey] = enumValue;
_migratedStoreIds.add(legacyKey.id); _migratedStoreIds.add(legacyKey.id);
} }
@@ -223,7 +225,11 @@ class _StoreMigrator {
return; return;
} }
final enumValue = values.firstWhere((e) => e.name == name, orElse: () => newKey.defaultValue); final enumValue = values.firstWhereOrNull((e) => e.name == name);
if (enumValue == null) {
return;
}
_cache[newKey] = enumValue; _cache[newKey] = enumValue;
_migratedStoreIds.add(legacyKey.id); _migratedStoreIds.add(legacyKey.id);
} }
@@ -267,9 +273,12 @@ class _StoreMigrator {
Future<void> complete() async { Future<void> complete() async {
await _db.batch((batch) { await _db.batch((batch) {
for (final entry in _cache.entries) { for (final entry in _cache.entries) {
if (entry.value == defaultConfig.read(entry.key)) {
continue;
}
batch.insert( batch.insert(
_db.metadataEntity, _db.metadataEntity,
MetadataEntityCompanion(key: Value(entry.key.key), value: Value(entry.key.encode(entry.value))), MetadataEntityCompanion(key: Value(entry.key.name), value: Value(entry.key.encode(entry.value))),
mode: InsertMode.insertOrReplace, mode: InsertMode.insertOrReplace,
); );
} }
@@ -31,7 +31,7 @@ class AdvancedSettings extends HookConsumerWidget {
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false); final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false); final manageMediaAndroidPermission = useState(false);
final levelId = useState<int>(ref.read(systemConfigProvider).logLevel.index); final levelId = useState<int>(ref.read(appConfigProvider).logLevel.index);
final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote); final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
useValueChanged( useValueChanged(
preferRemote.value, preferRemote.value,
@@ -64,7 +64,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
} }
useEffect(() { useEffect(() {
final urls = ref.read(metadataProvider).systemConfig.network.externalEndpointList; final urls = ref.read(appConfigProvider).network.externalEndpointList;
if (urls.isEmpty) { if (urls.isEmpty) {
return null; return null;
@@ -19,7 +19,7 @@ class NetworkingSettings extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint = getServerUrl(); final currentEndpoint = getServerUrl();
final featureEnabled = useState(ref.read(systemConfigProvider).network.autoEndpointSwitching); final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching);
useValueChanged<bool, void>(featureEnabled.value, (_, __) { useValueChanged<bool, void>(featureEnabled.value, (_, __) {
ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value); ref.read(metadataProvider).write(.networkAutoEndpointSwitching, featureEnabled.value);
}); });
@@ -10,9 +10,16 @@ class ImmichFormController extends ChangeNotifier {
FutureOr<void> Function()? onSubmit; FutureOr<void> Function()? onSubmit;
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
bool _isDisposed = false;
bool _isLoading = false; bool _isLoading = false;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
@override
void dispose() {
_isDisposed = true;
super.dispose();
}
Future<void> submit() async { Future<void> submit() async {
if (_isLoading) { if (_isLoading) {
return; return;
@@ -27,7 +34,9 @@ class ImmichFormController extends ChangeNotifier {
await onSubmit?.call(); await onSubmit?.call();
} finally { } finally {
_isLoading = false; _isLoading = false;
notifyListeners(); if (!_isDisposed) {
notifyListeners();
}
} }
} }
} }
@@ -38,13 +47,7 @@ class ImmichForm extends StatefulWidget {
final String? submitText; final String? submitText;
final IconData? submitIcon; final IconData? submitIcon;
const ImmichForm({ const ImmichForm({super.key, this.onSubmit, this.submitText, this.submitIcon, required this.builder});
super.key,
this.onSubmit,
this.submitText,
this.submitIcon,
required this.builder,
});
@override @override
State<ImmichForm> createState() => _ImmichFormState(); State<ImmichForm> createState() => _ImmichFormState();
@@ -1,7 +1,7 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/config/system_config.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/log.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
@@ -39,7 +39,7 @@ void main() {
registerFallbackValue(LogLevel.info); registerFallbackValue(LogLevel.info);
when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {}); when(() => mockLogRepo.truncate(limit: any(named: 'limit'))).thenAnswer((_) async => {});
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine)); when(() => mockMetadataRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.fine));
when(() => mockMetadataRepository.write<LogLevel, LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {}); when(() => mockMetadataRepository.write<LogLevel, LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
when(() => mockLogRepo.getAll()).thenAnswer((_) async => []); when(() => mockLogRepo.getAll()).thenAnswer((_) async => []);
when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true);
@@ -59,7 +59,7 @@ void main() {
}); });
test('Sets log level based on the metadata repository', () { test('Sets log level based on the metadata repository', () {
verify(() => mockMetadataRepository.systemConfig).called(1); verify(() => mockMetadataRepository.appConfig).called(1);
expect(Logger.root.level, Level.FINE); expect(Logger.root.level, Level.FINE);
}); });
}); });
@@ -23,7 +23,7 @@ void main() {
setUp(() async { setUp(() async {
await ctx.db.delete(ctx.db.metadataEntity).go(); await ctx.db.delete(ctx.db.metadataEntity).go();
await MetadataRepository.refresh(); await MetadataRepository.instance.refresh();
}); });
group('defaults', () { group('defaults', () {
@@ -31,8 +31,8 @@ void main() {
expect(sut.appConfig.theme.mode, ThemeMode.system); expect(sut.appConfig.theme.mode, ThemeMode.system);
}); });
test('systemConfig returns key defaults when DB is empty', () { test('appConfig returns key defaults when DB is empty', () {
expect(sut.systemConfig.logLevel, LogLevel.info); expect(sut.appConfig.logLevel, LogLevel.info);
}); });
}); });
@@ -46,16 +46,14 @@ void main() {
await sut.write(.themeMode, ThemeMode.light); await sut.write(.themeMode, ThemeMode.light);
await sut.write(.logLevel, LogLevel.severe); await sut.write(.logLevel, LogLevel.severe);
expect(sut.appConfig.theme.mode, ThemeMode.light); expect(sut.appConfig.theme.mode, ThemeMode.light);
expect(sut.systemConfig.logLevel, LogLevel.severe); expect(sut.appConfig.logLevel, LogLevel.severe);
}); });
});
group('delete', () {
test('removes the row and reverts to default', () async { test('removes the row and reverts to default', () async {
await sut.write(.themeMode, ThemeMode.dark); await sut.write(.themeMode, ThemeMode.dark);
expect(sut.appConfig.theme.mode, ThemeMode.dark); expect(sut.appConfig.theme.mode, ThemeMode.dark);
await sut.delete(.themeMode); await sut.write(.themeMode, ThemeMode.system);
expect(sut.appConfig.theme.mode, ThemeMode.system); expect(sut.appConfig.theme.mode, ThemeMode.system);
final rows = await ctx.db.select(ctx.db.metadataEntity).get(); final rows = await ctx.db.select(ctx.db.metadataEntity).get();
@@ -63,13 +61,15 @@ void main() {
}); });
}); });
group('refresh', () { group('delete', () {});
group('sync', () {
test('picks up rows that were inserted directly into the DB', () async { test('picks up rows that were inserted directly into the DB', () async {
await ctx.db await ctx.db
.into(ctx.db.metadataEntity) .into(ctx.db.metadataEntity)
.insert( .insert(
MetadataEntityCompanion.insert( MetadataEntityCompanion.insert(
key: MetadataKey.themeMode.key, key: MetadataKey.themeMode.name,
value: ThemeMode.dark.name, value: ThemeMode.dark.name,
updatedAt: Value(DateTime.now()), updatedAt: Value(DateTime.now()),
), ),
@@ -78,7 +78,7 @@ void main() {
// Cache hasn't seen this row yet — view still returns the default. // Cache hasn't seen this row yet — view still returns the default.
expect(sut.appConfig.theme.mode, ThemeMode.system); expect(sut.appConfig.theme.mode, ThemeMode.system);
await MetadataRepository.refresh(); await MetadataRepository.instance.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.dark); expect(sut.appConfig.theme.mode, ThemeMode.dark);
}); });
@@ -88,7 +88,7 @@ void main() {
await ctx.db.delete(ctx.db.metadataEntity).go(); await ctx.db.delete(ctx.db.metadataEntity).go();
expect(sut.appConfig.theme.mode, ThemeMode.dark); expect(sut.appConfig.theme.mode, ThemeMode.dark);
await MetadataRepository.refresh(); await MetadataRepository.instance.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system); expect(sut.appConfig.theme.mode, ThemeMode.system);
}); });
@@ -103,32 +103,20 @@ void main() {
), ),
); );
await MetadataRepository.refresh(); await MetadataRepository.instance.refresh();
expect(sut.appConfig.theme.mode, ThemeMode.system); expect(sut.appConfig.theme.mode, ThemeMode.system);
}); });
}); });
group('watch', () { group('watch', () {
test('watchAppConfig emits the new value after a write', () async { test('watchAppConfig emits the new value after a write', () async {
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark)); final expectation = expectLater(sut.watchConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
await sut.write(MetadataKey.themeMode, ThemeMode.dark); await sut.write(MetadataKey.themeMode, ThemeMode.dark);
await expectation; await expectation;
}); });
test('watchAppConfig does not emit when only system-config rows change', () async { test('watchConfig emits the new value after a write', () async {
final emissions = <ThemeMode>[]; final expectation = expectLater(sut.watchConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
// 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 sut.write(MetadataKey.logLevel, LogLevel.warning);
await expectation; await expectation;
}); });
@@ -1,28 +1,16 @@
import 'package:flutter_test/flutter_test.dart'; 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'; import 'package:immich_mobile/domain/models/metadata_key.dart';
void main() { void main() {
group('MetadataKey', () { group('MetadataKey', () {
test('every key round-trips its default value losslessly', () { for (final key in MetadataKey.values) {
for (final key in MetadataKey.values) { test('verify codec for $key', () {
final encoded = key.encode(key.defaultValue); final defaultValue = defaultConfig.read(key);
final encoded = key.encode(defaultValue);
final decoded = key.decode(encoded); final decoded = key.decode(encoded);
expect(decoded, key.defaultValue, reason: 'round-trip failed for ${key.name}'); expect(decoded, 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}',
);
}
});
}); });
} }
+9 -5
View File
@@ -75,7 +75,7 @@ export class SearchService extends BaseService {
const page = dto.page ?? 1; const page = dto.page ?? 1;
const size = dto.size || 250; const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth); const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const { hasNextPage, items } = await this.searchRepository.searchMetadata( const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size }, { page, size },
{ {
@@ -103,7 +103,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth); requireElevatedPermission(auth);
} }
const userIds = await this.getUserIdsToSearch(auth); const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth })); return items.map((item) => mapAsset(item, { auth }));
} }
@@ -113,7 +113,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth); requireElevatedPermission(auth);
} }
const userIds = await this.getUserIdsToSearch(auth); const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds }); const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth })); return items.map((item) => mapAsset(item, { auth }));
} }
@@ -128,7 +128,7 @@ export class SearchService extends BaseService {
throw new BadRequestException('Smart search is not enabled'); throw new BadRequestException('Smart search is not enabled');
} }
const userIds = this.getUserIdsToSearch(auth); const userIds = this.getUserIdsToSearch(auth, dto.visibility);
let embedding; let embedding;
if (dto.query) { if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language; const key = machineLearning.clip.modelName + dto.query + dto.language;
@@ -202,7 +202,11 @@ export class SearchService extends BaseService {
} }
} }
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> { private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
if (visibility === AssetVisibility.Locked) {
return [auth.user.id];
}
const partnerIds = await getMyPartnerIds({ const partnerIds = await getMyPartnerIds({
userId: auth.user.id, userId: auth.user.id,
repository: this.partnerRepository, repository: this.partnerRepository,
@@ -204,5 +204,16 @@ describe(TimelineService.name, () => {
}), }),
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
}); });
it('should throw an error if withPartners is true and visibility is locked', async () => {
await expect(
sut.getTimeBucket(authStub.adminWithElevatedPermission, {
timeBucket: 'bucket',
visibility: AssetVisibility.Locked,
withPartners: true,
userId: authStub.adminWithElevatedPermission.user.id,
}),
).rejects.toThrow(BadRequestException);
});
}); });
}); });
+3 -2
View File
@@ -71,13 +71,14 @@ export class TimelineService extends BaseService {
} }
if (dto.withPartners) { if (dto.withPartners) {
const requestedLocked = dto.visibility === AssetVisibility.Locked;
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined; const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true; const requestedTrash = dto.isTrashed === true;
if (requestedArchived || requestedFavorite || requestedTrash) { if (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException( throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
} }
} }
+7
View File
@@ -41,6 +41,13 @@ export const authStub = {
id: 'token-id', id: 'token-id',
} as AuthSession, } as AuthSession,
}), }),
adminWithElevatedPermission: Object.freeze<AuthDto>({
user: authUser.admin,
session: {
id: 'token-id-elevated',
hasElevatedPermission: true,
} as AuthSession,
}),
adminSharedLink: Object.freeze({ adminSharedLink: Object.freeze({
user: authUser.admin, user: authUser.admin,
sharedLink: { sharedLink: {
@@ -51,13 +51,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive }); const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive });
await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow( await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
const response2 = sut.getTimeBuckets(auth, { withPartners: true }); const response2 = sut.getTimeBuckets(auth, { withPartners: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException); await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow( await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
}); });
@@ -67,13 +67,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false }); const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false });
await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow( await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true }); const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException); await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow( await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
}); });
@@ -83,7 +83,7 @@ describe(TimelineService.name, () => {
const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true }); const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true });
await expect(response).rejects.toBeInstanceOf(BadRequestException); await expect(response).rejects.toBeInstanceOf(BadRequestException);
await expect(response).rejects.toThrow( await expect(response).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
}); });
@@ -30,14 +30,11 @@
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150); const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport));
const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport));
</script> </script>
<!-- Image grid --> <!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}> <div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)} {#each viewerAssets as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
+1 -1
View File
@@ -100,7 +100,7 @@
<AssetLayout <AssetLayout
{manager} {manager}
viewerAssets={timelineDay.viewerAssets} viewerAssets={timelineDay.activeViewerAssets}
height={timelineDay.height} height={timelineDay.height}
width={timelineDay.width} width={timelineDay.width}
{customThumbnailLayout} {customThumbnailLayout}
@@ -10,7 +10,7 @@
mdiPlus, mdiPlus,
mdiTrashCanOutline, mdiTrashCanOutline,
} from '@mdi/js'; } from '@mdi/js';
import { flushSync, mount } from 'svelte'; import { mount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte'; import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
@@ -30,7 +30,7 @@
const configEntries = $derived( const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''), Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
); );
let isDragging = $state(false); let dragImage = $state<Element>();
let isDropTarget = $state(false); let isDropTarget = $state(false);
let hoverDrag = $state(false); let hoverDrag = $state(false);
@@ -68,15 +68,11 @@
return; return;
} }
isDragging = true;
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(index)); event.dataTransfer.setData('text/plain', String(index));
const dragElement = document.createElement('div');
document.body.append(dragElement);
mount(WorkflowStepDragImage, { mount(WorkflowStepDragImage, {
target: dragElement, target: document.body,
props: { props: {
description: method?.description, description: method?.description,
isFilter: method?.uiHints?.includes('filter') ?? false, isFilter: method?.uiHints?.includes('filter') ?? false,
@@ -84,9 +80,9 @@
stepNumber: index + 1, stepNumber: index + 1,
}, },
}); });
flushSync();
event.dataTransfer.setDragImage(dragElement, 16, 22); dragImage = document.body.querySelector('#workflow-step-drag-image')!;
event.dataTransfer.setDragImage(dragImage, 16, 22);
}; };
const handleDrop = (index: number, event: DragEvent) => { const handleDrop = (index: number, event: DragEvent) => {
@@ -109,7 +105,8 @@
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
isDragging = false; dragImage?.remove();
dragImage = undefined;
isDropTarget = false; isDropTarget = false;
}; };
</script> </script>
@@ -132,8 +129,8 @@
<div <div
class="w-full transition-all" class="w-full transition-all"
class:opacity-40={isDragging} class:opacity-40={!!dragImage}
class:scale-[0.99]={isDragging} class:scale-[0.99]={!!dragImage}
ondragover={handleDragOver} ondragover={handleDragOver}
ondragleave={() => (isDropTarget = false)} ondragleave={() => (isDropTarget = false)}
ondrop={(event) => handleDrop(index, event)} ondrop={(event) => handleDrop(index, event)}
@@ -13,6 +13,7 @@
</script> </script>
<div <div
id="workflow-step-drag-image"
aria-hidden="true" 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" 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"
> >
@@ -53,17 +53,3 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
timelineManager.clearDeferredLayout(month); timelineManager.clearDeferredLayout(month);
} }
} }
export function calculateViewerAssetViewportProximity(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
const headerHeight = timelineManager.headerHeight;
return calculateViewportProximity(
positionTop,
positionTop + positionHeight,
timelineManager.visibleWindow.top - headerHeight,
timelineManager.visibleWindow.bottom + headerHeight,
);
}
@@ -1,12 +1,31 @@
import { AssetOrder, AssetOrderBy } from '@immich/sdk'; import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils'; import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util'; import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { TimelineMonth } from './timeline-month.svelte'; import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types'; import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
function lowerBound(assets: ViewerAsset[], target: number, key: (pos: CommonPosition) => number): number {
let lo = 0;
let hi = assets.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (key(assets[mid].position!) < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
export class TimelineDay { export class TimelineDay {
readonly timelineMonth: TimelineMonth; readonly timelineMonth: TimelineMonth;
readonly index: number; readonly index: number;
@@ -18,12 +37,15 @@ export class TimelineDay {
height = $state(0); height = $state(0);
width = $state(0); width = $state(0);
// Assets in or near the viewport; active assets should be added to the DOM.
activeViewerAssets: ViewerAsset[] = $state([]);
isInOrNearViewport = $state(false);
#top: number = $state(0); #top: number = $state(0);
#start: number = $state(0); #start: number = $state(0);
#row = $state(0); #row = $state(0);
#col = $state(0); #col = $state(0);
#deferredLayout = false; #deferredLayout = false;
#lastInOrNearViewport = -1;
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) { constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
this.index = index; this.index = index;
@@ -149,18 +171,32 @@ export class TimelineDay {
for (let i = 0; i < this.viewerAssets.length; i++) { for (let i = 0; i < this.viewerAssets.length; i++) {
this.viewerAssets[i].position = geometry.getPosition(i); this.viewerAssets[i].position = geometry.getPosition(i);
} }
this.updateAssetBoundaries();
}
updateAssetBoundaries() {
const manager = this.timelineMonth.timelineManager;
const visibleWindow = manager.visibleWindow;
if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) {
this.activeViewerAssets = [];
this.isInOrNearViewport = false;
return;
}
const dayOffset = this.absoluteTimelineDayTop;
const headerHeight = manager.headerHeight;
const expandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset;
const expandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset;
const first = lowerBound(this.viewerAssets, expandedTop, (p) => p.top + p.height);
const last = lowerBound(this.viewerAssets, expandedBottom, (p) => p.top) - 1;
const hasActive = last >= first && first < this.viewerAssets.length;
this.activeViewerAssets = hasActive ? this.viewerAssets.slice(first, last + 1) : [];
this.isInOrNearViewport = hasActive;
} }
get absoluteTimelineDayTop() { get absoluteTimelineDayTop() {
return this.timelineMonth.top + this.#top; return this.timelineMonth.top + this.#top;
} }
get isInOrNearViewport() {
if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) {
return true;
}
this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport);
return this.#lastInOrNearViewport !== -1;
}
} }
@@ -214,6 +214,11 @@ export class TimelineManager extends VirtualScrollManager {
for (const month of this.months) { for (const month of this.months) {
updateTimelineMonthViewportProximity(this, month); updateTimelineMonthViewportProximity(this, month);
if (month.isInOrNearViewport && month.isLoaded) {
for (const day of month.timelineDays) {
day.updateAssetBoundaries();
}
}
} }
const month = this.months.find((month) => month.isInViewport); const month = this.months.find((month) => month.isInViewport);
@@ -254,7 +254,7 @@ export class TimelineMonth {
addContext.newTimelineDays.add(timelineDay); addContext.newTimelineDays.add(timelineDay);
} }
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset); const viewerAsset = new ViewerAsset(timelineAsset);
timelineDay.viewerAssets.push(viewerAsset); timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay); addContext.changedTimelineDays.add(timelineDay);
} }
@@ -1,36 +1,12 @@
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import {
ViewportProximity,
calculateViewerAssetViewportProximity,
isInOrNearViewport,
} from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
export class ViewerAsset { export class ViewerAsset {
readonly #group: TimelineDay;
#viewportProximity = $derived.by(() => {
if (!this.position) {
return ViewportProximity.FarFromViewport;
}
const store = this.#group.timelineMonth.timelineManager;
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
});
get isInOrNearViewport() {
return isInOrNearViewport(this.#viewportProximity);
}
position: CommonPosition | undefined = $state.raw(); position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset; asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id); id: string = $derived(this.asset.id);
constructor(group: TimelineDay, asset: TimelineAsset) { constructor(asset: TimelineAsset) {
this.#group = group;
this.asset = asset; this.asset = asset;
} }
} }
@@ -45,9 +45,9 @@
import { cloneDeep, isEqual } from 'lodash-es'; import { cloneDeep, isEqual } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte'; import WorkflowStepCard from '$lib/components/workflows/WorkflowStepCard.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte'; import WorkflowJsonEditor from '$lib/components/workflows/WorkflowJsonEditor.svelte';
import WorkflowSummary from './WorkflowSummary.svelte'; import WorkflowSummary from '$lib/components/workflows/WorkflowSummary.svelte';
type WorkflowJsonContent = Required< type WorkflowJsonContent = Required<
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'> Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>