mirror of
https://github.com/immich-app/immich.git
synced 2026-05-28 10:32:31 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe5785cf64 |
@@ -8,8 +8,6 @@ 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,39 +1,39 @@
|
|||||||
dev:
|
dev:
|
||||||
@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
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||||
|
|
||||||
dev-down:
|
dev-down:
|
||||||
@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
|
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
||||||
|
|
||||||
dev-update:
|
dev-update:
|
||||||
@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
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
dev-scale:
|
dev-scale:
|
||||||
@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
|
@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
|
||||||
|
|
||||||
dev-docs:
|
dev-docs:
|
||||||
npm --prefix docs run start
|
npm --prefix docs run start
|
||||||
|
|
||||||
.PHONY: e2e
|
.PHONY: e2e
|
||||||
e2e:
|
e2e:
|
||||||
@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
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||||
|
|
||||||
e2e-dev:
|
e2e-dev:
|
||||||
@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
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
|
||||||
|
|
||||||
e2e-update:
|
e2e-update:
|
||||||
@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
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
e2e-down:
|
e2e-down:
|
||||||
@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
|
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
|
||||||
|
|
||||||
prod:
|
prod:
|
||||||
@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
|
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
prod-down:
|
prod-down:
|
||||||
@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
|
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
|
||||||
|
|
||||||
prod-scale:
|
prod-scale:
|
||||||
@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
|
@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
|
||||||
|
|
||||||
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ config_roots = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
node = "24.15.0"
|
node = "24.16.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,72 +84,6 @@ 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"
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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;
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:immich_mobile/constants/colors.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/config/album_config.dart';
|
import 'package:immich_mobile/domain/models/config/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;
|
||||||
@@ -29,10 +18,8 @@ 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(),
|
||||||
@@ -42,11 +29,9 @@ 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,
|
||||||
@@ -56,9 +41,7 @@ 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,
|
||||||
@@ -68,14 +51,12 @@ 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 &&
|
||||||
@@ -84,118 +65,12 @@ 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 =>
|
int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow, album, backup);
|
||||||
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network)';
|
'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup)';
|
||||||
|
|
||||||
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 {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/config/network_config.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
|
|
||||||
|
class SystemConfig {
|
||||||
|
final LogLevel logLevel;
|
||||||
|
final NetworkConfig network;
|
||||||
|
|
||||||
|
const SystemConfig({this.logLevel = .info, this.network = const .new()});
|
||||||
|
|
||||||
|
SystemConfig copyWith({LogLevel? logLevel, NetworkConfig? network}) =>
|
||||||
|
SystemConfig(logLevel: logLevel ?? this.logLevel, network: network ?? this.network);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) || (other is SystemConfig && other.logLevel == logLevel && other.network == network);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(logLevel, network);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SystemConfig(logLevel: $logLevel, network: $network)';
|
||||||
|
}
|
||||||
@@ -1,105 +1,142 @@
|
|||||||
import 'dart:convert';
|
import '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 MetadataScope {
|
enum MetadataDomain<T extends Object> {
|
||||||
user, // keys with this scope are deleted on logout
|
appConfig<AppConfig>('config.app'),
|
||||||
system;
|
systemConfig<SystemConfig>('config.system');
|
||||||
|
|
||||||
const MetadataScope();
|
final String prefix;
|
||||||
|
const MetadataDomain(this.prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MetadataKey<T extends Object> {
|
enum MetadataKey<T extends Object> {
|
||||||
// Theme
|
// Theme
|
||||||
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
|
themePrimaryColor<ImmichColorPreset>(.appConfig, 'theme.primaryColor', .indigo, _EnumCodec(ImmichColorPreset.values)),
|
||||||
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
themeMode<ThemeMode>(.appConfig, 'theme.mode', .system, _EnumCodec(ThemeMode.values)),
|
||||||
themeDynamic<bool>(),
|
themeDynamic<bool>(.appConfig, 'theme.dynamic', false),
|
||||||
themeColorfulInterface<bool>(),
|
themeColorfulInterface<bool>(.appConfig, 'theme.colorfulInterface', true),
|
||||||
|
|
||||||
// Image
|
// Image
|
||||||
imagePreferRemote<bool>(),
|
imagePreferRemote<bool>(.appConfig, 'image.preferRemote', false),
|
||||||
imageLoadOriginal<bool>(),
|
imageLoadOriginal<bool>(.appConfig, 'image.loadOriginal', false),
|
||||||
|
|
||||||
// Viewer
|
// Viewer
|
||||||
viewerLoopVideo<bool>(),
|
viewerLoopVideo<bool>(.appConfig, 'viewer.loopVideo', true),
|
||||||
viewerLoadOriginalVideo<bool>(),
|
viewerLoadOriginalVideo<bool>(.appConfig, 'viewer.loadOriginalVideo', false),
|
||||||
viewerAutoPlayVideo<bool>(),
|
viewerAutoPlayVideo<bool>(.appConfig, 'viewer.autoPlayVideo', true),
|
||||||
viewerTapToNavigate<bool>(),
|
viewerTapToNavigate<bool>(.appConfig, 'viewer.tapToNavigate', false),
|
||||||
|
|
||||||
// Network
|
// Network
|
||||||
networkAutoEndpointSwitching<bool>(scope: .system),
|
networkAutoEndpointSwitching<bool>(.systemConfig, 'network.autoEndpointSwitching', false),
|
||||||
networkPreferredWifiName<String>(scope: .system),
|
networkPreferredWifiName<String>(.systemConfig, 'network.preferredWifiName', ''),
|
||||||
networkLocalEndpoint<String>(scope: .system),
|
networkLocalEndpoint<String>(.systemConfig, 'network.localEndpoint', ''),
|
||||||
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
|
networkExternalEndpointList<List<String>>(
|
||||||
|
.systemConfig,
|
||||||
|
'network.externalEndpointList',
|
||||||
|
[],
|
||||||
|
_ListCodec(_PrimitiveCodec.string),
|
||||||
|
),
|
||||||
networkCustomHeaders<Map<String, String>>(
|
networkCustomHeaders<Map<String, String>>(
|
||||||
scope: .system,
|
.systemConfig,
|
||||||
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
'network.customHeaders',
|
||||||
|
{},
|
||||||
|
_MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
|
albumSortMode<AlbumSortMode>(
|
||||||
albumIsReverse<bool>(),
|
.appConfig,
|
||||||
albumIsGrid<bool>(),
|
'album.sortMode',
|
||||||
|
AlbumSortMode.mostRecent,
|
||||||
|
_EnumCodec(AlbumSortMode.values),
|
||||||
|
),
|
||||||
|
albumIsReverse<bool>(.appConfig, 'album.isReverse', true),
|
||||||
|
albumIsGrid<bool>(.appConfig, 'album.isGrid', false),
|
||||||
|
|
||||||
// Backup
|
// Backup
|
||||||
backupEnabled<bool>(),
|
backupEnabled<bool>(.appConfig, 'backup.enabled', false),
|
||||||
backupUseCellularForVideos<bool>(),
|
backupUseCellularForVideos<bool>(.appConfig, 'backup.useCellularForVideos', false),
|
||||||
backupUseCellularForPhotos<bool>(),
|
backupUseCellularForPhotos<bool>(.appConfig, 'backup.useCellularForPhotos', false),
|
||||||
backupRequireCharging<bool>(),
|
backupRequireCharging<bool>(.appConfig, 'backup.requireCharging', false),
|
||||||
backupTriggerDelay<int>(),
|
backupTriggerDelay<int>(.appConfig, 'backup.triggerDelay', 30),
|
||||||
backupSyncAlbums<bool>(),
|
backupSyncAlbums<bool>(.appConfig, 'backup.syncAlbums', false),
|
||||||
|
|
||||||
// Timeline
|
// Timeline
|
||||||
timelineTilesPerRow<int>(),
|
timelineTilesPerRow<int>(.appConfig, 'timeline.tilesPerRow', 4),
|
||||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
|
timelineGroupAssetsBy<GroupAssetsBy>(
|
||||||
timelineStorageIndicator<bool>(),
|
.appConfig,
|
||||||
|
'timeline.groupAssetsBy',
|
||||||
|
GroupAssetsBy.day,
|
||||||
|
_EnumCodec(GroupAssetsBy.values),
|
||||||
|
),
|
||||||
|
timelineStorageIndicator<bool>(.appConfig, 'timeline.storageIndicator', true),
|
||||||
|
|
||||||
// Log
|
// Log
|
||||||
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
|
logLevel<LogLevel>(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)),
|
||||||
|
|
||||||
// Map
|
// Map
|
||||||
mapShowFavoriteOnly<bool>(),
|
mapShowFavoriteOnly<bool>(.appConfig, 'map.showFavoriteOnly', false),
|
||||||
mapRelativeDate<int>(),
|
mapRelativeDate<int>(.appConfig, 'map.relativeDate', 0),
|
||||||
mapIncludeArchived<bool>(),
|
mapIncludeArchived<bool>(.appConfig, 'map.includeArchived', false),
|
||||||
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
mapThemeMode<ThemeMode>(.appConfig, 'map.themeMode', .system, _EnumCodec(ThemeMode.values)),
|
||||||
mapWithPartners<bool>(),
|
mapWithPartners<bool>(.appConfig, 'map.withPartners', false),
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
cleanupKeepFavorites<bool>(),
|
cleanupKeepFavorites<bool>(.appConfig, 'cleanup.keepFavorites', true),
|
||||||
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
|
cleanupKeepMediaType<AssetKeepType>(
|
||||||
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
.appConfig,
|
||||||
cleanupCutoffDaysAgo<int>(),
|
'cleanup.keepMediaType',
|
||||||
cleanupDefaultsInitialized<bool>(),
|
AssetKeepType.none,
|
||||||
|
_EnumCodec(AssetKeepType.values),
|
||||||
|
),
|
||||||
|
cleanupKeepAlbumIds<List<String>>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)),
|
||||||
|
cleanupCutoffDaysAgo<int>(.appConfig, 'cleanup.cutoffDaysAgo', -1),
|
||||||
|
cleanupDefaultsInitialized<bool>(.appConfig, 'cleanup.defaultsInitialized', false),
|
||||||
|
|
||||||
// Slideshow
|
// Slideshow
|
||||||
slideshowTransition<bool>(),
|
slideshowTransition<bool>(.appConfig, 'slideshow.transition', true),
|
||||||
slideshowRepeat<bool>(),
|
slideshowRepeat<bool>(.appConfig, 'slideshow.repeat', true),
|
||||||
slideshowDuration<int>(),
|
slideshowDuration<int>(.appConfig, 'slideshow.duration', 5),
|
||||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
slideshowLook<SlideshowLook>(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)),
|
||||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
slideshowDirection<SlideshowDirection>(
|
||||||
|
.appConfig,
|
||||||
|
'slideshow.direction',
|
||||||
|
SlideshowDirection.forward,
|
||||||
|
_EnumCodec(SlideshowDirection.values),
|
||||||
|
);
|
||||||
|
|
||||||
final MetadataScope scope;
|
final MetadataDomain domain;
|
||||||
|
final String name;
|
||||||
|
final T defaultValue;
|
||||||
final _MetadataCodec<T>? _codecOverride;
|
final _MetadataCodec<T>? _codecOverride;
|
||||||
|
|
||||||
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
|
const MetadataKey(this.domain, this.name, this.defaultValue, [this._codecOverride]);
|
||||||
|
|
||||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
|
String get key => '${domain.prefix}.$name';
|
||||||
|
|
||||||
|
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forPrimitive(defaultValue);
|
||||||
|
|
||||||
String encode(T value) => _codec.encode(value);
|
String encode(T value) => _codec.encode(value);
|
||||||
|
|
||||||
T decode(String raw) => _codec.decode(raw);
|
T decode(String raw) => _codec.decode(raw) ?? defaultValue;
|
||||||
|
|
||||||
|
static Map<String, MetadataKey<Object>> asKeyMap() => {for (var value in MetadataKey.values) value.key: value};
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class _MetadataCodec<T extends Object> {
|
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,
|
||||||
@@ -109,10 +146,12 @@ sealed class _MetadataCodec<T extends Object> {
|
|||||||
DateTime: _DateTimeCodec(),
|
DateTime: _DateTimeCodec(),
|
||||||
};
|
};
|
||||||
|
|
||||||
static _MetadataCodec<T> forType<T extends Object>(Type runtimeType) {
|
static _MetadataCodec<T> forPrimitive<T extends Object>(T sample) {
|
||||||
final codec = _primitives[runtimeType];
|
final codec = _primitives[sample.runtimeType];
|
||||||
if (codec == null) {
|
if (codec == null) {
|
||||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the MetadataKey.');
|
throw StateError(
|
||||||
|
'No primitive codec for ${sample.runtimeType}. Provide an explicit codec when defining the MetadataKey.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return codec as _MetadataCodec<T>;
|
return codec as _MetadataCodec<T>;
|
||||||
}
|
}
|
||||||
@@ -127,7 +166,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.firstWhere((v) => v.name == raw);
|
T? decode(String raw) => values.firstWhereOrNull((v) => v.name == raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
final class _DateTimeCodec extends _MetadataCodec<DateTime> {
|
||||||
@@ -137,7 +176,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.parse(raw);
|
DateTime? decode(String raw) => DateTime.tryParse(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>> {
|
||||||
@@ -154,26 +193,29 @@ 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 {};
|
return null;
|
||||||
}
|
}
|
||||||
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 {};
|
return null;
|
||||||
}
|
}
|
||||||
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 {};
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,29 +229,32 @@ final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
|
|||||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
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 [];
|
return null;
|
||||||
}
|
}
|
||||||
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 [];
|
return null;
|
||||||
}
|
}
|
||||||
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 [];
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -217,12 +262,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.parse);
|
static const integer = _PrimitiveCodec<int>._(int.tryParse);
|
||||||
static const real = _PrimitiveCodec<double>._(double.parse);
|
static const real = _PrimitiveCodec<double>._(double.tryParse);
|
||||||
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
|
static const boolean = _PrimitiveCodec<bool>._(bool.tryParse);
|
||||||
static const string = _PrimitiveCodec<String>._(_identity);
|
static const string = _PrimitiveCodec<String>._(_identity);
|
||||||
|
|
||||||
static String _identity(String s) => s;
|
static String? _identity(String s) => s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class LogService {
|
|||||||
}) async {
|
}) 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.appConfig.logLevel;
|
final level = instance._metadataRepository.systemConfig.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,12 +1,14 @@
|
|||||||
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);
|
||||||
|
|
||||||
@@ -23,50 +25,153 @@ 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.refresh();
|
await instance._hydrate();
|
||||||
_instance = instance;
|
_instance = instance;
|
||||||
}
|
}
|
||||||
return _instance!;
|
return _instance!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
|
static Future<void> refresh() async {
|
||||||
|
instance._cache.clear();
|
||||||
|
instance._appConfig = const .new();
|
||||||
|
instance._systemConfig = const .new();
|
||||||
|
await instance._hydrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hydrate() async => _hydrateCache(await _db.select(_db.metadataEntity).get());
|
||||||
|
|
||||||
|
T _read<T extends Object>(MetadataKey<T> key) => (_cache[key] as T?) ?? key.defaultValue;
|
||||||
|
|
||||||
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
|
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
|
||||||
if (value == _appConfig.read(key)) {
|
if (_read(key) == value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value == defaultConfig.read(key)) {
|
await _db
|
||||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
|
.into(_db.metadataEntity)
|
||||||
} else {
|
.insertOnConflictUpdate(
|
||||||
await _db
|
MetadataEntityCompanion.insert(key: key.key, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||||
.into(_db.metadataEntity)
|
);
|
||||||
.insertOnConflictUpdate(
|
_updateCache(key, value);
|
||||||
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_appConfig = _appConfig.write(key, value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<AppConfig> watchConfig() => _db.select(_db.metadataEntity).watch().map((rows) {
|
Future<void> delete<T extends Object>(MetadataKey<T> key) async {
|
||||||
_applyOverrides(rows);
|
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.key))).go();
|
||||||
return _appConfig;
|
_updateCache(key, key.defaultValue);
|
||||||
});
|
}
|
||||||
|
|
||||||
void _applyOverrides(List<MetadataEntityData> rows) {
|
Stream<AppConfig> watchAppConfig() => _watchDomain(.appConfig).distinct();
|
||||||
_appConfig = AppConfig.fromEntries(
|
|
||||||
rows.fold({}, (overrides, row) {
|
|
||||||
final metadataKey = MetadataKey.values.firstWhereOrNull((key) => key.name == row.key);
|
|
||||||
if (metadataKey == null) {
|
|
||||||
return overrides;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {...overrides, metadataKey: metadataKey.decode(row.value)};
|
Stream<SystemConfig> watchSystemConfig() => _watchDomain(.systemConfig).distinct();
|
||||||
}),
|
|
||||||
);
|
Stream<T> _watchDomain<T extends Object>(MetadataDomain<T> domain) {
|
||||||
|
final query = _db.select(_db.metadataEntity)..where((t) => t.key.like('${domain.prefix}.%'));
|
||||||
|
return query.watch().map((rows) {
|
||||||
|
_hydrateCache(rows);
|
||||||
|
return domain.config(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hydrateCache(List<MetadataEntityData> rows) {
|
||||||
|
final keyMap = MetadataKey.asKeyMap();
|
||||||
|
for (final row in rows) {
|
||||||
|
final key = keyMap[row.key];
|
||||||
|
if (key == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_updateCache(key, key.decode(row.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateCache<T extends Object>(MetadataKey<T> key, T value) {
|
||||||
|
if (_cache[key] == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_cache[key] = value;
|
||||||
|
key.domain.rebuild(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension<T extends Object> on MetadataDomain<T> {
|
||||||
|
T config(MetadataRepository repo) => switch (this) {
|
||||||
|
.appConfig => repo._appConfig as T,
|
||||||
|
.systemConfig => repo._systemConfig as T,
|
||||||
|
};
|
||||||
|
|
||||||
|
void rebuild(MetadataRepository repo) {
|
||||||
|
switch (this) {
|
||||||
|
case .appConfig:
|
||||||
|
repo._appConfig = .new(
|
||||||
|
theme: .new(
|
||||||
|
mode: repo._read(.themeMode),
|
||||||
|
primaryColor: repo._read(.themePrimaryColor),
|
||||||
|
dynamicTheme: repo._read(.themeDynamic),
|
||||||
|
colorfulInterface: repo._read(.themeColorfulInterface),
|
||||||
|
),
|
||||||
|
cleanup: .new(
|
||||||
|
keepFavorites: repo._read(.cleanupKeepFavorites),
|
||||||
|
keepMediaType: repo._read(.cleanupKeepMediaType),
|
||||||
|
keepAlbumIds: repo._read(.cleanupKeepAlbumIds),
|
||||||
|
cutoffDaysAgo: repo._read(.cleanupCutoffDaysAgo),
|
||||||
|
defaultsInitialized: repo._read(.cleanupDefaultsInitialized),
|
||||||
|
),
|
||||||
|
map: .new(
|
||||||
|
relativeDays: repo._read(.mapRelativeDate),
|
||||||
|
favoritesOnly: repo._read(.mapShowFavoriteOnly),
|
||||||
|
includeArchived: repo._read(.mapIncludeArchived),
|
||||||
|
themeMode: repo._read(.mapThemeMode),
|
||||||
|
withPartners: repo._read(.mapWithPartners),
|
||||||
|
),
|
||||||
|
timeline: .new(
|
||||||
|
tilesPerRow: repo._read(.timelineTilesPerRow),
|
||||||
|
groupAssetsBy: repo._read(.timelineGroupAssetsBy),
|
||||||
|
storageIndicator: repo._read(.timelineStorageIndicator),
|
||||||
|
),
|
||||||
|
image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)),
|
||||||
|
viewer: .new(
|
||||||
|
loopVideo: repo._read(.viewerLoopVideo),
|
||||||
|
loadOriginalVideo: repo._read(.viewerLoadOriginalVideo),
|
||||||
|
autoPlayVideo: repo._read(.viewerAutoPlayVideo),
|
||||||
|
tapToNavigate: repo._read(.viewerTapToNavigate),
|
||||||
|
),
|
||||||
|
slideshow: .new(
|
||||||
|
transition: repo._read(.slideshowTransition),
|
||||||
|
repeat: repo._read(.slideshowRepeat),
|
||||||
|
duration: repo._read(.slideshowDuration),
|
||||||
|
look: repo._read(.slideshowLook),
|
||||||
|
direction: repo._read(.slideshowDirection),
|
||||||
|
),
|
||||||
|
album: .new(
|
||||||
|
sortMode: repo._read(.albumSortMode),
|
||||||
|
isReverse: repo._read(.albumIsReverse),
|
||||||
|
isGrid: repo._read(.albumIsGrid),
|
||||||
|
),
|
||||||
|
backup: .new(
|
||||||
|
enabled: repo._read(.backupEnabled),
|
||||||
|
useCellularForVideos: repo._read(.backupUseCellularForVideos),
|
||||||
|
useCellularForPhotos: repo._read(.backupUseCellularForPhotos),
|
||||||
|
requireCharging: repo._read(.backupRequireCharging),
|
||||||
|
triggerDelay: repo._read(.backupTriggerDelay),
|
||||||
|
syncAlbums: repo._read(.backupSyncAlbums),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case .systemConfig:
|
||||||
|
repo._systemConfig = .new(
|
||||||
|
logLevel: repo._read(.logLevel),
|
||||||
|
network: .new(
|
||||||
|
autoEndpointSwitching: repo._read(.networkAutoEndpointSwitching),
|
||||||
|
preferredWifiName: repo._read(.networkPreferredWifiName).nullIfEmpty,
|
||||||
|
localEndpoint: repo._read(.networkLocalEndpoint).nullIfEmpty,
|
||||||
|
externalEndpointList: repo._read(.networkExternalEndpointList),
|
||||||
|
customHeaders: repo._read(.networkCustomHeaders),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
|
|||||||
_searchController = TextEditingController();
|
_searchController = TextEditingController();
|
||||||
_searchFocusNode = FocusNode();
|
_searchFocusNode = FocusNode();
|
||||||
|
|
||||||
_enableSyncUploadAlbum.value = ref.read(appConfigProvider).backup.syncAlbums;
|
_enableSyncUploadAlbum.value = ref.read(metadataProvider).appConfig.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(appConfigProvider).backup.syncAlbums;
|
final enableSyncUploadAlbum = ref.read(metadataProvider).appConfig.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(appConfigProvider).backup;
|
final previousBackup = ref.read(metadataProvider).appConfig.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).appConfig.network.customHeaders;
|
final storedHeaders = ref.read(metadataProvider).systemConfig.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/config/app_config.dart';
|
import 'package:immich_mobile/domain/models/metadata_key.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/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 = defaultConfig.theme.primaryColor.themeOfPreset;
|
final immichTheme = MetadataKey.themePrimaryColor.defaultValue.themeOfPreset;
|
||||||
|
|
||||||
return EasyLocalization(
|
return EasyLocalization(
|
||||||
supportedLocales: locales.values.toList(),
|
supportedLocales: locales.values.toList(),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
|||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/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
|
||||||
@@ -40,8 +39,6 @@ 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,9 +14,7 @@ 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 {
|
||||||
final ValueNotifier<double?> progress;
|
const _SharePreparingDialog();
|
||||||
|
|
||||||
const _SharePreparingDialog({required this.progress});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -24,24 +22,8 @@ class _SharePreparingDialog extends StatelessWidget {
|
|||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()),
|
const CircularProgressIndicator(),
|
||||||
SizedBox(
|
Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()),
|
||||||
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)}%')),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -61,39 +43,32 @@ class ShareActionButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final cancelCompleter = Completer<void>();
|
final cancelCompleter = Completer<void>();
|
||||||
final progress = ValueNotifier<double?>(null);
|
const preparingDialog = _SharePreparingDialog();
|
||||||
final preparingDialog = _SharePreparingDialog(progress: progress);
|
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext buildContext) {
|
builder: (BuildContext buildContext) {
|
||||||
ref
|
ref.read(actionProvider.notifier).shareAssets(source, context, cancelCompleter: cancelCompleter).then((
|
||||||
.read(actionProvider.notifier)
|
ActionResult result,
|
||||||
.shareAssets(
|
) {
|
||||||
source,
|
if (cancelCompleter.isCompleted || !context.mounted) {
|
||||||
context,
|
return;
|
||||||
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 download progress with a "Preparing" message
|
// show a loading spinner with a "Preparing" message
|
||||||
return preparingDialog;
|
return preparingDialog;
|
||||||
},
|
},
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -102,7 +77,6 @@ class ShareActionButton extends ConsumerWidget {
|
|||||||
if (!cancelCompleter.isCompleted) {
|
if (!cancelCompleter.isCompleted) {
|
||||||
cancelCompleter.complete();
|
cancelCompleter.complete();
|
||||||
}
|
}
|
||||||
progress.dispose();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).appConfig.network.customHeaders;
|
final headerMap = _ref.read(metadataProvider).systemConfig.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).appConfig.network.preferredWifiName;
|
return _ref.read(metadataProvider).systemConfig.network.preferredWifiName;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? getSavedLocalEndpoint() {
|
String? getSavedLocalEndpoint() {
|
||||||
return _ref.read(metadataProvider).appConfig.network.localEndpoint;
|
return _ref.read(metadataProvider).systemConfig.network.localEndpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current server endpoint (with /api) URL from the store
|
/// Returns the current server endpoint (with /api) URL from the store
|
||||||
|
|||||||
@@ -465,17 +465,11 @@ 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(
|
await _service.shareAssets(ids, context, cancelCompleter: cancelCompleter);
|
||||||
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,12 +1,20 @@
|
|||||||
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.watchConfig().listen((event) => ref.state = event);
|
final subscription = repo.watchAppConfig().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,29 +1,31 @@
|
|||||||
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/services/api.service.dart';
|
import 'package:immich_mobile/repositories/asset_api.repository.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((ref) => AssetMediaRepository(ref.watch(nativeSyncApiProvider)));
|
final assetMediaRepositoryProvider = Provider(
|
||||||
|
(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._nativeSyncApi);
|
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
||||||
|
|
||||||
Future<bool> _androidSupportsTrash() async {
|
Future<bool> _androidSupportsTrash() async {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
@@ -105,29 +107,10 @@ class AssetMediaRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> shareAssets(
|
// TODO: make this more efficient
|
||||||
List<BaseAsset> assets,
|
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) async {
|
||||||
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) {
|
||||||
@@ -144,8 +127,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -153,50 +134,22 @@ 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 taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
|
final tempDir = await getTemporaryDirectory();
|
||||||
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
|
final name = asset.name;
|
||||||
final task = DownloadTask(
|
final tempFile = await File('${tempDir.path}/$name').create();
|
||||||
taskId: taskId,
|
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true);
|
||||||
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 (cancelCompleter != null && cancelCompleter.isCompleted) {
|
if (res.statusCode != 200) {
|
||||||
await _cleanupTempFiles(tempFiles);
|
_log.severe("Download for $name failed", res.toLoggerString());
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusUpdate.status == TaskStatus.complete) {
|
|
||||||
final filePath = await task.filePath();
|
|
||||||
final file = File(filePath);
|
|
||||||
tempFiles.add(file);
|
|
||||||
downloadedXFiles.add(XFile(filePath));
|
|
||||||
processedAssets++;
|
|
||||||
updateProgress();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_log.severe("Download for ${asset.name} failed with status ${statusUpdate.status}", statusUpdate.exception);
|
|
||||||
processedAssets++;
|
await tempFile.writeAsBytes(res.bodyBytes);
|
||||||
updateProgress();
|
downloadedXFiles.add(XFile(tempFile.path));
|
||||||
|
tempFiles.add(tempFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
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(appConfigProvider)),
|
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(metadataProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
class AuthRepository {
|
class AuthRepository {
|
||||||
final Drift _drift;
|
final Drift _drift;
|
||||||
final AppConfig _config;
|
final MetadataRepository _metadata;
|
||||||
|
|
||||||
const AuthRepository(this._drift, this._config);
|
const AuthRepository(this._drift, this._metadata);
|
||||||
|
|
||||||
Future<void> clearLocalData() async {
|
Future<void> clearLocalData() async {
|
||||||
await SyncStreamRepository(_drift).reset();
|
await SyncStreamRepository(_drift).reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool getEndpointSwitchingFeature() {
|
bool getEndpointSwitchingFeature() {
|
||||||
return _config.network.autoEndpointSwitching;
|
return _metadata.systemConfig.network.autoEndpointSwitching;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? getPreferredWifiName() {
|
String? getPreferredWifiName() {
|
||||||
return _config.network.preferredWifiName;
|
return _metadata.systemConfig.network.preferredWifiName;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? getLocalEndpoint() {
|
String? getLocalEndpoint() {
|
||||||
return _config.network.localEndpoint;
|
return _metadata.systemConfig.network.localEndpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<AuxilaryEndpoint> getExternalEndpointList() {
|
List<AuxilaryEndpoint> getExternalEndpointList() {
|
||||||
return _config.network.externalEndpointList.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
|
return _metadata.systemConfig.network.externalEndpointList
|
||||||
|
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,18 +269,8 @@ class ActionService {
|
|||||||
await _assetApiRepository.unStack(stackIds);
|
await _assetApiRepository.unStack(stackIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> shareAssets(
|
Future<int> shareAssets(List<BaseAsset> assets, BuildContext context, {Completer<void>? cancelCompleter}) {
|
||||||
List<BaseAsset> assets,
|
return _assetMediaRepository.shareAssets(assets, context, cancelCompleter: cancelCompleter);
|
||||||
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) {
|
||||||
|
|||||||
@@ -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.appConfig.network;
|
final network = MetadataRepository.instance.systemConfig.network;
|
||||||
final localEndpoint = network.localEndpoint;
|
final localEndpoint = network.localEndpoint;
|
||||||
if (localEndpoint.isNotEmpty) {
|
if (localEndpoint != null) {
|
||||||
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.appConfig.network.customHeaders;
|
return MetadataRepository.instance.systemConfig.network.customHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiClient get apiClient => _apiClient;
|
ApiClient get apiClient => _apiClient;
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
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';
|
||||||
@@ -138,11 +136,15 @@ 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);
|
||||||
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
|
if (raw == null) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,11 +208,7 @@ class _StoreMigrator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final enumValue = values.elementAtOrNull(index);
|
final enumValue = values.elementAtOrNull(index) ?? newKey.defaultValue;
|
||||||
if (enumValue == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cache[newKey] = enumValue;
|
_cache[newKey] = enumValue;
|
||||||
_migratedStoreIds.add(legacyKey.id);
|
_migratedStoreIds.add(legacyKey.id);
|
||||||
}
|
}
|
||||||
@@ -225,11 +223,7 @@ class _StoreMigrator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final enumValue = values.firstWhereOrNull((e) => e.name == name);
|
final enumValue = values.firstWhere((e) => e.name == name, orElse: () => newKey.defaultValue);
|
||||||
if (enumValue == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cache[newKey] = enumValue;
|
_cache[newKey] = enumValue;
|
||||||
_migratedStoreIds.add(legacyKey.id);
|
_migratedStoreIds.add(legacyKey.id);
|
||||||
}
|
}
|
||||||
@@ -273,12 +267,9 @@ 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.name), value: Value(entry.key.encode(entry.value))),
|
MetadataEntityCompanion(key: Value(entry.key.key), 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(appConfigProvider).logLevel.index);
|
final levelId = useState<int>(ref.read(systemConfigProvider).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(appConfigProvider).network.externalEndpointList;
|
final urls = ref.read(metadataProvider).systemConfig.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(appConfigProvider).network.autoEndpointSwitching);
|
final featureEnabled = useState(ref.read(systemConfigProvider).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,16 +10,9 @@ 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;
|
||||||
@@ -34,9 +27,7 @@ class ImmichFormController extends ChangeNotifier {
|
|||||||
await onSubmit?.call();
|
await onSubmit?.call();
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
if (!_isDisposed) {
|
notifyListeners();
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +38,13 @@ class ImmichForm extends StatefulWidget {
|
|||||||
final String? submitText;
|
final String? submitText;
|
||||||
final IconData? submitIcon;
|
final IconData? submitIcon;
|
||||||
|
|
||||||
const ImmichForm({super.key, this.onSubmit, this.submitText, this.submitIcon, required this.builder});
|
const ImmichForm({
|
||||||
|
super.key,
|
||||||
|
this.onSubmit,
|
||||||
|
this.submitText,
|
||||||
|
this.submitIcon,
|
||||||
|
required this.builder,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@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/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/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.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.fine));
|
when(() => mockMetadataRepository.systemConfig).thenReturn(const SystemConfig(logLevel: LogLevel.fine));
|
||||||
when(() => mockMetadataRepository.write<LogLevel, LogLevel>(MetadataKey.logLevel, any())).thenAnswer((_) async {});
|
when(() => 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.appConfig).called(1);
|
verify(() => mockMetadataRepository.systemConfig).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.instance.refresh();
|
await MetadataRepository.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('appConfig returns key defaults when DB is empty', () {
|
test('systemConfig returns key defaults when DB is empty', () {
|
||||||
expect(sut.appConfig.logLevel, LogLevel.info);
|
expect(sut.systemConfig.logLevel, LogLevel.info);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,14 +46,16 @@ 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.appConfig.logLevel, LogLevel.severe);
|
expect(sut.systemConfig.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.write(.themeMode, ThemeMode.system);
|
await sut.delete(.themeMode);
|
||||||
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();
|
||||||
@@ -61,15 +63,13 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('delete', () {});
|
group('refresh', () {
|
||||||
|
|
||||||
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.name,
|
key: MetadataKey.themeMode.key,
|
||||||
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.instance.refresh();
|
await MetadataRepository.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.instance.refresh();
|
await MetadataRepository.refresh();
|
||||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,20 +103,32 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await MetadataRepository.instance.refresh();
|
await MetadataRepository.refresh();
|
||||||
expect(sut.appConfig.theme.mode, ThemeMode.system);
|
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.watchConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
final expectation = expectLater(sut.watchAppConfig().map((c) => c.theme.mode), emitsThrough(ThemeMode.dark));
|
||||||
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
|
await sut.write(MetadataKey.themeMode, ThemeMode.dark);
|
||||||
await expectation;
|
await expectation;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('watchConfig emits the new value after a write', () async {
|
test('watchAppConfig does not emit when only system-config rows change', () async {
|
||||||
final expectation = expectLater(sut.watchConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
|
final emissions = <ThemeMode>[];
|
||||||
|
// skip(1) drops the on-subscribe replay so we only capture emissions caused by the write below.
|
||||||
|
final sub = sut.watchAppConfig().skip(1).listen((c) => emissions.add(c.theme.mode));
|
||||||
|
|
||||||
|
await sut.write(MetadataKey.logLevel, LogLevel.severe);
|
||||||
|
await pumpEventQueue();
|
||||||
|
await sub.cancel();
|
||||||
|
|
||||||
|
expect(emissions, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchSystemConfig emits the new value after a write', () async {
|
||||||
|
final expectation = expectLater(sut.watchSystemConfig().map((c) => c.logLevel), emitsThrough(LogLevel.warning));
|
||||||
await sut.write(MetadataKey.logLevel, LogLevel.warning);
|
await sut.write(MetadataKey.logLevel, LogLevel.warning);
|
||||||
await expectation;
|
await expectation;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
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', () {
|
||||||
for (final key in MetadataKey.values) {
|
test('every key round-trips its default value losslessly', () {
|
||||||
test('verify codec for $key', () {
|
for (final key in MetadataKey.values) {
|
||||||
final defaultValue = defaultConfig.read(key);
|
final encoded = key.encode(key.defaultValue);
|
||||||
final encoded = key.encode(defaultValue);
|
|
||||||
final decoded = key.decode(encoded);
|
final decoded = key.decode(encoded);
|
||||||
expect(decoded, defaultValue, reason: 'round-trip failed for ${key.name}');
|
expect(decoded, key.defaultValue, reason: 'round-trip failed for ${key.name}');
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
test('decode falls back to the default value when the raw input is unparseable', () {
|
||||||
|
for (final key in MetadataKey.values) {
|
||||||
|
// String keys can decode any string. So skip them
|
||||||
|
if (key.defaultValue is String) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
key.decode('not a valid encoding for any key'),
|
||||||
|
key.defaultValue,
|
||||||
|
reason: 'fallback failed for ${key.name}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, dto.visibility);
|
const userIds = await this.getUserIdsToSearch(auth);
|
||||||
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, dto.visibility);
|
const userIds = await this.getUserIdsToSearch(auth);
|
||||||
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, dto.visibility);
|
const userIds = await this.getUserIdsToSearch(auth);
|
||||||
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, dto.visibility);
|
const userIds = this.getUserIdsToSearch(auth);
|
||||||
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,11 +202,7 @@ export class SearchService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
|
private async getUserIdsToSearch(auth: AuthDto): 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,16 +204,5 @@ 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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,14 +71,13 @@ 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 (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) {
|
if (requestedArchived || requestedFavorite || requestedTrash) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
|
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-7
@@ -41,13 +41,6 @@ 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, non-locked assets',
|
'withPartners is only supported for non-archived, non-trashed, non-favorited 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, non-locked assets',
|
'withPartners is only supported for non-archived, non-trashed, non-favorited 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, non-locked assets',
|
'withPartners is only supported for non-archived, non-trashed, non-favorited 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, non-locked assets',
|
'withPartners is only supported for non-archived, non-trashed, non-favorited 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, non-locked assets',
|
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,14 @@
|
|||||||
|
|
||||||
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 as viewerAsset (viewerAsset.id)}
|
{#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
|
||||||
{@const position = viewerAsset.position!}
|
{@const position = viewerAsset.position!}
|
||||||
{@const asset = viewerAsset.asset!}
|
{@const asset = viewerAsset.asset!}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
|
|
||||||
<AssetLayout
|
<AssetLayout
|
||||||
{manager}
|
{manager}
|
||||||
viewerAssets={timelineDay.activeViewerAssets}
|
viewerAssets={timelineDay.viewerAssets}
|
||||||
height={timelineDay.height}
|
height={timelineDay.height}
|
||||||
width={timelineDay.width}
|
width={timelineDay.width}
|
||||||
{customThumbnailLayout}
|
{customThumbnailLayout}
|
||||||
|
|||||||
@@ -53,3 +53,17 @@ 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,31 +1,12 @@
|
|||||||
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, CommonPosition } from '$lib/utils/layout-utils';
|
import type { CommonLayoutOptions } 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;
|
||||||
@@ -37,15 +18,12 @@ 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;
|
||||||
@@ -171,32 +149,18 @@ 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,11 +214,6 @@ 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(timelineAsset);
|
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset);
|
||||||
timelineDay.viewerAssets.push(viewerAsset);
|
timelineDay.viewerAssets.push(viewerAsset);
|
||||||
addContext.changedTimelineDays.add(timelineDay);
|
addContext.changedTimelineDays.add(timelineDay);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
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(asset: TimelineAsset) {
|
constructor(group: TimelineDay, 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 WorkflowStepCard from '$lib/components/workflows/WorkflowStepCard.svelte';
|
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
|
||||||
import WorkflowJsonEditor from '$lib/components/workflows/WorkflowJsonEditor.svelte';
|
import WorkflowStepCard from './WorkflowStepCard.svelte';
|
||||||
import WorkflowSummary from '$lib/components/workflows/WorkflowSummary.svelte';
|
import WorkflowSummary from './WorkflowSummary.svelte';
|
||||||
|
|
||||||
type WorkflowJsonContent = Required<
|
type WorkflowJsonContent = Required<
|
||||||
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
|
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
|
||||||
|
|||||||
+12
-9
@@ -10,7 +10,7 @@
|
|||||||
mdiPlus,
|
mdiPlus,
|
||||||
mdiTrashCanOutline,
|
mdiTrashCanOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { mount } from 'svelte';
|
import { flushSync, 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 dragImage = $state<Element>();
|
let isDragging = $state(false);
|
||||||
let isDropTarget = $state(false);
|
let isDropTarget = $state(false);
|
||||||
let hoverDrag = $state(false);
|
let hoverDrag = $state(false);
|
||||||
|
|
||||||
@@ -68,11 +68,15 @@
|
|||||||
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: document.body,
|
target: dragElement,
|
||||||
props: {
|
props: {
|
||||||
description: method?.description,
|
description: method?.description,
|
||||||
isFilter: method?.uiHints?.includes('filter') ?? false,
|
isFilter: method?.uiHints?.includes('filter') ?? false,
|
||||||
@@ -80,9 +84,9 @@
|
|||||||
stepNumber: index + 1,
|
stepNumber: index + 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
flushSync();
|
||||||
|
|
||||||
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
|
event.dataTransfer.setDragImage(dragElement, 16, 22);
|
||||||
event.dataTransfer.setDragImage(dragImage, 16, 22);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (index: number, event: DragEvent) => {
|
const handleDrop = (index: number, event: DragEvent) => {
|
||||||
@@ -105,8 +109,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
dragImage?.remove();
|
isDragging = false;
|
||||||
dragImage = undefined;
|
|
||||||
isDropTarget = false;
|
isDropTarget = false;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -129,8 +132,8 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="w-full transition-all"
|
class="w-full transition-all"
|
||||||
class:opacity-40={!!dragImage}
|
class:opacity-40={isDragging}
|
||||||
class:scale-[0.99]={!!dragImage}
|
class:scale-[0.99]={isDragging}
|
||||||
ondragover={handleDragOver}
|
ondragover={handleDragOver}
|
||||||
ondragleave={() => (isDropTarget = false)}
|
ondragleave={() => (isDropTarget = false)}
|
||||||
ondrop={(event) => handleDrop(index, event)}
|
ondrop={(event) => handleDrop(index, event)}
|
||||||
-1
@@ -13,7 +13,6 @@
|
|||||||
</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"
|
||||||
>
|
>
|
||||||
Reference in New Issue
Block a user