Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0eb4b3b526 | |||
| 32945a01b4 | |||
| 3817aec5b1 | |||
| 82054eb1c7 | |||
| dc7f3f5aa4 | |||
| 2c58b32bbc | |||
| 3a5e172262 | |||
| 7f38183cbb | |||
| c42cea5ca9 | |||
| da8505f61d | |||
| 58586483dc | |||
| a838167f11 | |||
| b189fc571c | |||
| 96923f6115 | |||
| 0d6cce4a5b | |||
| 55947cb227 | |||
| 8783180cf3 | |||
| 134c0d4dfb | |||
| aecf8ec88b | |||
| bcff1d42b0 | |||
| 1bd367bd51 | |||
| 725f266b81 | |||
| d08e3de207 |
@@ -72,10 +72,6 @@ jobs:
|
||||
run: flutter pub get
|
||||
working-directory: ./mobile/packages/ui
|
||||
|
||||
- name: Install dependencies for UI Showcase
|
||||
run: flutter pub get
|
||||
working-directory: ./mobile/packages/ui/showcase
|
||||
|
||||
- name: Generate translation files
|
||||
run: mise //mobile:codegen:translation
|
||||
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
dev:
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n" >&2 && exit 1
|
||||
|
||||
dev-down:
|
||||
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n" >&2 && exit 1
|
||||
|
||||
dev-update:
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n" >&2 && exit 1
|
||||
|
||||
dev-scale:
|
||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n" >&2 && exit 1
|
||||
|
||||
dev-docs:
|
||||
npm --prefix docs run start
|
||||
|
||||
.PHONY: e2e
|
||||
e2e:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n" >&2 && exit 1
|
||||
|
||||
e2e-dev:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n" >&2 && exit 1
|
||||
|
||||
e2e-update:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n" >&2 && exit 1
|
||||
|
||||
e2e-down:
|
||||
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n" >&2 && exit 1
|
||||
|
||||
prod:
|
||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n" >&2 && exit 1
|
||||
|
||||
prod-down:
|
||||
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n" >&2 && exit 1
|
||||
|
||||
prod-scale:
|
||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n" >&2 && exit 1
|
||||
|
||||
.PHONY: open-api
|
||||
open-api:
|
||||
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n"\n\n >&2 && exit 1
|
||||
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n" >&2 && exit 1
|
||||
|
||||
sql:
|
||||
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n"\n\n >&2 && exit 1
|
||||
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n" >&2 && exit 1
|
||||
|
||||
|
||||
renovate:
|
||||
@@ -52,16 +52,7 @@ renovate:
|
||||
MODULES = e2e server web cli sdk docs .github
|
||||
|
||||
test-e2e:
|
||||
docker compose -f ./e2e/docker-compose.yml build
|
||||
pnpm --filter immich-e2e run test
|
||||
pnpm --filter immich-e2e run test:web
|
||||
@printf "This command has been removed. Please use:\n\n mise //e2e:test # or mise //e2e:test-web for web tests, respectively\n\n" >&2 && exit 1
|
||||
|
||||
clean:
|
||||
find . -name "node_modules" -type d -prune -exec rm -rf {} +
|
||||
find . -name "dist" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "build" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
|
||||
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
|
||||
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
|
||||
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
|
||||
@printf "This command has been removed. Please use:\n\n mise clean # or mise //:clean from another directory\n\n" >&2 && exit 1
|
||||
|
||||
@@ -109,6 +109,24 @@ mise //mobile:translation
|
||||
|
||||
The mobile app asks you what backend to connect to. You can utilize the demo backend (https://demo.immich.app/) if you don't need to change server code or upload photos. Alternatively, you can run the server yourself per the instructions above.
|
||||
|
||||
#### UI components and widget previews
|
||||
|
||||
Shared design-system widgets (buttons, inputs, forms) live in the
|
||||
[`immich_ui` package](https://github.com/immich-app/immich/tree/main/mobile/packages/ui/)
|
||||
under `mobile/packages/ui/`. Components are defined in `lib/src/components/`
|
||||
and have matching previews in `lib/src/previews/`.
|
||||
|
||||
To inspect a component in isolation with a light/dark toggle and hot reload,
|
||||
launch [Flutter's Widget Previewer](https://docs.flutter.dev/tools/widget-previewer):
|
||||
|
||||
```bash
|
||||
cd mobile/packages/ui
|
||||
flutter widget-preview start
|
||||
```
|
||||
|
||||
In VS Code or Android Studio with the Flutter plugin, the previewer
|
||||
auto-starts when you open the **Flutter Widget Preview** tab in the sidebar.
|
||||
|
||||
## IDE setup
|
||||
|
||||
### Lint / format extensions
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
[tasks.install]
|
||||
run = "pnpm install --filter immich-e2e --frozen-lockfile"
|
||||
|
||||
[tasks.build]
|
||||
dir = "{{ config_root }}"
|
||||
run = "docker compose build"
|
||||
|
||||
[tasks.test]
|
||||
depends = ["//e2e:build", "//e2e:ci-setup"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "vitest --run"
|
||||
|
||||
[tasks.playwright-install]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "playwright install"
|
||||
|
||||
[tasks."test-web"]
|
||||
depends = ["//e2e:build", "//e2e:ci-setup", "//e2e:playwright-install"]
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "playwright test"
|
||||
|
||||
@@ -30,7 +40,12 @@ run = "tsc --noEmit"
|
||||
|
||||
|
||||
[tasks.ci-setup]
|
||||
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
|
||||
depends = [
|
||||
"//:sdk:install",
|
||||
"//:sdk:build",
|
||||
"//packages/cli:install",
|
||||
"//packages/cli:build",
|
||||
]
|
||||
run = { task = ":install" }
|
||||
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
|
||||
result.duration.push(asset.duration);
|
||||
result.projectionType.push(asset.projectionType);
|
||||
result.livePhotoVideoId.push(asset.livePhotoVideoId);
|
||||
result.city.push(asset.city);
|
||||
result.country.push(asset.country);
|
||||
result.city?.push(asset.city);
|
||||
result.country?.push(asset.country);
|
||||
result.visibility.push(asset.visibility);
|
||||
}
|
||||
|
||||
|
||||
@@ -2233,6 +2233,7 @@
|
||||
"slideshow_repeat": "Repeat slideshow",
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
"smart_album": "Smart album",
|
||||
"sort_albums_by": "Sort albums by...",
|
||||
"sort_created": "Date created",
|
||||
"sort_items": "Number of items",
|
||||
|
||||
@@ -64,6 +64,7 @@ class TextRecognizer(InferenceModel):
|
||||
rec_batch_num=max_batch_size if max_batch_size else 6,
|
||||
rec_img_shape=(3, 48, 320),
|
||||
lang_type=self.language,
|
||||
model_root_dir=self.cache_dir,
|
||||
)
|
||||
)
|
||||
return session
|
||||
|
||||
@@ -1028,7 +1028,12 @@ class TestOcr:
|
||||
text_recognizer.load()
|
||||
|
||||
rapid_recognizer.assert_called_once_with(
|
||||
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
||||
OcrOptions(
|
||||
session=ort_session.return_value,
|
||||
rec_batch_num=6,
|
||||
rec_img_shape=(3, 48, 320),
|
||||
model_root_dir=text_recognizer.cache_dir,
|
||||
)
|
||||
)
|
||||
|
||||
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
|
||||
@@ -1041,7 +1046,12 @@ class TestOcr:
|
||||
text_recognizer.load()
|
||||
|
||||
rapid_recognizer.assert_called_once_with(
|
||||
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
|
||||
OcrOptions(
|
||||
session=ort_session.return_value,
|
||||
rec_batch_num=4,
|
||||
rec_img_shape=(3, 48, 320),
|
||||
model_root_dir=text_recognizer.cache_dir,
|
||||
)
|
||||
)
|
||||
|
||||
def test_ignore_other_custom_max_batch_size(
|
||||
@@ -1056,7 +1066,12 @@ class TestOcr:
|
||||
text_recognizer.load()
|
||||
|
||||
rapid_recognizer.assert_called_once_with(
|
||||
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
||||
OcrOptions(
|
||||
session=ort_session.return_value,
|
||||
rec_batch_num=6,
|
||||
rec_img_shape=(3, 48, 320),
|
||||
model_root_dir=text_recognizer.cache_dir,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ lockfile = true
|
||||
|
||||
[tasks.plugins]
|
||||
run = [
|
||||
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
|
||||
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build",
|
||||
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
|
||||
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core build",
|
||||
]
|
||||
|
||||
[tasks.open-api-typescript]
|
||||
@@ -84,6 +84,72 @@ run = [
|
||||
dir = "server"
|
||||
run = "node ./dist/bin/sync-sql.js"
|
||||
|
||||
# TODO dev, prod, and e2e should be de-duplicated by using env but for some reason I ran into issues
|
||||
[tasks.dev]
|
||||
depends = "//:plugins"
|
||||
dir = "docker"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
|
||||
depends_post = "//:dev-down"
|
||||
|
||||
[tasks.dev-update]
|
||||
run = { task = "//:dev", args = ["--build", "-V"] }
|
||||
|
||||
[tasks.dev-scale]
|
||||
run = { task = "//:dev", args = ["--build", "-V", "--scale immich-server=3"] }
|
||||
|
||||
[tasks.dev-down]
|
||||
dir = "docker"
|
||||
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
|
||||
|
||||
[tasks.prod]
|
||||
depends = "//:plugins"
|
||||
dir = "docker"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.prod.yml up --build --remove-orphans"
|
||||
depends_post = "//:prod-down"
|
||||
|
||||
[tasks.prod-scale]
|
||||
run = { task = "//:prod", args = [
|
||||
"--build",
|
||||
"-V",
|
||||
"--scale immich-server=3",
|
||||
"--scale immich-microservices",
|
||||
] }
|
||||
|
||||
[tasks.prod-down]
|
||||
dir = "docker"
|
||||
run = "docker compose -f ./docker-compose.prod.yml down --remove-orphans"
|
||||
|
||||
[tasks.e2e]
|
||||
depends = "//:plugins"
|
||||
dir = "e2e"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.yml up --remove-orphans"
|
||||
depends_post = "//:e2e-down"
|
||||
|
||||
[tasks.e2e-dev]
|
||||
depends = "//:plugins"
|
||||
dir = "e2e"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
|
||||
depends_post = "//:e2e-dev-down"
|
||||
|
||||
[tasks.e2e-update]
|
||||
run = { task = "//:e2e", args = ["--build", '-V'] }
|
||||
|
||||
[tasks.e2e-down]
|
||||
dir = "e2e"
|
||||
run = "docker compose -f ./docker-compose.yml down --remove-orphans"
|
||||
|
||||
[tasks.e2e-dev-down]
|
||||
dir = "e2e"
|
||||
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
|
||||
|
||||
# SDK tasks
|
||||
[tasks."sdk:install"]
|
||||
dir = "packages/sdk"
|
||||
@@ -99,3 +165,14 @@ run = "pnpm format"
|
||||
|
||||
[tasks."i18n:format-fix"]
|
||||
run = "pnpm format:fix"
|
||||
|
||||
[tasks.clean]
|
||||
run = [
|
||||
"find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
|
||||
"find . -name 'dist' -type d -prune -exec rm -rf '{}' +",
|
||||
"find . -name 'build' -type d -prune -exec rm -rf '{}' +",
|
||||
"find . -name '.svelte-kit' -type d -prune -exec rm -rf '{}' +",
|
||||
"find . -name 'coverage' -type d -prune -exec rm -rf '{}' +",
|
||||
"find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' +",
|
||||
{ task = "//:*-down" },
|
||||
]
|
||||
|
||||
@@ -143,13 +143,8 @@ class AppConfig {
|
||||
})
|
||||
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;
|
||||
}
|
||||
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> overrides) =>
|
||||
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
|
||||
|
||||
AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
|
||||
return switch (key) {
|
||||
|
||||
@@ -7,13 +7,6 @@ import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
enum MetadataScope {
|
||||
user, // keys with this scope are deleted on logout
|
||||
system;
|
||||
|
||||
const MetadataScope();
|
||||
}
|
||||
|
||||
enum MetadataKey<T extends Object> {
|
||||
// Theme
|
||||
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
|
||||
@@ -32,14 +25,11 @@ enum MetadataKey<T extends Object> {
|
||||
viewerTapToNavigate<bool>(),
|
||||
|
||||
// Network
|
||||
networkAutoEndpointSwitching<bool>(scope: .system),
|
||||
networkPreferredWifiName<String>(scope: .system),
|
||||
networkLocalEndpoint<String>(scope: .system),
|
||||
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(
|
||||
scope: .system,
|
||||
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
),
|
||||
networkAutoEndpointSwitching<bool>(),
|
||||
networkPreferredWifiName<String>(),
|
||||
networkLocalEndpoint<String>(),
|
||||
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
|
||||
@@ -60,7 +50,7 @@ enum MetadataKey<T extends Object> {
|
||||
timelineStorageIndicator<bool>(),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
|
||||
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
|
||||
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
@@ -83,10 +73,9 @@ enum MetadataKey<T extends Object> {
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
|
||||
final MetadataScope scope;
|
||||
final _MetadataCodec<T>? _codecOverride;
|
||||
|
||||
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
|
||||
const MetadataKey({_MetadataCodec<T>? codec}) : _codecOverride = codec;
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
|
||||
|
||||
|
||||
@@ -234,13 +234,24 @@ class RemoteAlbumService {
|
||||
final pendingAdds = <Future<void>>[];
|
||||
final localById = {for (final a in localAssets) a.id: a};
|
||||
|
||||
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = userCallbacks;
|
||||
final wrappedCallbacks = UploadCallbacks(
|
||||
onProgress: onProgress,
|
||||
onICloudProgress: onICloudProgress,
|
||||
onError: onError,
|
||||
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
|
||||
'Upload progress callback failed for $localId',
|
||||
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
|
||||
),
|
||||
onICloudProgress: (localId, progress) => _runUploadCallback(
|
||||
'iCloud progress callback failed for $localId',
|
||||
() => userCallbacks.onICloudProgress?.call(localId, progress),
|
||||
),
|
||||
onError: (localId, errorMessage) => _runUploadCallback(
|
||||
'Upload error callback failed for $localId',
|
||||
() => userCallbacks.onError?.call(localId, errorMessage),
|
||||
),
|
||||
onSuccess: (localId, remoteId) {
|
||||
onSuccess?.call(localId, remoteId);
|
||||
_runUploadCallback(
|
||||
'Upload success callback failed for $localId',
|
||||
() => userCallbacks.onSuccess?.call(localId, remoteId),
|
||||
);
|
||||
final source = localById[localId];
|
||||
if (source == null) {
|
||||
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
|
||||
@@ -248,22 +259,29 @@ class RemoteAlbumService {
|
||||
}
|
||||
pendingAdds.add(
|
||||
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
|
||||
.then<void>((added) => addedCount += added)
|
||||
.onError(
|
||||
(error, stack) =>
|
||||
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack),
|
||||
),
|
||||
.then<void>((added) {
|
||||
addedCount += added;
|
||||
})
|
||||
.catchError((Object error, StackTrace stack) {
|
||||
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _uploadService.uploadManual(localAssets, cancelToken: null, callbacks: wrappedCallbacks);
|
||||
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
|
||||
await Future.wait(pendingAdds);
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
// TODO: this is a poorly designed flow; adding a "stub" just to satisfy FK constraints is hacky,
|
||||
// it goes out of its way to insert one at a time, and it swallows errors that should be surfaced to the user.
|
||||
void _runUploadCallback(String message, void Function() callback) {
|
||||
try {
|
||||
callback();
|
||||
} catch (error, stack) {
|
||||
_logger.warning(message, error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// Links a freshly-uploaded asset to an album, ensuring the local DB
|
||||
/// reflects the change without waiting for the next sync. We call the API
|
||||
/// (server is the source of truth), then upsert a placeholder
|
||||
|
||||
@@ -34,21 +34,33 @@ class MetadataRepository extends DriftDatabaseRepository {
|
||||
|
||||
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
|
||||
|
||||
Future<void> clear(Iterable<MetadataKey> keys) async {
|
||||
if (keys.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final names = keys.map((key) => key.name).toList();
|
||||
await (_db.delete(_db.metadataEntity)..where((row) => row.key.isIn(names))).go();
|
||||
|
||||
for (final key in keys) {
|
||||
_appConfig = _appConfig.write(key, defaultConfig.read(key));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
|
||||
if (value == _appConfig.read(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == defaultConfig.read(key)) {
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
|
||||
} else {
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
return clear([key]);
|
||||
}
|
||||
|
||||
await _db
|
||||
.into(_db.metadataEntity)
|
||||
.insertOnConflictUpdate(
|
||||
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
_appConfig = _appConfig.write(key, value);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@@ -6,54 +5,66 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
typedef OnProgress = void Function(String id, double progress);
|
||||
|
||||
class StorageRepository {
|
||||
static final log = Logger('StorageRepository');
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
const StorageRepository();
|
||||
StorageRepository();
|
||||
|
||||
Future<File?> getAssetFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
|
||||
return _getFileForAsset(assetId, isMotion: false, onProgress: onProgress, cancelToken: cancelToken);
|
||||
}
|
||||
|
||||
Future<File?> getMotionFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
|
||||
return _getFileForAsset(assetId, isMotion: true, onProgress: onProgress, cancelToken: cancelToken);
|
||||
}
|
||||
|
||||
Future<File?> _getFileForAsset(
|
||||
String assetId, {
|
||||
bool isMotion = false,
|
||||
OnProgress? onProgress,
|
||||
Completer<void>? cancelToken,
|
||||
}) async {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
PMProgressHandler? progressHandler;
|
||||
StreamSubscription<PMProgressState>? progressSubscription;
|
||||
PMCancelToken? pmCancelToken;
|
||||
if (cancelToken != null) {
|
||||
progressHandler = PMProgressHandler();
|
||||
progressSubscription = progressHandler.stream.listen((event) => onProgress?.call(assetId, event.progress));
|
||||
pmCancelToken = PMCancelToken();
|
||||
unawaited(cancelToken.future.then((_) => pmCancelToken!.cancelRequest()));
|
||||
}
|
||||
Future<File?> getFileForAsset(String assetId) async {
|
||||
File? file;
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
try {
|
||||
return await entity.loadFile(withSubtype: isMotion, progressHandler: progressHandler, cancelToken: pmCancelToken);
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
file = await entity?.originFile;
|
||||
if (file == null) {
|
||||
log.warning("Cannot get file for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
final exists = await file.exists();
|
||||
if (!exists) {
|
||||
log.warning("File for asset $assetId does not exist");
|
||||
return null;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error loading file for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
} finally {
|
||||
unawaited(progressSubscription?.cancel());
|
||||
log.warning("Error getting file for asset $assetId", error, stackTrace);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
|
||||
File? file;
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(asset.id);
|
||||
file = await entity?.originFileWithSubtype;
|
||||
if (file == null) {
|
||||
log.warning(
|
||||
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final exists = await file.exists();
|
||||
if (!exists) {
|
||||
log.warning("Motion file for asset ${asset.id} does not exist");
|
||||
return null;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning(
|
||||
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
AssetEntity? entity;
|
||||
|
||||
try {
|
||||
@@ -88,7 +99,39 @@ class StorageRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await entity.loadFile(progressHandler: progressHandler);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
try {
|
||||
await PhotoManager.clearFileCache();
|
||||
} catch (error, stackTrace) {
|
||||
|
||||
@@ -6,13 +6,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
@@ -108,7 +108,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
try {
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await ref.read(storageRepositoryProvider).getAssetFile(id);
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
@@ -274,7 +273,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
onProgress: _handleForegroundBackupProgress,
|
||||
onSuccess: _handleForegroundBackupSuccess,
|
||||
onError: _handleForegroundBackupError,
|
||||
onICloudProgress: CurrentPlatform.isIOS ? _handleICloudProgress : null,
|
||||
onICloudProgress: _handleICloudProgress,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -283,7 +282,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
_cancelToken?.complete();
|
||||
_cancelToken = null;
|
||||
_uploadSpeedManager.clear();
|
||||
state = state.copyWith(uploadItems: const {}, iCloudDownloadProgress: const {});
|
||||
state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
|
||||
}
|
||||
|
||||
void _handleICloudProgress(String localAssetId, double progress) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
|
||||
final storageRepositoryProvider = Provider<StorageRepository>((ref) => const StorageRepository());
|
||||
final storageRepositoryProvider = Provider<StorageRepository>((ref) => StorageRepository());
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
|
||||
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
|
||||
|
||||
@@ -19,14 +20,21 @@ class UploadRepository {
|
||||
void Function(TaskProgressUpdate)? onTaskProgress;
|
||||
|
||||
UploadRepository() {
|
||||
final downloader = FileDownloader();
|
||||
for (final group in const [kBackupGroup, kBackupLivePhotoGroup, kManualUploadGroup]) {
|
||||
downloader.registerCallbacks(
|
||||
group: group,
|
||||
taskStatusCallback: onUploadStatus,
|
||||
taskProgressCallback: onTaskProgress,
|
||||
);
|
||||
}
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kBackupGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kBackupLivePhotoGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kManualUploadGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> enqueueBackground(UploadTask task) {
|
||||
@@ -58,6 +66,28 @@ class UploadRepository {
|
||||
return FileDownloader().start();
|
||||
}
|
||||
|
||||
Future<void> getUploadInfo() async {
|
||||
final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup),
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup),
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup),
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup),
|
||||
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
|
||||
]);
|
||||
|
||||
dPrint(
|
||||
() =>
|
||||
"""
|
||||
Upload Info:
|
||||
Enqueued: ${enqueuedTasks.length}
|
||||
Running: ${runningTasks.length}
|
||||
Canceled: ${canceledTasks.length}
|
||||
Waiting: ${waitingTasks.length}
|
||||
Paused: ${pausedTasks.length}
|
||||
""",
|
||||
);
|
||||
}
|
||||
|
||||
Future<UploadResult> uploadFile({
|
||||
required File file,
|
||||
required String originalFileName,
|
||||
@@ -81,30 +111,41 @@ class UploadRepository {
|
||||
baseRequest.fields.addAll(fields);
|
||||
baseRequest.files.add(assetRawUploadData);
|
||||
|
||||
final StreamedResponse(:statusCode, :stream) = await NetworkRepository.client.send(baseRequest);
|
||||
final responseBodyString = await stream.bytesToString();
|
||||
final response = await NetworkRepository.client.send(baseRequest);
|
||||
final responseBodyString = await response.stream.bytesToString();
|
||||
|
||||
return switch ((statusCode, _tryJsonDecode(responseBodyString))) {
|
||||
(200 || 201, {'id': String id}) => UploadSuccess(remoteAssetId: id),
|
||||
(413, _) => const UploadError(statusCode: 413, message: 'File is too large to upload'),
|
||||
(_, {'message': String message}) => UploadError(statusCode: statusCode, message: message),
|
||||
_ => UploadError(statusCode: statusCode, message: 'Upload failed with status $statusCode'),
|
||||
};
|
||||
if (![200, 201].contains(response.statusCode)) {
|
||||
String? errorMessage;
|
||||
|
||||
if (response.statusCode == 413) {
|
||||
errorMessage = 'Error(413) File is too large to upload';
|
||||
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
final error = jsonDecode(responseBodyString);
|
||||
errorMessage = error['message'] ?? error['error'];
|
||||
} catch (_) {
|
||||
errorMessage = responseBodyString.isNotEmpty
|
||||
? responseBodyString
|
||||
: 'Upload failed with status ${response.statusCode}';
|
||||
}
|
||||
|
||||
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
final responseBody = jsonDecode(responseBodyString);
|
||||
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
|
||||
} catch (e) {
|
||||
return UploadResult.error(errorMessage: 'Failed to parse server response');
|
||||
}
|
||||
} on RequestAbortedException {
|
||||
logger.warning("Upload $logContext was cancelled");
|
||||
return const UploadCancelled();
|
||||
return UploadResult.cancelled();
|
||||
} catch (error, stackTrace) {
|
||||
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
|
||||
return UploadError(message: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
Map? _tryJsonDecode(String s) {
|
||||
try {
|
||||
return (jsonDecode(s) as Map);
|
||||
} catch (_) {
|
||||
return null;
|
||||
return UploadResult.error(errorMessage: error.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,23 +180,30 @@ class ProgressMultipartRequest extends MultipartRequest with Abortable {
|
||||
}
|
||||
}
|
||||
|
||||
sealed class UploadResult {
|
||||
const UploadResult();
|
||||
}
|
||||
|
||||
final class UploadSuccess extends UploadResult {
|
||||
final String remoteAssetId;
|
||||
|
||||
const UploadSuccess({required this.remoteAssetId});
|
||||
}
|
||||
|
||||
final class UploadError extends UploadResult {
|
||||
final String message;
|
||||
class UploadResult {
|
||||
final bool isSuccess;
|
||||
final bool isCancelled;
|
||||
final String? remoteAssetId;
|
||||
final String? errorMessage;
|
||||
final int? statusCode;
|
||||
|
||||
const UploadError({required this.message, this.statusCode});
|
||||
}
|
||||
const UploadResult({
|
||||
required this.isSuccess,
|
||||
required this.isCancelled,
|
||||
this.remoteAssetId,
|
||||
this.errorMessage,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
final class UploadCancelled extends UploadResult {
|
||||
const UploadCancelled();
|
||||
factory UploadResult.success({required String remoteAssetId}) {
|
||||
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
|
||||
}
|
||||
|
||||
factory UploadResult.error({String? errorMessage, int? statusCode}) {
|
||||
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
|
||||
}
|
||||
|
||||
factory UploadResult.cancelled() {
|
||||
return const UploadResult(isSuccess: false, isCancelled: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class ApiService {
|
||||
late ApiClient _apiClient;
|
||||
final ApiClient _apiClient = ApiClient(basePath: '');
|
||||
|
||||
late UsersApi usersApi;
|
||||
late AuthenticationApi authenticationApi;
|
||||
@@ -54,7 +54,7 @@ class ApiService {
|
||||
}
|
||||
|
||||
setEndpoint(String endpoint) {
|
||||
_apiClient = ApiClient(basePath: endpoint);
|
||||
_apiClient.basePath = endpoint;
|
||||
_apiClient.client = NetworkRepository.client;
|
||||
usersApi = UsersApi(_apiClient);
|
||||
authenticationApi = AuthenticationApi(_apiClient);
|
||||
|
||||
@@ -110,7 +110,7 @@ class AuthService {
|
||||
/// - Authentication repository data
|
||||
/// - Current user information
|
||||
/// - Access token
|
||||
/// - Asset ETag
|
||||
/// - Server-specific endpoint configuration
|
||||
///
|
||||
/// All deletions are executed in parallel using [Future.wait].
|
||||
Future<void> clearLocalData() async {
|
||||
@@ -120,6 +120,12 @@ class AuthService {
|
||||
_authRepository.clearLocalData(),
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
MetadataRepository.instance.clear(const [
|
||||
.networkAutoEndpointSwitching,
|
||||
.networkPreferredWifiName,
|
||||
.networkLocalEndpoint,
|
||||
.networkExternalEndpointList,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,8 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
File? file;
|
||||
|
||||
/// iOS LivePhoto has two files: a photo and a video.
|
||||
/// They are uploaded separately, with video file being upload first, then returned with the assetId
|
||||
/// The assetId is then used as a metadata for the photo file upload task.
|
||||
@@ -276,9 +278,11 @@ class BackgroundUploadService {
|
||||
/// The cancel operation will only cancel the video group (normal group), the photo group will not
|
||||
/// be touched, as the video file is already uploaded.
|
||||
|
||||
final file = await (entity.isLivePhoto
|
||||
? _storageRepository.getMotionFile(asset.id)
|
||||
: _storageRepository.getAssetFile(asset.id));
|
||||
if (entity.isLivePhoto) {
|
||||
file = await _storageRepository.getMotionFileForAsset(asset);
|
||||
} else {
|
||||
file = await _storageRepository.getFileForAsset(asset.id);
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
|
||||
@@ -326,7 +330,7 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = await _storageRepository.getAssetFile(asset.id);
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
|
||||
/// Callbacks for upload progress and status updates
|
||||
class UploadCallbacks {
|
||||
@@ -98,7 +99,7 @@ class ForegroundUploadService {
|
||||
final requireWifi = _shouldRequireWiFi(asset);
|
||||
return requireWifi && !hasWifi;
|
||||
},
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -124,14 +125,14 @@ class ForegroundUploadService {
|
||||
continue;
|
||||
}
|
||||
|
||||
await _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks);
|
||||
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually upload picked local assets
|
||||
Future<void> uploadManual(
|
||||
List<LocalAsset> localAssets, {
|
||||
required Completer<void>? cancelToken,
|
||||
Completer<void>? cancelToken,
|
||||
UploadCallbacks callbacks = const UploadCallbacks(),
|
||||
}) async {
|
||||
if (localAssets.isEmpty) {
|
||||
@@ -141,7 +142,7 @@ class ForegroundUploadService {
|
||||
await _executeWithWorkerPool<LocalAsset>(
|
||||
items: localAssets,
|
||||
cancelToken: cancelToken,
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,11 +170,11 @@ class ForegroundUploadService {
|
||||
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
|
||||
);
|
||||
|
||||
return switch (result) {
|
||||
UploadSuccess() => onSuccess?.call(fileId),
|
||||
UploadError(:final message) => onError?.call(fileId, message),
|
||||
UploadCancelled() => null,
|
||||
};
|
||||
if (result.isSuccess) {
|
||||
onSuccess?.call(fileId);
|
||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||
onError?.call(fileId, result.errorMessage!);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -215,7 +216,7 @@ class ForegroundUploadService {
|
||||
|
||||
final item = items[index];
|
||||
|
||||
if (shouldSkip != null && shouldSkip(item)) {
|
||||
if (shouldSkip?.call(item) ?? false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -232,48 +233,78 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
Future<void> _uploadSingleAsset(
|
||||
LocalAsset asset, {
|
||||
required Completer<void>? cancelToken,
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
}) async {
|
||||
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = callbacks;
|
||||
File? assetFile;
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
|
||||
try {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
onError?.call(
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
File? file;
|
||||
if (entity.isLivePhoto) {
|
||||
file = await _storageRepository.getMotionFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
|
||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||
|
||||
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
||||
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
|
||||
|
||||
// Create progress handler for iCloud download
|
||||
PMProgressHandler? progressHandler;
|
||||
StreamSubscription? progressSubscription;
|
||||
|
||||
progressHandler = PMProgressHandler();
|
||||
progressSubscription = progressHandler.stream.listen((event) {
|
||||
callbacks.onICloudProgress?.call(asset.localId!, event.progress);
|
||||
});
|
||||
|
||||
try {
|
||||
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
|
||||
if (entity.isLivePhoto) {
|
||||
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
|
||||
asset.id,
|
||||
progressHandler: progressHandler,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await progressSubscription.cancel();
|
||||
}
|
||||
} else {
|
||||
// Get files locally
|
||||
file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
|
||||
onError?.call(
|
||||
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
livePhotoFile = file;
|
||||
|
||||
// For live photos, get the motion video file
|
||||
if (entity.isLivePhoto) {
|
||||
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
|
||||
if (livePhotoFile == null) {
|
||||
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file = await _storageRepository.getAssetFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
|
||||
onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
|
||||
return;
|
||||
}
|
||||
assetFile = file;
|
||||
|
||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
|
||||
@@ -299,9 +330,11 @@ class ForegroundUploadService {
|
||||
};
|
||||
|
||||
// Upload live photo video first if available
|
||||
String? livePhotoVideoId;
|
||||
if (entity.isLivePhoto && livePhotoFile != null) {
|
||||
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
final livePhotoResult = await _uploadRepository.uploadFile(
|
||||
file: livePhotoFile,
|
||||
originalFileName: livePhotoTitle,
|
||||
@@ -313,16 +346,15 @@ class ForegroundUploadService {
|
||||
logContext: 'livePhotoVideo[${asset.localId}]',
|
||||
);
|
||||
|
||||
switch (livePhotoResult) {
|
||||
case UploadSuccess(:final remoteAssetId):
|
||||
fields['livePhotoVideoId'] = remoteAssetId;
|
||||
case UploadError(:final message):
|
||||
onError?.call(asset.localId!, "Failed to upload live photo video: $message");
|
||||
return;
|
||||
case UploadCancelled():
|
||||
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
|
||||
livePhotoVideoId = livePhotoResult.remoteAssetId;
|
||||
}
|
||||
}
|
||||
|
||||
if (livePhotoVideoId != null) {
|
||||
fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||
}
|
||||
|
||||
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null) {
|
||||
fields['metadata'] = jsonEncode([
|
||||
@@ -339,6 +371,7 @@ class ForegroundUploadService {
|
||||
]);
|
||||
}
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: file,
|
||||
originalFileName: originalFileName,
|
||||
@@ -350,33 +383,34 @@ class ForegroundUploadService {
|
||||
logContext: 'asset[${asset.localId}]',
|
||||
);
|
||||
|
||||
switch (result) {
|
||||
case UploadSuccess(:final remoteAssetId):
|
||||
onSuccess?.call(asset.localId!, remoteAssetId);
|
||||
case UploadCancelled():
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
_logger.warning(() => "Backup was cancelled by the user");
|
||||
shouldAbortUpload = true;
|
||||
} else if (result.errorMessage != null) {
|
||||
_logger.severe(
|
||||
() =>
|
||||
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
|
||||
);
|
||||
|
||||
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||
|
||||
if (result.errorMessage == "Quota has been exceeded!") {
|
||||
shouldAbortUpload = true;
|
||||
_logger.warning("Upload was cancelled by the user for asset ${asset.localId}");
|
||||
case UploadError(:final message, :final statusCode):
|
||||
shouldAbortUpload |= message == "Quota has been exceeded!";
|
||||
_logger.severe(
|
||||
"Error(${statusCode ?? 'unknown'}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | $message",
|
||||
);
|
||||
onError?.call(asset.localId!, message);
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe("Asset backup failed", error, stackTrace);
|
||||
onError?.call(asset.localId!, error.toString());
|
||||
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
|
||||
callbacks.onError?.call(asset.localId!, error.toString());
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
unawaited(
|
||||
Future.wait([
|
||||
if (assetFile != null) assetFile.delete(),
|
||||
if (livePhotoFile != null) livePhotoFile.delete(),
|
||||
]).onError((error, stackTrace) {
|
||||
_logger.severe("Post-upload file cleanup failed", error, stackTrace);
|
||||
return const [];
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await file?.delete();
|
||||
await livePhotoFile?.delete();
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,7 +446,7 @@ class ForegroundUploadService {
|
||||
logContext: 'shareIntent[$deviceAssetId]',
|
||||
);
|
||||
} catch (e) {
|
||||
return UploadError(message: e.toString());
|
||||
return UploadResult.error(errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -508,12 +508,18 @@ class AlbumsApi {
|
||||
/// * [String] assetId:
|
||||
/// Filter albums containing this asset ID (ignores other parameters)
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Album ID
|
||||
///
|
||||
/// * [bool] isOwned:
|
||||
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
|
||||
///
|
||||
/// * [bool] isShared:
|
||||
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
|
||||
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? isOwned, bool? isShared, }) async {
|
||||
///
|
||||
/// * [String] name:
|
||||
/// Album name (exact match)
|
||||
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/albums';
|
||||
|
||||
@@ -527,12 +533,18 @@ class AlbumsApi {
|
||||
if (assetId != null) {
|
||||
queryParams.addAll(_queryParams('', 'assetId', assetId));
|
||||
}
|
||||
if (id != null) {
|
||||
queryParams.addAll(_queryParams('', 'id', id));
|
||||
}
|
||||
if (isOwned != null) {
|
||||
queryParams.addAll(_queryParams('', 'isOwned', isOwned));
|
||||
}
|
||||
if (isShared != null) {
|
||||
queryParams.addAll(_queryParams('', 'isShared', isShared));
|
||||
}
|
||||
if (name != null) {
|
||||
queryParams.addAll(_queryParams('', 'name', name));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
@@ -557,13 +569,19 @@ class AlbumsApi {
|
||||
/// * [String] assetId:
|
||||
/// Filter albums containing this asset ID (ignores other parameters)
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Album ID
|
||||
///
|
||||
/// * [bool] isOwned:
|
||||
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
|
||||
///
|
||||
/// * [bool] isShared:
|
||||
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
|
||||
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? isOwned, bool? isShared, }) async {
|
||||
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, isOwned: isOwned, isShared: isShared, );
|
||||
///
|
||||
/// * [String] name:
|
||||
/// Album name (exact match)
|
||||
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
|
||||
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, id: id, isOwned: isOwned, isShared: isShared, name: name, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ part of openapi.api;
|
||||
class ApiClient {
|
||||
ApiClient({this.basePath = '/api', this.authentication,});
|
||||
|
||||
final String basePath;
|
||||
String basePath;
|
||||
final Authentication? authentication;
|
||||
|
||||
var _client = Client();
|
||||
|
||||
@@ -77,7 +77,7 @@ class JobName {
|
||||
static const versionCheck = JobName._(r'VersionCheck');
|
||||
static const ocrQueueAll = JobName._(r'OcrQueueAll');
|
||||
static const ocr = JobName._(r'Ocr');
|
||||
static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate');
|
||||
static const workflowAssetTrigger = JobName._(r'WorkflowAssetTrigger');
|
||||
|
||||
/// List of all possible values in this [enum][JobName].
|
||||
static const values = <JobName>[
|
||||
@@ -135,7 +135,7 @@ class JobName {
|
||||
versionCheck,
|
||||
ocrQueueAll,
|
||||
ocr,
|
||||
workflowAssetCreate,
|
||||
workflowAssetTrigger,
|
||||
];
|
||||
|
||||
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
|
||||
@@ -228,7 +228,7 @@ class JobNameTypeTransformer {
|
||||
case r'VersionCheck': return JobName.versionCheck;
|
||||
case r'OcrQueueAll': return JobName.ocrQueueAll;
|
||||
case r'Ocr': return JobName.ocr;
|
||||
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate;
|
||||
case r'WorkflowAssetTrigger': return JobName.workflowAssetTrigger;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
@@ -18,6 +18,7 @@ class PluginTemplateResponseDto {
|
||||
this.steps = const [],
|
||||
required this.title,
|
||||
required this.trigger,
|
||||
this.uiHints = const [],
|
||||
});
|
||||
|
||||
/// Template description
|
||||
@@ -34,13 +35,17 @@ class PluginTemplateResponseDto {
|
||||
|
||||
WorkflowTrigger trigger;
|
||||
|
||||
/// Ui hints, for example \"smart-album\"
|
||||
List<String> uiHints;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
|
||||
other.description == description &&
|
||||
other.key == key &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.title == title &&
|
||||
other.trigger == trigger;
|
||||
other.trigger == trigger &&
|
||||
_deepEquality.equals(other.uiHints, uiHints);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
@@ -49,10 +54,11 @@ class PluginTemplateResponseDto {
|
||||
(key.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(title.hashCode) +
|
||||
(trigger.hashCode);
|
||||
(trigger.hashCode) +
|
||||
(uiHints.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]';
|
||||
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger, uiHints=$uiHints]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -61,6 +67,7 @@ class PluginTemplateResponseDto {
|
||||
json[r'steps'] = this.steps;
|
||||
json[r'title'] = this.title;
|
||||
json[r'trigger'] = this.trigger;
|
||||
json[r'uiHints'] = this.uiHints;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -78,6 +85,9 @@ class PluginTemplateResponseDto {
|
||||
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
uiHints: json[r'uiHints'] is Iterable
|
||||
? (json[r'uiHints'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -130,6 +140,7 @@ class PluginTemplateResponseDto {
|
||||
'steps',
|
||||
'title',
|
||||
'trigger',
|
||||
'uiHints',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,13 @@ class WorkflowTrigger {
|
||||
String toJson() => value;
|
||||
|
||||
static const assetCreate = WorkflowTrigger._(r'AssetCreate');
|
||||
static const assetMetadataExtraction = WorkflowTrigger._(r'AssetMetadataExtraction');
|
||||
static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
|
||||
|
||||
/// List of all possible values in this [enum][WorkflowTrigger].
|
||||
static const values = <WorkflowTrigger>[
|
||||
assetCreate,
|
||||
assetMetadataExtraction,
|
||||
personRecognized,
|
||||
];
|
||||
|
||||
@@ -69,6 +71,7 @@ class WorkflowTriggerTypeTransformer {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'AssetCreate': return WorkflowTrigger.assetCreate;
|
||||
case r'AssetMetadataExtraction': return WorkflowTrigger.assetMetadataExtraction;
|
||||
case r'PersonRecognized': return WorkflowTrigger.personRecognized;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widget_previews.dart';
|
||||
import 'package:immich_ui/src/theme.dart';
|
||||
|
||||
const ColorScheme _lightColorScheme = ColorScheme.light(
|
||||
primary: Color(0xFF4250AF),
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: Color(0xFFD4D6F0),
|
||||
onPrimaryContainer: Color(0xFF181E44),
|
||||
secondary: Color(0xFF737373),
|
||||
onSecondary: Colors.white,
|
||||
error: Color(0xFFE53E3E),
|
||||
onError: Colors.white,
|
||||
surface: Color(0xFFFAFAFA),
|
||||
onSurface: Color(0xFF1A1C1E),
|
||||
surfaceContainerHighest: Color(0xFFE3E4E8),
|
||||
outline: Color(0xFFD1D3D9),
|
||||
outlineVariant: Color(0xFFD4D4D4),
|
||||
);
|
||||
|
||||
const ColorScheme _darkColorScheme = ColorScheme.dark(
|
||||
primary: Color(0xFFACCBFA),
|
||||
onPrimary: Color(0xFF0F1433),
|
||||
primaryContainer: Color(0xFF616D94),
|
||||
onPrimaryContainer: Color(0xFFD4D6F0),
|
||||
secondary: Color(0xFFC4C6D0),
|
||||
onSecondary: Color(0xFF2E3042),
|
||||
error: Color(0xFFE88080),
|
||||
onError: Color(0xFF0F1433),
|
||||
surface: Color(0xFF0A0A0A),
|
||||
onSurface: Color(0xFFE3E3E6),
|
||||
surfaceContainerHighest: Color(0xFF262626),
|
||||
outline: Color(0xFF8E9099),
|
||||
outlineVariant: Color(0xFF43464F),
|
||||
);
|
||||
|
||||
PreviewThemeData immichPreviewTheme() => PreviewThemeData(
|
||||
materialLight: ThemeData(colorScheme: _lightColorScheme, useMaterial3: true),
|
||||
materialDark: ThemeData(colorScheme: _darkColorScheme, useMaterial3: true),
|
||||
);
|
||||
|
||||
Widget immichPreviewWrapper(Widget child) {
|
||||
return Builder(
|
||||
builder: (context) => ImmichThemeProvider(
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
child: Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Align(alignment: Alignment.topLeft, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final class ImmichPreview extends Preview {
|
||||
const ImmichPreview({super.name, super.group, super.size, super.textScaleFactor})
|
||||
: super(theme: immichPreviewTheme, wrapper: immichPreviewWrapper);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/close_button.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'CloseButton', name: 'Variants')
|
||||
Widget previewCloseButtonVariants() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichCloseButton(onPressed: _previewNoop),
|
||||
ImmichCloseButton(onPressed: _previewNoop, variant: ImmichVariant.filled),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'CloseButton', name: 'Colors')
|
||||
Widget previewCloseButtonColors() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichCloseButton(onPressed: _previewNoop),
|
||||
ImmichCloseButton(onPressed: _previewNoop, color: ImmichColor.secondary),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/form.dart';
|
||||
import 'package:immich_ui/src/components/password_input.dart';
|
||||
import 'package:immich_ui/src/components/text_input.dart';
|
||||
import 'package:immich_ui/src/constants.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'Form', name: 'Login Form')
|
||||
Widget previewFormLogin() => const _PreviewLoginForm();
|
||||
|
||||
class _PreviewLoginForm extends StatefulWidget {
|
||||
const _PreviewLoginForm();
|
||||
|
||||
@override
|
||||
State<_PreviewLoginForm> createState() => _PreviewLoginFormState();
|
||||
}
|
||||
|
||||
class _PreviewLoginFormState extends State<_PreviewLoginForm> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
String _result = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ImmichForm(
|
||||
submitText: 'Login',
|
||||
submitIcon: Icons.login,
|
||||
onSubmit: () async {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_result = 'Form submitted!';
|
||||
});
|
||||
},
|
||||
builder: (context, form) => Column(
|
||||
spacing: ImmichSpacing.sm,
|
||||
children: [
|
||||
ImmichTextInput(
|
||||
label: 'Email',
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) => value?.isEmpty ?? true ? 'Required' : null,
|
||||
),
|
||||
ImmichPasswordInput(
|
||||
label: 'Password',
|
||||
controller: _passwordController,
|
||||
validator: (value) => value?.isEmpty ?? true ? 'Required' : null,
|
||||
onSubmit: (_) => form.submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_result.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_result, style: const TextStyle(color: Colors.green)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/formatted_text.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'FormattedText', name: 'Bold')
|
||||
Widget previewFormattedTextBold() => const ImmichFormattedText('This is <b>bold text</b>.');
|
||||
|
||||
@ImmichPreview(group: 'FormattedText', name: 'Links')
|
||||
Widget previewFormattedTextLinks() => const _PreviewFormattedTextLinks();
|
||||
|
||||
@ImmichPreview(group: 'FormattedText', name: 'Mixed Content')
|
||||
Widget previewFormattedTextMixed() => const _PreviewFormattedTextMixed();
|
||||
|
||||
class _PreviewFormattedTextLinks extends StatelessWidget {
|
||||
const _PreviewFormattedTextLinks();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'Read the <docs-link>documentation</docs-link> or visit <github-link>GitHub</github-link>.',
|
||||
spanBuilder: (tag) => FormattedSpan(
|
||||
onTap: switch (tag) {
|
||||
'docs-link' =>
|
||||
() => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))),
|
||||
'github-link' =>
|
||||
() => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewFormattedTextMixed extends StatelessWidget {
|
||||
const _PreviewFormattedTextMixed();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'You can use <b>bold text</b> and <link>links</link> together.',
|
||||
spanBuilder: (tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
_ => FormattedSpan(
|
||||
onTap: () =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Link clicked!'))),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/icon_button.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'IconButton', name: 'Variants')
|
||||
Widget previewIconButtonVariants() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichIconButton(icon: Icons.add, onPressed: _previewNoop),
|
||||
ImmichIconButton(icon: Icons.edit, onPressed: _previewNoop, variant: ImmichVariant.ghost),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'IconButton', name: 'Colors')
|
||||
Widget previewIconButtonColors() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichIconButton(icon: Icons.favorite, onPressed: _previewNoop),
|
||||
ImmichIconButton(icon: Icons.delete, onPressed: _previewNoop, color: ImmichColor.secondary),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'IconButton', name: 'Disabled')
|
||||
Widget previewIconButtonDisabled() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichIconButton(icon: Icons.settings, onPressed: _previewNoop, disabled: true),
|
||||
ImmichIconButton(
|
||||
icon: Icons.settings,
|
||||
onPressed: _previewNoop,
|
||||
disabled: true,
|
||||
variant: ImmichVariant.ghost,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/password_input.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'PasswordInput', name: 'With Validator')
|
||||
Widget previewPasswordInput() => ImmichPasswordInput(
|
||||
label: 'Password',
|
||||
hintText: 'Enter your password',
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/text_button.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
import 'package:immich_ui/src/types.dart';
|
||||
|
||||
void _previewNoop() {}
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Variants')
|
||||
Widget previewTextButtonVariants() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Filled', expanded: false),
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Ghost', variant: ImmichVariant.ghost, expanded: false),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Colors')
|
||||
Widget previewTextButtonColors() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Primary', expanded: false),
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Secondary', color: ImmichColor.secondary, expanded: false),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'With Icons')
|
||||
Widget previewTextButtonWithIcons() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'With Icon', icon: Icons.add, expanded: false),
|
||||
ImmichTextButton(
|
||||
onPressed: _previewNoop,
|
||||
labelText: 'Download',
|
||||
icon: Icons.download,
|
||||
variant: ImmichVariant.ghost,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Loading')
|
||||
Widget previewTextButtonLoading() => const _PreviewLoadingDemo();
|
||||
|
||||
@ImmichPreview(group: 'TextButton', name: 'Disabled')
|
||||
Widget previewTextButtonDisabled() => const Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(onPressed: _previewNoop, labelText: 'Disabled', disabled: true, expanded: false),
|
||||
ImmichTextButton(
|
||||
onPressed: _previewNoop,
|
||||
labelText: 'Disabled Ghost',
|
||||
variant: ImmichVariant.ghost,
|
||||
disabled: true,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
class _PreviewLoadingDemo extends StatefulWidget {
|
||||
const _PreviewLoadingDemo();
|
||||
|
||||
@override
|
||||
State<_PreviewLoadingDemo> createState() => _PreviewLoadingDemoState();
|
||||
}
|
||||
|
||||
class _PreviewLoadingDemoState extends State<_PreviewLoadingDemo> {
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichTextButton(
|
||||
onPressed: () async {
|
||||
setState(() => _isLoading = true);
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
labelText: _isLoading ? 'Loading...' : 'Click Me',
|
||||
loading: _isLoading,
|
||||
expanded: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/text_input.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'TextInput', name: 'Basic')
|
||||
Widget previewTextInputBasic() => const _PreviewTextInputBasic();
|
||||
|
||||
@ImmichPreview(group: 'TextInput', name: 'With Validator')
|
||||
Widget previewTextInputValidator() => const _PreviewTextInputValidator();
|
||||
|
||||
class _PreviewTextInputBasic extends StatefulWidget {
|
||||
const _PreviewTextInputBasic();
|
||||
|
||||
@override
|
||||
State<_PreviewTextInputBasic> createState() => _PreviewTextInputBasicState();
|
||||
}
|
||||
|
||||
class _PreviewTextInputBasicState extends State<_PreviewTextInputBasic> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichTextInput(
|
||||
label: 'Email',
|
||||
hintText: 'Enter your email',
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewTextInputValidator extends StatefulWidget {
|
||||
const _PreviewTextInputValidator();
|
||||
|
||||
@override
|
||||
State<_PreviewTextInputValidator> createState() => _PreviewTextInputValidatorState();
|
||||
}
|
||||
|
||||
class _PreviewTextInputValidatorState extends State<_PreviewTextInputValidator> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichTextInput(
|
||||
label: 'Username',
|
||||
controller: _controller,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Username is required';
|
||||
}
|
||||
if (value.length < 3) {
|
||||
return 'Username must be at least 3 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/src/components/url_input.dart';
|
||||
import 'package:immich_ui/src/previews.dart';
|
||||
|
||||
@ImmichPreview(group: 'URLInput', name: 'Basic')
|
||||
Widget previewUrlInput() => const _PreviewUrlInput();
|
||||
|
||||
class _PreviewUrlInput extends StatefulWidget {
|
||||
const _PreviewUrlInput();
|
||||
|
||||
@override
|
||||
State<_PreviewUrlInput> createState() => _PreviewUrlInputState();
|
||||
}
|
||||
|
||||
class _PreviewUrlInputState extends State<_PreviewUrlInput> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichURLInput(label: 'Server URL', hintText: 'https://demo.immich.com', controller: _controller);
|
||||
}
|
||||
}
|
||||
@@ -185,5 +185,5 @@ packages:
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_ui
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Build artifacts
|
||||
build/
|
||||
|
||||
# Test cache and generated files
|
||||
.dart_tool/
|
||||
.packages
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
|
||||
# IDE-specific files
|
||||
.vscode/
|
||||
@@ -1,30 +0,0 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: web
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -1 +0,0 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
@@ -1,339 +0,0 @@
|
||||
{
|
||||
"name": "GitHub Dark",
|
||||
"settings": [
|
||||
{
|
||||
"settings": {
|
||||
"foreground": "#e1e4e8",
|
||||
"background": "#24292e"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"comment",
|
||||
"punctuation.definition.comment",
|
||||
"string.comment"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#6a737d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"constant",
|
||||
"entity.name.constant",
|
||||
"variable.other.constant",
|
||||
"variable.other.enummember",
|
||||
"variable.language"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"entity",
|
||||
"entity.name"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#b392f0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "variable.parameter.function",
|
||||
"settings": {
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "entity.name.tag",
|
||||
"settings": {
|
||||
"foreground": "#85e89d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "keyword",
|
||||
"settings": {
|
||||
"foreground": "#f97583"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"storage",
|
||||
"storage.type"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#f97583"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"storage.modifier.package",
|
||||
"storage.modifier.import",
|
||||
"storage.type.java"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"string",
|
||||
"punctuation.definition.string",
|
||||
"string punctuation.section.embedded source"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#9ecbff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "support",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.property-name",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "variable",
|
||||
"settings": {
|
||||
"foreground": "#ffab70"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "variable.other",
|
||||
"settings": {
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "invalid.broken",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "invalid.deprecated",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "invalid.illegal",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "invalid.unimplemented",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "message.error",
|
||||
"settings": {
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "string variable",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"source.regexp",
|
||||
"string.regexp"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#dbedff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"string.regexp.character-class",
|
||||
"string.regexp constant.character.escape",
|
||||
"string.regexp source.ruby.embedded",
|
||||
"string.regexp string.regexp.arbitrary-repitition"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#dbedff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "string.regexp constant.character.escape",
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#85e89d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "support.constant",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "support.variable",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.module-reference",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "punctuation.definition.list.begin.markdown",
|
||||
"settings": {
|
||||
"foreground": "#ffab70"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.heading",
|
||||
"markup.heading entity.name"
|
||||
],
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.quote",
|
||||
"settings": {
|
||||
"foreground": "#85e89d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.italic",
|
||||
"settings": {
|
||||
"fontStyle": "italic",
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.bold",
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#e1e4e8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.underline",
|
||||
"settings": {
|
||||
"fontStyle": "underline"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "markup.inline.raw",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.deleted",
|
||||
"meta.diff.header.from-file",
|
||||
"punctuation.definition.deleted"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.inserted",
|
||||
"meta.diff.header.to-file",
|
||||
"punctuation.definition.inserted"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#85e89d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.changed",
|
||||
"punctuation.definition.changed"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#ffab70"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"markup.ignored",
|
||||
"markup.untracked"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#2f363d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.diff.range",
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#b392f0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.diff.header",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.separator",
|
||||
"settings": {
|
||||
"fontStyle": "bold",
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "meta.output",
|
||||
"settings": {
|
||||
"foreground": "#79b8ff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"brackethighlighter.tag",
|
||||
"brackethighlighter.curly",
|
||||
"brackethighlighter.round",
|
||||
"brackethighlighter.square",
|
||||
"brackethighlighter.angle",
|
||||
"brackethighlighter.quote"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "#d1d5da"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "brackethighlighter.unmatched",
|
||||
"settings": {
|
||||
"foreground": "#fdaeb7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"constant.other.reference.link",
|
||||
"string.other.link"
|
||||
],
|
||||
"settings": {
|
||||
"fontStyle": "underline",
|
||||
"foreground": "#dbedff"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
// Light theme colors
|
||||
static const _primary500 = Color(0xFF4250AF);
|
||||
static const _primary100 = Color(0xFFD4D6F0);
|
||||
static const _primary900 = Color(0xFF181E44);
|
||||
static const _danger500 = Color(0xFFE53E3E);
|
||||
static const _light50 = Color(0xFFFAFAFA);
|
||||
static const _light300 = Color(0xFFD4D4D4);
|
||||
static const _light500 = Color(0xFF737373);
|
||||
|
||||
// Dark theme colors
|
||||
static const _darkPrimary500 = Color(0xFFACCBFA);
|
||||
static const _darkPrimary300 = Color(0xFF616D94);
|
||||
static const _darkDanger500 = Color(0xFFE88080);
|
||||
static const _darkLight50 = Color(0xFF0A0A0A);
|
||||
static const _darkLight100 = Color(0xFF171717);
|
||||
static const _darkLight200 = Color(0xFF262626);
|
||||
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _primary500,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: _primary100,
|
||||
onPrimaryContainer: _primary900,
|
||||
secondary: _light500,
|
||||
onSecondary: Colors.white,
|
||||
error: _danger500,
|
||||
onError: Colors.white,
|
||||
surface: _light50,
|
||||
onSurface: Color(0xFF1A1C1E),
|
||||
surfaceContainerHighest: Color(0xFFE3E4E8),
|
||||
outline: Color(0xFFD1D3D9),
|
||||
outlineVariant: _light300,
|
||||
),
|
||||
useMaterial3: true,
|
||||
fontFamily: 'GoogleSans',
|
||||
scaffoldBackgroundColor: _light50,
|
||||
cardTheme: const CardThemeData(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: _light300, width: 1),
|
||||
),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: Color(0xFF1A1C1E),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _darkPrimary500,
|
||||
onPrimary: Color(0xFF0F1433),
|
||||
primaryContainer: _darkPrimary300,
|
||||
onPrimaryContainer: _primary100,
|
||||
secondary: Color(0xFFC4C6D0),
|
||||
onSecondary: Color(0xFF2E3042),
|
||||
error: _darkDanger500,
|
||||
onError: Color(0xFF0F1433),
|
||||
surface: _darkLight50,
|
||||
onSurface: Color(0xFFE3E3E6),
|
||||
surfaceContainerHighest: _darkLight200,
|
||||
outline: Color(0xFF8E9099),
|
||||
outlineVariant: Color(0xFF43464F),
|
||||
),
|
||||
useMaterial3: true,
|
||||
fontFamily: 'GoogleSans',
|
||||
scaffoldBackgroundColor: _darkLight50,
|
||||
cardTheme: const CardThemeData(
|
||||
elevation: 0,
|
||||
color: _darkLight100,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
side: BorderSide(color: _darkLight200, width: 1),
|
||||
),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
backgroundColor: _darkLight50,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: Color(0xFFE3E3E6),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
const String appTitle = '@immich/ui';
|
||||
|
||||
class LayoutConstants {
|
||||
static const double sidebarWidth = 220.0;
|
||||
|
||||
static const double gridSpacing = 16.0;
|
||||
static const double gridAspectRatio = 2.5;
|
||||
|
||||
static const double borderRadiusSmall = 6.0;
|
||||
static const double borderRadiusMedium = 8.0;
|
||||
static const double borderRadiusLarge = 12.0;
|
||||
|
||||
static const double iconSizeSmall = 16.0;
|
||||
static const double iconSizeMedium = 18.0;
|
||||
static const double iconSizeLarge = 20.0;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/app_theme.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:showcase/router.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await initializeCodeHighlighter();
|
||||
runApp(const ShowcaseApp());
|
||||
}
|
||||
|
||||
class ShowcaseApp extends StatefulWidget {
|
||||
const ShowcaseApp({super.key});
|
||||
|
||||
@override
|
||||
State<ShowcaseApp> createState() => _ShowcaseAppState();
|
||||
}
|
||||
|
||||
class _ShowcaseAppState extends State<ShowcaseApp> {
|
||||
ThemeMode _themeMode = ThemeMode.light;
|
||||
late final GoRouter _router;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_router = AppRouter.createRouter(_toggleTheme);
|
||||
}
|
||||
|
||||
void _toggleTheme() {
|
||||
setState(() {
|
||||
_themeMode = _themeMode == ThemeMode.light
|
||||
? ThemeMode.dark
|
||||
: ThemeMode.light;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: appTitle,
|
||||
themeMode: _themeMode,
|
||||
routerConfig: _router,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (context, child) => ImmichThemeProvider(
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class CloseButtonPage extends StatelessWidget {
|
||||
const CloseButtonPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.closeButton.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichCloseButton',
|
||||
subtitle: 'Pre-configured close button for dialogs and sheets.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Default & Custom',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichCloseButton(onPressed: () {}),
|
||||
ImmichCloseButton(
|
||||
variant: ImmichVariant.filled,
|
||||
onPressed: () {},
|
||||
),
|
||||
ImmichCloseButton(
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextBoldText extends StatelessWidget {
|
||||
const FormattedTextBoldText({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText('This is <b>bold text</b>.');
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextLinks extends StatelessWidget {
|
||||
const FormattedTextLinks({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'Read the <docs-link>documentation</docs-link> or visit <github-link>GitHub</github-link>.',
|
||||
spanBuilder: (tag) => FormattedSpan(
|
||||
onTap: switch (tag) {
|
||||
'docs-link' => () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))),
|
||||
'github-link' => () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextMixedContent extends StatelessWidget {
|
||||
const FormattedTextMixedContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'You can use <b>bold text</b> and <link>links</link> together.',
|
||||
spanBuilder: (tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
_ => FormattedSpan(
|
||||
onTap: () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Link clicked!'))),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class FormPage extends StatefulWidget {
|
||||
const FormPage({super.key});
|
||||
|
||||
@override
|
||||
State<FormPage> createState() => _FormPageState();
|
||||
}
|
||||
|
||||
class _FormPageState extends State<FormPage> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
String _result = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.form.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichForm',
|
||||
subtitle:
|
||||
'Form container with built-in validation and submit handling.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Login Form',
|
||||
preview: Column(
|
||||
children: [
|
||||
ImmichForm(
|
||||
submitText: 'Login',
|
||||
submitIcon: Icons.login,
|
||||
onSubmit: () async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
setState(() {
|
||||
_result = 'Form submitted!';
|
||||
});
|
||||
},
|
||||
builder: (context, form) => Column(
|
||||
spacing: 10,
|
||||
children: [
|
||||
ImmichTextInput(
|
||||
label: 'Email',
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) =>
|
||||
value?.isEmpty ?? true ? 'Required' : null,
|
||||
),
|
||||
ImmichPasswordInput(
|
||||
label: 'Password',
|
||||
controller: _passwordController,
|
||||
validator: (value) =>
|
||||
value?.isEmpty ?? true ? 'Required' : null,
|
||||
onSubmit: (_) => form.submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_result.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_result, style: const TextStyle(color: Colors.green)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_bold_text.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_links.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_mixed_tags.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class FormattedTextPage extends StatelessWidget {
|
||||
const FormattedTextPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.formattedText.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichFormattedText',
|
||||
subtitle: 'Render text with HTML formatting (bold, links).',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Bold Text',
|
||||
preview: const FormattedTextBoldText(),
|
||||
code: 'formatted_text_bold_text.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Links',
|
||||
preview: const FormattedTextLinks(),
|
||||
code: 'formatted_text_links.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Mixed Content',
|
||||
preview: const FormattedTextMixedContent(),
|
||||
code: 'formatted_text_mixed_tags.dart',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class IconButtonPage extends StatelessWidget {
|
||||
const IconButtonPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.iconButton.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichIconButton',
|
||||
subtitle: 'Icon-only button with customizable styling.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Variants & Colors',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichIconButton(
|
||||
icon: Icons.add,
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.filled,
|
||||
),
|
||||
ImmichIconButton(
|
||||
icon: Icons.edit,
|
||||
onPressed: () {},
|
||||
variant: ImmichVariant.ghost,
|
||||
),
|
||||
ImmichIconButton(
|
||||
icon: Icons.delete,
|
||||
onPressed: () {},
|
||||
color: ImmichColor.secondary,
|
||||
),
|
||||
ImmichIconButton(
|
||||
icon: Icons.settings,
|
||||
onPressed: () {},
|
||||
disabled: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class PasswordInputPage extends StatelessWidget {
|
||||
const PasswordInputPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.passwordInput.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichPasswordInput',
|
||||
subtitle: 'Password field with visibility toggle.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Password Input',
|
||||
preview: ImmichPasswordInput(
|
||||
label: 'Password',
|
||||
hintText: 'Enter your password',
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class TextButtonPage extends StatefulWidget {
|
||||
const TextButtonPage({super.key});
|
||||
|
||||
@override
|
||||
State<TextButtonPage> createState() => _TextButtonPageState();
|
||||
}
|
||||
|
||||
class _TextButtonPageState extends State<TextButtonPage> {
|
||||
bool _isLoading = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.textButton.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichTextButton',
|
||||
subtitle:
|
||||
'A versatile button component with multiple variants and color options.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Variants',
|
||||
description:
|
||||
'Filled and ghost variants for different visual hierarchy',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Filled',
|
||||
variant: ImmichVariant.filled,
|
||||
expanded: false,
|
||||
),
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Ghost',
|
||||
variant: ImmichVariant.ghost,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Colors',
|
||||
description: 'Primary and secondary color options',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Primary',
|
||||
color: ImmichColor.primary,
|
||||
expanded: false,
|
||||
),
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Secondary',
|
||||
color: ImmichColor.secondary,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'With Icons',
|
||||
description: 'Add leading icons',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'With Icon',
|
||||
icon: Icons.add,
|
||||
expanded: false,
|
||||
),
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Download',
|
||||
icon: Icons.download,
|
||||
variant: ImmichVariant.ghost,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Loading State',
|
||||
description: 'Shows loading indicator during async operations',
|
||||
preview: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () async {
|
||||
setState(() => _isLoading = true);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
},
|
||||
labelText: _isLoading ? 'Loading...' : 'Click Me',
|
||||
loading: _isLoading,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Disabled State',
|
||||
description: 'Buttons can be disabled',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Disabled',
|
||||
disabled: true,
|
||||
expanded: false,
|
||||
),
|
||||
ImmichTextButton(
|
||||
onPressed: () {},
|
||||
labelText: 'Disabled Ghost',
|
||||
variant: ImmichVariant.ghost,
|
||||
disabled: true,
|
||||
expanded: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class TextInputPage extends StatefulWidget {
|
||||
const TextInputPage({super.key});
|
||||
|
||||
@override
|
||||
State<TextInputPage> createState() => _TextInputPageState();
|
||||
}
|
||||
|
||||
class _TextInputPageState extends State<TextInputPage> {
|
||||
final _controller1 = TextEditingController();
|
||||
final _controller2 = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.textInput.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichTextInput',
|
||||
subtitle: 'Text field with validation support.',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Basic Usage',
|
||||
preview: Column(
|
||||
children: [
|
||||
ImmichTextInput(
|
||||
label: 'Email',
|
||||
hintText: 'Enter your email',
|
||||
controller: _controller1,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ImmichTextInput(
|
||||
label: 'Username',
|
||||
controller: _controller2,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Username is required';
|
||||
}
|
||||
if (value.length < 3) {
|
||||
return 'Username must be at least 3 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller1.dispose();
|
||||
_controller2.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class ConstantsPage extends StatefulWidget {
|
||||
const ConstantsPage({super.key});
|
||||
|
||||
@override
|
||||
State<ConstantsPage> createState() => _ConstantsPageState();
|
||||
}
|
||||
|
||||
class _ConstantsPageState extends State<ConstantsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.constants.name,
|
||||
child: ComponentExamples(
|
||||
title: 'Constants',
|
||||
subtitle: 'Consistent spacing, sizing, and styling constants.',
|
||||
expand: true,
|
||||
examples: [
|
||||
const ExampleCard(
|
||||
title: 'Spacing',
|
||||
description: 'ImmichSpacing (4.0 → 48.0)',
|
||||
preview: Column(
|
||||
children: [
|
||||
_SpacingBox(label: 'xs', size: ImmichSpacing.xs),
|
||||
_SpacingBox(label: 'sm', size: ImmichSpacing.sm),
|
||||
_SpacingBox(label: 'md', size: ImmichSpacing.md),
|
||||
_SpacingBox(label: 'lg', size: ImmichSpacing.lg),
|
||||
_SpacingBox(label: 'xl', size: ImmichSpacing.xl),
|
||||
_SpacingBox(label: 'xxl', size: ImmichSpacing.xxl),
|
||||
_SpacingBox(label: 'xxxl', size: ImmichSpacing.xxxl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Border Radius',
|
||||
description: 'ImmichRadius (0.0 → 24.0)',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_RadiusBox(label: 'none', radius: ImmichRadius.none),
|
||||
_RadiusBox(label: 'xs', radius: ImmichRadius.xs),
|
||||
_RadiusBox(label: 'sm', radius: ImmichRadius.sm),
|
||||
_RadiusBox(label: 'md', radius: ImmichRadius.md),
|
||||
_RadiusBox(label: 'lg', radius: ImmichRadius.lg),
|
||||
_RadiusBox(label: 'xl', radius: ImmichRadius.xl),
|
||||
_RadiusBox(label: 'xxl', radius: ImmichRadius.xxl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Icon Sizes',
|
||||
description: 'ImmichIconSize (16.0 → 48.0)',
|
||||
preview: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.start,
|
||||
children: [
|
||||
_IconSizeBox(label: 'xs', size: ImmichIconSize.xs),
|
||||
_IconSizeBox(label: 'sm', size: ImmichIconSize.sm),
|
||||
_IconSizeBox(label: 'md', size: ImmichIconSize.md),
|
||||
_IconSizeBox(label: 'lg', size: ImmichIconSize.lg),
|
||||
_IconSizeBox(label: 'xl', size: ImmichIconSize.xl),
|
||||
_IconSizeBox(label: 'xxl', size: ImmichIconSize.xxl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Text Sizes',
|
||||
description: 'ImmichTextSize (10.0 → 60.0)',
|
||||
preview: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Caption',
|
||||
style: TextStyle(fontSize: ImmichTextSize.caption),
|
||||
),
|
||||
Text('Label', style: TextStyle(fontSize: ImmichTextSize.label)),
|
||||
Text('Body', style: TextStyle(fontSize: ImmichTextSize.body)),
|
||||
Text('H6', style: TextStyle(fontSize: ImmichTextSize.h6)),
|
||||
Text('H5', style: TextStyle(fontSize: ImmichTextSize.h5)),
|
||||
Text('H4', style: TextStyle(fontSize: ImmichTextSize.h4)),
|
||||
Text('H3', style: TextStyle(fontSize: ImmichTextSize.h3)),
|
||||
Text('H2', style: TextStyle(fontSize: ImmichTextSize.h2)),
|
||||
Text('H1', style: TextStyle(fontSize: ImmichTextSize.h1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Elevation',
|
||||
description: 'ImmichElevation (0.0 → 16.0)',
|
||||
preview: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_ElevationBox(label: 'none', elevation: ImmichElevation.none),
|
||||
_ElevationBox(label: 'xs', elevation: ImmichElevation.xs),
|
||||
_ElevationBox(label: 'sm', elevation: ImmichElevation.sm),
|
||||
_ElevationBox(label: 'md', elevation: ImmichElevation.md),
|
||||
_ElevationBox(label: 'lg', elevation: ImmichElevation.lg),
|
||||
_ElevationBox(label: 'xl', elevation: ImmichElevation.xl),
|
||||
_ElevationBox(label: 'xxl', elevation: ImmichElevation.xxl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Border Width',
|
||||
description: 'ImmichBorderWidth (0.5 → 4.0)',
|
||||
preview: Column(
|
||||
children: [
|
||||
_BorderBox(
|
||||
label: 'hairline',
|
||||
borderWidth: ImmichBorderWidth.hairline,
|
||||
),
|
||||
_BorderBox(label: 'base', borderWidth: ImmichBorderWidth.base),
|
||||
_BorderBox(label: 'md', borderWidth: ImmichBorderWidth.md),
|
||||
_BorderBox(label: 'lg', borderWidth: ImmichBorderWidth.lg),
|
||||
_BorderBox(label: 'xl', borderWidth: ImmichBorderWidth.xl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ExampleCard(
|
||||
title: 'Animation Durations',
|
||||
description: 'ImmichDuration (100ms → 700ms)',
|
||||
preview: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
_AnimatedDurationBox(
|
||||
label: 'Extra Fast',
|
||||
duration: ImmichDuration.extraFast,
|
||||
),
|
||||
_AnimatedDurationBox(
|
||||
label: 'Fast',
|
||||
duration: ImmichDuration.fast,
|
||||
),
|
||||
_AnimatedDurationBox(
|
||||
label: 'Normal',
|
||||
duration: ImmichDuration.normal,
|
||||
),
|
||||
_AnimatedDurationBox(
|
||||
label: 'Slow',
|
||||
duration: ImmichDuration.slow,
|
||||
),
|
||||
_AnimatedDurationBox(
|
||||
label: 'Extra Slow',
|
||||
duration: ImmichDuration.extraSlow,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpacingBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double size;
|
||||
|
||||
const _SpacingBox({required this.label, required this.size});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontFamily: 'GoogleSansCode'),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: size,
|
||||
height: 24,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${size.toStringAsFixed(1)}px'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RadiusBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double radius;
|
||||
|
||||
const _RadiusBox({required this.label, required this.radius});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconSizeBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double size;
|
||||
|
||||
const _IconSizeBox({required this.label, required this.size});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(Icons.palette_rounded, size: size),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 12)),
|
||||
Text(
|
||||
'${size.toStringAsFixed(0)}px',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ElevationBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double elevation;
|
||||
|
||||
const _ElevationBox({required this.label, required this.elevation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Material(
|
||||
elevation: elevation,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
alignment: Alignment.center,
|
||||
child: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
elevation.toStringAsFixed(1),
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BorderBox extends StatelessWidget {
|
||||
final String label;
|
||||
final double borderWidth;
|
||||
|
||||
const _BorderBox({required this.label, required this.borderWidth});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontFamily: 'GoogleSansCode'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: borderWidth,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${borderWidth.toStringAsFixed(1)}px'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedDurationBox extends StatefulWidget {
|
||||
final String label;
|
||||
final Duration duration;
|
||||
|
||||
const _AnimatedDurationBox({required this.label, required this.duration});
|
||||
|
||||
@override
|
||||
State<_AnimatedDurationBox> createState() => _AnimatedDurationBoxState();
|
||||
}
|
||||
|
||||
class _AnimatedDurationBoxState extends State<_AnimatedDurationBox> {
|
||||
bool _atEnd = false;
|
||||
bool _isAnimating = false;
|
||||
|
||||
void _playAnimation() async {
|
||||
if (_isAnimating) return;
|
||||
setState(() => _isAnimating = true);
|
||||
setState(() => _atEnd = true);
|
||||
await Future.delayed(widget.duration);
|
||||
if (!mounted) return;
|
||||
setState(() => _atEnd = false);
|
||||
await Future.delayed(widget.duration);
|
||||
if (!mounted) return;
|
||||
setState(() => _isAnimating = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: const TextStyle(fontFamily: 'GoogleSansCode', fontSize: 12),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: AnimatedAlign(
|
||||
duration: widget.duration,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: _atEnd ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${widget.duration.inMilliseconds}ms',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: _isAnimating ? null : _playAnimation,
|
||||
icon: Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: _isAnimating ? colorScheme.outline : colorScheme.primary,
|
||||
),
|
||||
iconSize: 24,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
final VoidCallback onThemeToggle;
|
||||
|
||||
const HomePage({super.key, required this.onThemeToggle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Title(
|
||||
title: appTitle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
children: [
|
||||
Text(
|
||||
appTitle,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'A collection of Flutter components that are shared across all Immich projects',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
...routesByCategory.entries.map((entry) {
|
||||
if (entry.key == AppRouteCategory.root) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.key.displayName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: LayoutConstants.gridSpacing,
|
||||
mainAxisSpacing: LayoutConstants.gridSpacing,
|
||||
childAspectRatio: LayoutConstants.gridAspectRatio,
|
||||
),
|
||||
itemCount: entry.value.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _ComponentCard(route: entry.value[index]);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComponentCard extends StatelessWidget {
|
||||
final AppRoute route;
|
||||
|
||||
const _ComponentCard({required this.route});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => context.go(route.path),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(LayoutConstants.borderRadiusLarge)),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(route.icon, size: 32, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
route.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
route.description,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant, height: 1.4),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:showcase/pages/components/close_button_page.dart';
|
||||
import 'package:showcase/pages/components/form_page.dart';
|
||||
import 'package:showcase/pages/components/formatted_text_page.dart';
|
||||
import 'package:showcase/pages/components/icon_button_page.dart';
|
||||
import 'package:showcase/pages/components/password_input_page.dart';
|
||||
import 'package:showcase/pages/components/text_button_page.dart';
|
||||
import 'package:showcase/pages/components/text_input_page.dart';
|
||||
import 'package:showcase/pages/design_system/constants_page.dart';
|
||||
import 'package:showcase/pages/home_page.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/shell_layout.dart';
|
||||
|
||||
class AppRouter {
|
||||
static GoRouter createRouter(VoidCallback onThemeToggle) {
|
||||
return GoRouter(
|
||||
initialLocation: AppRoute.home.path,
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) =>
|
||||
ShellLayout(onThemeToggle: onThemeToggle, child: child),
|
||||
routes: AppRoute.values
|
||||
.map(
|
||||
(route) => GoRoute(
|
||||
path: route.path,
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: switch (route) {
|
||||
AppRoute.home => HomePage(onThemeToggle: onThemeToggle),
|
||||
AppRoute.textButton => const TextButtonPage(),
|
||||
AppRoute.iconButton => const IconButtonPage(),
|
||||
AppRoute.closeButton => const CloseButtonPage(),
|
||||
AppRoute.textInput => const TextInputPage(),
|
||||
AppRoute.passwordInput => const PasswordInputPage(),
|
||||
AppRoute.form => const FormPage(),
|
||||
AppRoute.formattedText => const FormattedTextPage(),
|
||||
AppRoute.constants => const ConstantsPage(),
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum AppRouteCategory {
|
||||
root(''),
|
||||
forms('Forms'),
|
||||
buttons('Buttons'),
|
||||
designSystem('Design System');
|
||||
|
||||
final String displayName;
|
||||
const AppRouteCategory(this.displayName);
|
||||
}
|
||||
|
||||
enum AppRoute {
|
||||
home(
|
||||
name: 'Home',
|
||||
description: 'Home page',
|
||||
path: '/',
|
||||
category: AppRouteCategory.root,
|
||||
icon: Icons.home_outlined,
|
||||
),
|
||||
textButton(
|
||||
name: 'Text Button',
|
||||
description: 'Versatile button with filled and ghost variants',
|
||||
path: '/text-button',
|
||||
category: AppRouteCategory.buttons,
|
||||
icon: Icons.smart_button_rounded,
|
||||
),
|
||||
iconButton(
|
||||
name: 'Icon Button',
|
||||
description: 'Icon-only button with customizable styling',
|
||||
path: '/icon-button',
|
||||
category: AppRouteCategory.buttons,
|
||||
icon: Icons.radio_button_unchecked_rounded,
|
||||
),
|
||||
closeButton(
|
||||
name: 'Close Button',
|
||||
description: 'Pre-configured close button for dialogs',
|
||||
path: '/close-button',
|
||||
category: AppRouteCategory.buttons,
|
||||
icon: Icons.close_rounded,
|
||||
),
|
||||
textInput(
|
||||
name: 'Text Input',
|
||||
description: 'Text field with validation support',
|
||||
path: '/text-input',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.text_fields_outlined,
|
||||
),
|
||||
passwordInput(
|
||||
name: 'Password Input',
|
||||
description: 'Password field with visibility toggle',
|
||||
path: '/password-input',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.password_outlined,
|
||||
),
|
||||
form(
|
||||
name: 'Form',
|
||||
description: 'Form container with built-in validation',
|
||||
path: '/form',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.description_outlined,
|
||||
),
|
||||
formattedText(
|
||||
name: 'Formatted Text',
|
||||
description: 'Render text with HTML formatting',
|
||||
path: '/formatted-text',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.code_rounded,
|
||||
),
|
||||
constants(
|
||||
name: 'Constants',
|
||||
description: 'Spacing, colors, typography, and more',
|
||||
path: '/constants',
|
||||
category: AppRouteCategory.designSystem,
|
||||
icon: Icons.palette_outlined,
|
||||
);
|
||||
|
||||
final String name;
|
||||
final String description;
|
||||
final String path;
|
||||
final AppRouteCategory category;
|
||||
final IconData icon;
|
||||
|
||||
const AppRoute({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.path,
|
||||
required this.category,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
final routesByCategory = AppRoute.values
|
||||
.fold<Map<AppRouteCategory, List<AppRoute>>>({}, (map, route) {
|
||||
map.putIfAbsent(route.category, () => []).add(route);
|
||||
return map;
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ComponentExamples extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final List<Widget> examples;
|
||||
final bool expand;
|
||||
|
||||
const ComponentExamples({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.examples,
|
||||
this.expand = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 24, 24, 24),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _PageHeader(title: title, subtitle: subtitle),
|
||||
),
|
||||
const SliverPadding(padding: EdgeInsets.only(top: 24)),
|
||||
if (expand)
|
||||
SliverList.builder(
|
||||
itemCount: examples.length,
|
||||
itemBuilder: (context, index) => examples[index],
|
||||
)
|
||||
else
|
||||
SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SliverList.builder(
|
||||
itemCount: examples.length,
|
||||
itemBuilder: (context, index) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: constraints.crossAxisExtent * 0.6,
|
||||
maxWidth: constraints.crossAxisExtent,
|
||||
),
|
||||
child: IntrinsicWidth(child: examples[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PageHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
const _PageHeader({required this.title, this.subtitle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:syntax_highlight/syntax_highlight.dart';
|
||||
|
||||
late final Highlighter _codeHighlighter;
|
||||
|
||||
Future<void> initializeCodeHighlighter() async {
|
||||
await Highlighter.initialize(['dart']);
|
||||
final darkTheme = await HighlighterTheme.loadFromAssets([
|
||||
'assets/themes/github_dark.json',
|
||||
], const TextStyle(color: Color(0xFFe1e4e8)));
|
||||
|
||||
_codeHighlighter = Highlighter(language: 'dart', theme: darkTheme);
|
||||
}
|
||||
|
||||
class ExampleCard extends StatefulWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
final Widget preview;
|
||||
final String? code;
|
||||
|
||||
const ExampleCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.preview,
|
||||
this.code,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ExampleCard> createState() => _ExampleCardState();
|
||||
}
|
||||
|
||||
class _ExampleCardState extends State<ExampleCard> {
|
||||
bool _showPreview = true;
|
||||
String? code;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.code != null) {
|
||||
rootBundle
|
||||
.loadString('lib/pages/components/examples/${widget.code!}')
|
||||
.then((value) {
|
||||
setState(() {
|
||||
code = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (widget.description != null)
|
||||
Text(
|
||||
widget.description!,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (code != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
Row(
|
||||
children: [
|
||||
_ToggleButton(
|
||||
icon: Icons.visibility_rounded,
|
||||
label: 'Preview',
|
||||
isSelected: _showPreview,
|
||||
onTap: () => setState(() => _showPreview = true),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ToggleButton(
|
||||
icon: Icons.code_rounded,
|
||||
label: 'Code',
|
||||
isSelected: !_showPreview,
|
||||
onTap: () => setState(() => _showPreview = false),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (_showPreview)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(width: double.infinity, child: widget.preview),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF24292e),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(
|
||||
LayoutConstants.borderRadiusMedium,
|
||||
),
|
||||
bottomRight: Radius.circular(
|
||||
LayoutConstants.borderRadiusMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _CodeCard(code: code!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToggleButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ToggleButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CodeCard extends StatelessWidget {
|
||||
final String code;
|
||||
|
||||
const _CodeCard({required this.code});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lines = code.split('\n');
|
||||
final lineNumberColor = Colors.white.withValues(alpha: 0.4);
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(
|
||||
lines.length,
|
||||
(index) => SizedBox(
|
||||
height: 20,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'GoogleSansCode',
|
||||
fontSize: 13,
|
||||
color: lineNumberColor,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SelectableText.rich(
|
||||
_codeHighlighter.highlight(code),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'GoogleSansCode',
|
||||
fontSize: 13,
|
||||
height: 1.54,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PageTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const PageTitle({super.key, required this.title, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Title(
|
||||
title: '$title | @immich/ui',
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:showcase/widgets/sidebar_navigation.dart';
|
||||
|
||||
class ShellLayout extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback onThemeToggle;
|
||||
|
||||
const ShellLayout({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onThemeToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset('assets/immich_logo.png', height: 32, width: 32),
|
||||
const SizedBox(width: 8),
|
||||
Image.asset(
|
||||
isDark
|
||||
? 'assets/immich-text-dark.png'
|
||||
: 'assets/immich-text-light.png',
|
||||
height: 24,
|
||||
filterQuality: FilterQuality.none,
|
||||
isAntiAlias: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
||||
size: LayoutConstants.iconSizeLarge,
|
||||
),
|
||||
onPressed: onThemeToggle,
|
||||
tooltip: 'Toggle theme',
|
||||
),
|
||||
],
|
||||
shape: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
const SidebarNavigation(),
|
||||
const VerticalDivider(),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:showcase/constants.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
|
||||
class SidebarNavigation extends StatelessWidget {
|
||||
const SidebarNavigation({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: LayoutConstants.sidebarWidth,
|
||||
decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
children: [
|
||||
...routesByCategory.entries.expand((entry) {
|
||||
final category = entry.key;
|
||||
final routes = entry.value;
|
||||
return [
|
||||
if (category != AppRouteCategory.root) _CategoryHeader(category),
|
||||
...routes.map((route) => _NavItem(route)),
|
||||
const SizedBox(height: 24),
|
||||
];
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryHeader extends StatelessWidget {
|
||||
final AppRouteCategory category;
|
||||
|
||||
const _CategoryHeader(this.category);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8),
|
||||
child: Text(
|
||||
category.displayName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem extends StatelessWidget {
|
||||
final AppRoute route;
|
||||
|
||||
const _NavItem(this.route);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentRoute = GoRouterState.of(context).uri.toString();
|
||||
final isSelected = currentRoute == route.path;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.go(route.path);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(
|
||||
LayoutConstants.borderRadiusMedium,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark
|
||||
? Colors.white.withValues(alpha: 0.1)
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.5))
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(
|
||||
LayoutConstants.borderRadiusMedium,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
route.icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
route.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.5.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.3"
|
||||
immich_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: ".."
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.0"
|
||||
irondash_engine_context:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: irondash_engine_context
|
||||
sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.5"
|
||||
irondash_message_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: irondash_message_channel
|
||||
sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
pixel_snap:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pixel_snap
|
||||
sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.5"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
super_clipboard:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: super_clipboard
|
||||
sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1"
|
||||
super_native_extensions:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: super_native_extensions
|
||||
sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1"
|
||||
syntax_highlight:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: syntax_highlight
|
||||
sha256: "4d3ba40658cadba6ba55d697f29f00b43538ebb6eb4a0ca0e895c568eaced138"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
@@ -1,47 +0,0 @@
|
||||
name: showcase
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
immich_ui:
|
||||
path: ../
|
||||
go_router: ^17.2.1
|
||||
syntax_highlight: ^0.5.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/
|
||||
- assets/themes/
|
||||
- lib/pages/components/examples/
|
||||
|
||||
fonts:
|
||||
- family: GoogleSans
|
||||
fonts:
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-Regular.ttf
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-Italic.ttf
|
||||
style: italic
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-Medium.ttf
|
||||
weight: 500
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-SemiBold.ttf
|
||||
weight: 600
|
||||
- asset: ../../../fonts/GoogleSans/GoogleSans-Bold.ttf
|
||||
weight: 700
|
||||
- family: GoogleSansCode
|
||||
fonts:
|
||||
- asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Regular.ttf
|
||||
- asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Medium.ttf
|
||||
weight: 500
|
||||
- asset: ../../../fonts/GoogleSansCode/GoogleSansCode-SemiBold.ttf
|
||||
weight: 600
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -1,38 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="Immich UI component library showcase and documentation">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="@immich/ui">
|
||||
<link rel="apple-touch-icon" href="icons/apple-icon-180.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico"/>
|
||||
|
||||
<title>@immich/ui</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "@immich/ui Showcase",
|
||||
"short_name": "@immich/ui",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#FCFCFD",
|
||||
"theme_color": "#4250AF",
|
||||
"description": "Immich UI component library showcase and documentation",
|
||||
"orientation": "landscape",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -75,7 +75,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
@@ -92,7 +92,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
@@ -109,7 +109,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getMotionFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
|
||||
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
|
||||
@@ -130,7 +130,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
|
||||
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
|
||||
@@ -150,7 +150,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
|
||||
|
||||
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
|
||||
@@ -194,7 +194,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutWithV24.getUploadTask(assetWithCloudId);
|
||||
@@ -243,7 +243,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
|
||||
|
||||
final task = await sutAndroid.getUploadTask(assetWithCloudId);
|
||||
@@ -281,7 +281,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getAssetFile(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
|
||||
).thenAnswer((_) async => 'test.jpg');
|
||||
@@ -323,7 +323,7 @@ void main() {
|
||||
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(true);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
|
||||
when(
|
||||
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
|
||||
).thenAnswer((_) async => 'livephoto.heic');
|
||||
|
||||
@@ -1627,6 +1627,17 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Album ID",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isOwned",
|
||||
"required": false,
|
||||
@@ -1644,6 +1655,15 @@
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Album name (exact match)",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -18151,7 +18171,7 @@
|
||||
"VersionCheck",
|
||||
"OcrQueueAll",
|
||||
"Ocr",
|
||||
"WorkflowAssetCreate"
|
||||
"WorkflowAssetTrigger"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -20199,6 +20219,13 @@
|
||||
"trigger": {
|
||||
"$ref": "#/components/schemas/WorkflowTrigger",
|
||||
"description": "Workflow trigger"
|
||||
},
|
||||
"uiHints": {
|
||||
"description": "Ui hints, for example \"smart-album\"",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20206,7 +20233,8 @@
|
||||
"key",
|
||||
"steps",
|
||||
"title",
|
||||
"trigger"
|
||||
"trigger",
|
||||
"uiHints"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -26327,6 +26355,7 @@
|
||||
"description": "Plugin trigger type",
|
||||
"enum": [
|
||||
"AssetCreate",
|
||||
"AssetMetadataExtraction",
|
||||
"PersonRecognized"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
@@ -13,7 +13,7 @@
|
||||
class ApiClient {
|
||||
ApiClient({this.basePath = '/api', this.authentication,});
|
||||
|
||||
- final String basePath;
|
||||
+ String basePath;
|
||||
final Authentication? authentication;
|
||||
|
||||
var _client = Client();
|
||||
@@ -143,19 +143,19 @@
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"wasmPath": "dist/plugin.wasm",
|
||||
"templates": [
|
||||
{
|
||||
"name": "auto-archive-screenshots",
|
||||
"title": "Auto-archive screenshots",
|
||||
"name": "screenshots-smart-album",
|
||||
"title": "Archive screenshots",
|
||||
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
|
||||
"trigger": "AssetCreate",
|
||||
"steps": [
|
||||
@@ -20,19 +20,41 @@
|
||||
"caseSensitive": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetAddToAlbums",
|
||||
"config": {
|
||||
"albumIds": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetArchive",
|
||||
"config": {
|
||||
"inverse": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetAddToAlbums",
|
||||
"config": {
|
||||
"albumName": "Screenshots",
|
||||
"albumIds": []
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"uiHints": ["SmartAlbum"]
|
||||
},
|
||||
{
|
||||
"name": "missing-timezone-smart-album",
|
||||
"title": "Missing timezone",
|
||||
"description": "Automatically create an album for assets without a time zone",
|
||||
"trigger": "AssetMetadataExtraction",
|
||||
"steps": [
|
||||
{
|
||||
"method": "immich-plugin-core#assetMissingTimeZoneFilter",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetAddToAlbums",
|
||||
"config": {
|
||||
"albumName": "Missing time zone",
|
||||
"albumIds": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"uiHints": ["SmartAlbum"]
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
@@ -65,7 +87,25 @@
|
||||
},
|
||||
"required": ["pattern"]
|
||||
},
|
||||
"uiHints": ["filter"]
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "assetMissingTimeZoneFilter",
|
||||
"title": "Filter by missing time zone",
|
||||
"description": "Filter assets that have no time zone information",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inverse": {
|
||||
"type": "boolean",
|
||||
"title": "Inverse",
|
||||
"description": "Missing by default, set to true to filter assets with a time zone",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "filterFileType",
|
||||
@@ -85,7 +125,7 @@
|
||||
},
|
||||
"required": ["fileTypes"]
|
||||
},
|
||||
"uiHints": ["filter"]
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "filterPerson",
|
||||
@@ -99,7 +139,7 @@
|
||||
"array": true,
|
||||
"title": "Person IDs",
|
||||
"description": "List of person to match",
|
||||
"uiHint": "personI"
|
||||
"uiHint": "personId"
|
||||
},
|
||||
"matchAny": {
|
||||
"type": "boolean",
|
||||
@@ -110,7 +150,7 @@
|
||||
},
|
||||
"required": ["personIds"]
|
||||
},
|
||||
"uiHints": ["filter"]
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
{
|
||||
"name": "assetArchive",
|
||||
@@ -187,7 +227,13 @@
|
||||
"title": "Album IDs",
|
||||
"array": true,
|
||||
"description": "Target album IDs",
|
||||
"uiHint": "albumId"
|
||||
"uiHint": "AlbumId"
|
||||
},
|
||||
"albumName": {
|
||||
"type": "string",
|
||||
"title": "Album name",
|
||||
"array": true,
|
||||
"description": "Use an album with this name if one exists, otherwise create a new one"
|
||||
}
|
||||
},
|
||||
"required": ["albumIds"]
|
||||
@@ -272,14 +318,14 @@
|
||||
"type": "string",
|
||||
"title": "Album ID",
|
||||
"description": "Target album ID",
|
||||
"uiHint": "albumId"
|
||||
"uiHint": "AlbumId"
|
||||
},
|
||||
"albumIds": {
|
||||
"type": "string",
|
||||
"title": "Album IDs",
|
||||
"description": "Target album IDs",
|
||||
"array": true,
|
||||
"uiHint": "albumId"
|
||||
"uiHint": "AlbumId"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.0.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/plugin-sdk": "workspace:*",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.0"
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
// copy from
|
||||
// import '@immich/plugin-sdk/host-functions';
|
||||
// keep in sync with plugin-sdk/host-functions.ts';
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
albumAddAssets(ptr: PTR): I64;
|
||||
searchAlbums(ptr: PTR): I64;
|
||||
createAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbums(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
// keep in sync with manifest.json
|
||||
declare module 'main' {
|
||||
// filters
|
||||
export function assetFileFilter(): I32;
|
||||
export function assetMissingTimeZoneFilter(): I32;
|
||||
|
||||
// updates
|
||||
export function assetFavorite(): I32;
|
||||
export function assetVisibility(): I32;
|
||||
export function assetArchive(): I32;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AssetStatus, AssetVisibility, WorkflowType, wrapper } from '@immich/plugin-sdk';
|
||||
import { wrapper } from '@immich/plugin-sdk';
|
||||
import { AssetVisibility, WorkflowType } from '@immich/sdk';
|
||||
|
||||
type AssetFileFilterConfig = {
|
||||
pattern: string;
|
||||
@@ -41,6 +42,14 @@ export const assetFileFilter = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const assetMissingTimeZoneFilter = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
|
||||
const needsTimeZone = config.inverse ? true : false;
|
||||
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
||||
});
|
||||
};
|
||||
|
||||
export const assetFavorite = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
const target = config.inverse ? false : true;
|
||||
@@ -89,28 +98,35 @@ export const assetLock = () => {
|
||||
};
|
||||
|
||||
export const assetTrash = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => ({
|
||||
changes: {
|
||||
asset: config.inverse
|
||||
? { deletedAt: null, status: AssetStatus.Active }
|
||||
: { deletedAt: new Date(), status: AssetStatus.Trashed },
|
||||
},
|
||||
}));
|
||||
// TODO use trash/untrash host functions
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
|
||||
};
|
||||
|
||||
export const assetAddToAlbums = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
|
||||
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
|
||||
const assetId = data.asset.id;
|
||||
|
||||
if (config.albumIds.length === 0) {
|
||||
// noop
|
||||
return {};
|
||||
if (!config.albumName) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const [existing] = functions.searchAlbums({ name: config.albumName });
|
||||
if (!existing) {
|
||||
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
|
||||
config.albumIds.push(created.id);
|
||||
return {};
|
||||
}
|
||||
|
||||
config.albumIds.push(existing.id);
|
||||
}
|
||||
|
||||
if (config.albumIds.length === 1) {
|
||||
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
|
||||
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
|
||||
return {};
|
||||
}
|
||||
|
||||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [data.asset.id] });
|
||||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"name": "@immich/plugin-sdk",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./host-functions": {
|
||||
@@ -11,7 +10,8 @@
|
||||
},
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -27,6 +27,7 @@
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@types/node": "^24.12.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = 'AssetCreate',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
export enum WorkflowType {
|
||||
AssetV1 = 'AssetV1',
|
||||
AssetPersonV1 = 'AssetPersonV1',
|
||||
}
|
||||
|
||||
export enum AssetType {
|
||||
Image = 'IMAGE',
|
||||
Video = 'VIDEO',
|
||||
Audio = 'AUDIO',
|
||||
Other = 'OTHER',
|
||||
}
|
||||
|
||||
export enum AssetStatus {
|
||||
Active = 'active',
|
||||
Trashed = 'trashed',
|
||||
Deleted = 'deleted',
|
||||
}
|
||||
|
||||
export enum AssetVisibility {
|
||||
Archive = 'archive',
|
||||
Timeline = 'timeline',
|
||||
|
||||
/**
|
||||
* Video part of the LivePhotos and MotionPhotos
|
||||
*/
|
||||
Hidden = 'hidden',
|
||||
Locked = 'locked',
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import {
|
||||
getAllAlbums,
|
||||
type AlbumResponseDto,
|
||||
type BulkIdResponseDto,
|
||||
type BulkIdsDto,
|
||||
type CreateAlbumDto,
|
||||
} from '@immich/sdk';
|
||||
|
||||
// keep in sync with plugin-core/src/index.d.ts';
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
albumAddAssets(ptr: PTR): I64;
|
||||
searchAlbums(ptr: PTR): I64;
|
||||
createAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbums(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
const host = Host.getFunctions();
|
||||
type HostFunctionName = keyof typeof host;
|
||||
type AlbumsToAssets = {
|
||||
assetIds: string[];
|
||||
albumIds: string[];
|
||||
};
|
||||
|
||||
type HostFunctionSuccessResult<T> = { success: true; response: T };
|
||||
type HostFunctionErrorResult = {
|
||||
success: false;
|
||||
@@ -17,35 +31,49 @@ type HostFunctionResult<T> =
|
||||
| HostFunctionSuccessResult<T>
|
||||
| HostFunctionErrorResult;
|
||||
|
||||
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
|
||||
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
|
||||
const fn = host[name];
|
||||
const handler = Memory.find(fn(pointer1.offset));
|
||||
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
|
||||
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
|
||||
|
||||
try {
|
||||
const result = JSON.parse(handler.readString()) as HostFunctionResult<R>;
|
||||
export const hostFunctions = (authToken: string) => {
|
||||
const host = Host.getFunctions();
|
||||
type HostFunctionName = keyof typeof host;
|
||||
|
||||
if (result.success) {
|
||||
return result.response;
|
||||
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
|
||||
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
|
||||
const fn = host[name];
|
||||
const handler = Memory.find(fn(pointer1.offset));
|
||||
|
||||
try {
|
||||
const result = JSON.parse(handler.readString()) as HostFunctionResult<R>;
|
||||
|
||||
if (result.success) {
|
||||
return result.response;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`,
|
||||
);
|
||||
} finally {
|
||||
handler.free();
|
||||
pointer1.free();
|
||||
}
|
||||
};
|
||||
|
||||
throw new Error(
|
||||
`Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`,
|
||||
);
|
||||
} finally {
|
||||
handler.free();
|
||||
pointer1.free();
|
||||
}
|
||||
return {
|
||||
// album
|
||||
searchAlbums: (dto: AlbumSearchDto) =>
|
||||
call<[AlbumSearchDto], AlbumResponseDto[]>('searchAlbums', authToken, [
|
||||
dto,
|
||||
]),
|
||||
createAlbum: (dto: CreateAlbumDto) =>
|
||||
call<[CreateAlbumDto], AlbumResponseDto>('createAlbum', authToken, [dto]),
|
||||
addAssetsToAlbum: (albumId: string, assetIds: string[]) =>
|
||||
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
|
||||
'addAssetsToAlbum',
|
||||
authToken,
|
||||
[albumId, { ids: assetIds }],
|
||||
),
|
||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||
};
|
||||
};
|
||||
|
||||
type AlbumsToAssets = {
|
||||
assetIds: string[];
|
||||
albumIds: string[];
|
||||
};
|
||||
|
||||
export const hostFunctions = (authToken: string) => ({
|
||||
albumAddAssets: (albumId: string, assetIds: string[]) =>
|
||||
call('albumAddAssets', authToken, [albumId, { ids: assetIds }]),
|
||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from 'src/enum.js';
|
||||
export * from 'src/host-functions.js';
|
||||
export * from 'src/sdk.js';
|
||||
export * from 'src/types.js';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { WorkflowType } from 'src/enum.js';
|
||||
import type { WorkflowType } from '@immich/sdk';
|
||||
import { hostFunctions } from 'src/host-functions.js';
|
||||
import type {
|
||||
ConfigValue,
|
||||
WorkflowEventPayload,
|
||||
WorkflowResponse,
|
||||
WorkflowStepConfig,
|
||||
} from 'src/types.js';
|
||||
|
||||
export const wrapper = <
|
||||
@@ -19,19 +20,28 @@ export const wrapper = <
|
||||
const input = Host.inputString();
|
||||
|
||||
try {
|
||||
const event = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
|
||||
// const debug = event.workflow.debug ?? false;
|
||||
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
|
||||
const event = {
|
||||
...payload,
|
||||
functions: hostFunctions(payload.workflow.authToken),
|
||||
};
|
||||
|
||||
const eventConfigBefore = JSON.stringify(event.config);
|
||||
|
||||
console.debug(
|
||||
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${JSON.stringify(event.config)}`,
|
||||
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
|
||||
);
|
||||
|
||||
const response =
|
||||
fn({ ...event, functions: hostFunctions(event.workflow.authToken) }) ??
|
||||
{};
|
||||
const response = fn(event) ?? {};
|
||||
|
||||
// if config changed, notify host
|
||||
const eventConfigAfter = JSON.stringify(event.config);
|
||||
if (!response.config && eventConfigBefore !== eventConfigAfter) {
|
||||
response.config = event.config as WorkflowStepConfig;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}`,
|
||||
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
|
||||
);
|
||||
|
||||
const output = JSON.stringify(response);
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
WorkflowTrigger,
|
||||
WorkflowType,
|
||||
} from 'src/enum.js';
|
||||
import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
|
||||
|
||||
type DeepPartial<T> = T extends Date
|
||||
? T
|
||||
@@ -21,6 +15,12 @@ export type WorkflowEventMap = {
|
||||
|
||||
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
|
||||
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = 'AssetCreate',
|
||||
AssetMetadataExtraction = 'AssetMetadataExtraction',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
export type WorkflowEventPayload<
|
||||
T extends WorkflowType = WorkflowType,
|
||||
TConfig = WorkflowStepConfig,
|
||||
@@ -48,6 +48,8 @@ export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
|
||||
changes?: WorkflowChanges<T>;
|
||||
/** data to be passed to the next workflow step */
|
||||
data?: Record<string, unknown>;
|
||||
/** update step config */
|
||||
config?: WorkflowStepConfig;
|
||||
};
|
||||
|
||||
export type WorkflowStepConfig = {
|
||||
@@ -66,24 +68,23 @@ export type AssetV1 = {
|
||||
asset: {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
type: AssetType;
|
||||
type: AssetTypeEnum;
|
||||
originalPath: string;
|
||||
fileCreatedAt: Date;
|
||||
fileModifiedAt: Date;
|
||||
fileCreatedAt: string;
|
||||
fileModifiedAt: string;
|
||||
isFavorite: boolean;
|
||||
checksum: Buffer; // sha1 checksum
|
||||
livePhotoVideoId: string | null;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
originalFileName: string;
|
||||
isOffline: boolean;
|
||||
libraryId: string | null;
|
||||
isExternal: boolean;
|
||||
deletedAt: Date | null;
|
||||
localDateTime: Date;
|
||||
deletedAt: string | null;
|
||||
localDateTime: string;
|
||||
stackId: string | null;
|
||||
duplicateId: string | null;
|
||||
status: AssetStatus;
|
||||
visibility: AssetVisibility;
|
||||
isEdited: boolean;
|
||||
exifInfo: {
|
||||
@@ -93,8 +94,8 @@ export type AssetV1 = {
|
||||
exifImageHeight: number | null;
|
||||
fileSizeInByte: number | null;
|
||||
orientation: string | null;
|
||||
dateTimeOriginal: Date | null;
|
||||
modifyDate: Date | null;
|
||||
dateTimeOriginal: string | null;
|
||||
modifyDate: string | null;
|
||||
lensModel: string | null;
|
||||
fNumber: number | null;
|
||||
focalLength: number | null;
|
||||
@@ -116,7 +117,7 @@ export type AssetV1 = {
|
||||
autoStackId: string | null;
|
||||
rating: number | null;
|
||||
tags: string[] | null;
|
||||
updatedAt: Date | null;
|
||||
updatedAt: string | null;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1535,6 +1535,8 @@ export type PluginTemplateResponseDto = {
|
||||
title: string;
|
||||
/** Workflow trigger */
|
||||
trigger: WorkflowTrigger;
|
||||
/** Ui hints, for example "smart-album" */
|
||||
uiHints: string[];
|
||||
};
|
||||
export type QueueResponseDto = {
|
||||
/** Whether the queue is paused */
|
||||
@@ -3597,18 +3599,22 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
|
||||
/**
|
||||
* List all albums
|
||||
*/
|
||||
export function getAllAlbums({ assetId, isOwned, isShared }: {
|
||||
export function getAllAlbums({ assetId, id, isOwned, isShared, name }: {
|
||||
assetId?: string;
|
||||
id?: string;
|
||||
isOwned?: boolean;
|
||||
isShared?: boolean;
|
||||
name?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AlbumResponseDto[];
|
||||
}>(`/albums${QS.query(QS.explode({
|
||||
assetId,
|
||||
id,
|
||||
isOwned,
|
||||
isShared
|
||||
isShared,
|
||||
name
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
@@ -7075,6 +7081,7 @@ export enum WorkflowType {
|
||||
}
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = "AssetCreate",
|
||||
AssetMetadataExtraction = "AssetMetadataExtraction",
|
||||
PersonRecognized = "PersonRecognized"
|
||||
}
|
||||
export enum QueueJobStatus {
|
||||
@@ -7140,7 +7147,7 @@ export enum JobName {
|
||||
VersionCheck = "VersionCheck",
|
||||
OcrQueueAll = "OcrQueueAll",
|
||||
Ocr = "Ocr",
|
||||
WorkflowAssetCreate = "WorkflowAssetCreate"
|
||||
WorkflowAssetTrigger = "WorkflowAssetTrigger"
|
||||
}
|
||||
export enum SearchSuggestionType {
|
||||
Country = "country",
|
||||
|
||||
@@ -10,7 +10,7 @@ overrides:
|
||||
sharp: ^0.34.5
|
||||
webpackbar: ^7.0.0
|
||||
|
||||
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
|
||||
packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k=
|
||||
|
||||
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
|
||||
|
||||
@@ -320,6 +320,9 @@ importers:
|
||||
'@immich/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../plugin-sdk
|
||||
'@immich/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
esbuild:
|
||||
specifier: ^0.28.0
|
||||
version: 0.28.0
|
||||
@@ -332,6 +335,9 @@ importers:
|
||||
'@extism/js-pdk':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
'@immich/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
'@types/node':
|
||||
specifier: ^24.12.4
|
||||
version: 24.12.4
|
||||
@@ -389,7 +395,7 @@ importers:
|
||||
version: 6.1.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
|
||||
'@nestjs/swagger':
|
||||
specifier: ^11.4.2
|
||||
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
|
||||
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
|
||||
'@nestjs/websockets':
|
||||
specifier: ^11.0.4
|
||||
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-socket.io@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -536,7 +542,7 @@ importers:
|
||||
version: 8.0.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
|
||||
nestjs-zod:
|
||||
specifier: ^5.3.0
|
||||
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6)
|
||||
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6)
|
||||
nodemailer:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.7
|
||||
@@ -3795,6 +3801,7 @@ packages:
|
||||
class-transformer: '*'
|
||||
class-validator: '*'
|
||||
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
'@fastify/static':
|
||||
optional: true
|
||||
@@ -16570,7 +16577,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)':
|
||||
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@microsoft/tsdoc': 0.16.0
|
||||
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -16581,6 +16588,7 @@ snapshots:
|
||||
path-to-regexp: 8.4.2
|
||||
reflect-metadata: 0.2.2
|
||||
swagger-ui-dist: 5.32.6
|
||||
typescript: 6.0.3
|
||||
|
||||
'@nestjs/testing@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)':
|
||||
dependencies:
|
||||
@@ -23229,14 +23237,14 @@ snapshots:
|
||||
'@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6):
|
||||
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6):
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
deepmerge: 4.3.1
|
||||
rxjs: 7.8.2
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
|
||||
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
|
||||
|
||||
next-tick@1.1.0: {}
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ packageExtensions:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
'@nestjs/swagger':
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
dedupePeerDependents: false
|
||||
preferWorkspacePackages: true
|
||||
injectWorkspacePackages: true
|
||||
|
||||
@@ -13,14 +13,15 @@ FROM builder AS server
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./server ./server/
|
||||
COPY ./packages/sdk ./packages/sdk/
|
||||
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
|
||||
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
|
||||
--mount=type=bind,source=package.json,target=package.json \
|
||||
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
||||
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
||||
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
||||
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \
|
||||
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
|
||||
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich --frozen-lockfile build && \
|
||||
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
|
||||
|
||||
FROM builder AS web
|
||||
|
||||
@@ -66,6 +67,7 @@ ENV MISE_DISABLE_TOOLS=flutter
|
||||
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
mise install
|
||||
|
||||
COPY ./packages/sdk ./packages/sdk/
|
||||
COPY ./packages/plugin-core ./packages/plugin-core/
|
||||
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ run = [
|
||||
[tasks.ci-medium]
|
||||
run = [
|
||||
{ task = ":install" },
|
||||
{ task = "//:plugins" },
|
||||
{ task = "//packages/plugin-core:build" },
|
||||
{ task = ":test-medium --run" },
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { WorkflowController } from 'src/controllers/workflow.controller';
|
||||
import { WorkflowTrigger } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
import request from 'supertest';
|
||||
|
||||
@@ -65,6 +65,8 @@ const UpdateAlbumSchema = z
|
||||
|
||||
const GetAlbumsSchema = z
|
||||
.object({
|
||||
id: z.uuidv4().optional().describe('Album ID'),
|
||||
name: z.string().optional().describe('Album name (exact match)'),
|
||||
isOwned: stringToBool
|
||||
.optional()
|
||||
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
|
||||
|
||||
@@ -38,6 +38,7 @@ const PluginManifestTemplateSchema = z
|
||||
description: z.string().min(1).describe('Template description'),
|
||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
||||
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
|
||||
uiHints: z.array(z.string()).optional().default([]).describe('Ui hints, for example "smart-album"'),
|
||||
})
|
||||
.meta({ id: 'PluginManifestTemplateDto' });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
|
||||
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||
import { asPluginKey } from 'src/utils/workflow';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -58,6 +59,7 @@ const PluginTemplateResponseSchema = z
|
||||
description: z.string().describe('Template description'),
|
||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
||||
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
|
||||
uiHints: z.array(z.string()).describe('Ui hints, for example "smart-album"'),
|
||||
})
|
||||
.meta({ id: 'PluginTemplateResponseDto' });
|
||||
|
||||
@@ -91,6 +93,7 @@ export type PluginTemplate = {
|
||||
config?: Record<string, unknown> | null;
|
||||
enabled?: boolean;
|
||||
}>;
|
||||
uiHints: string[];
|
||||
};
|
||||
|
||||
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
|
||||
@@ -104,6 +107,7 @@ export const mapTemplate = (plugin: { name: string }, template: PluginTemplate):
|
||||
config: step.config ?? null,
|
||||
enabled: step.enabled,
|
||||
})),
|
||||
uiHints: template.uiHints ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,14 @@ import { Place } from 'src/database';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
|
||||
import {
|
||||
AssetOrder,
|
||||
AssetOrderSchema,
|
||||
AssetTypeSchema,
|
||||
AssetVisibilitySchema,
|
||||
SearchOrderField,
|
||||
SearchOrderFieldSchema,
|
||||
} from 'src/enum';
|
||||
import { emptyStringToNull, isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -141,6 +148,199 @@ const SearchSuggestionRequestSchema = z
|
||||
})
|
||||
.meta({ id: 'SearchSuggestionRequestDto' });
|
||||
|
||||
// v3 SearchFilter DTOs — new shape introduced alongside the legacy flat DTOs above.
|
||||
|
||||
const atLeastOneKey = <T extends z.ZodObject>(schema: T) => {
|
||||
const keys = Object.keys(schema.shape);
|
||||
return schema.refine((value) => Object.values(value).some((v) => v !== undefined), {
|
||||
message: `At least one of the following keys is required: ${keys.join(', ')}`,
|
||||
});
|
||||
};
|
||||
|
||||
const IdFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.uuidv4().optional(),
|
||||
ne: z.uuidv4().optional(),
|
||||
}),
|
||||
).meta({ id: 'IdFilter' });
|
||||
|
||||
const IdFilterNullableSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.uuidv4().nullable().optional(),
|
||||
ne: z.uuidv4().nullable().optional(),
|
||||
}),
|
||||
).meta({ id: 'IdFilterNullable' });
|
||||
|
||||
const IdsFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
any: z.array(z.uuidv4()).min(1).optional(),
|
||||
all: z.array(z.uuidv4()).min(1).optional(),
|
||||
none: z.array(z.uuidv4()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'IdsFilter' });
|
||||
|
||||
const StringFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.string().optional(),
|
||||
ne: z.string().optional(),
|
||||
in: z.array(z.string()).min(1).optional(),
|
||||
notIn: z.array(z.string()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'StringFilter' });
|
||||
|
||||
const StringFilterNullableSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.string().nullable().optional(),
|
||||
ne: z.string().nullable().optional(),
|
||||
in: z.array(z.string()).min(1).optional(),
|
||||
notIn: z.array(z.string()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'StringFilterNullable' });
|
||||
|
||||
const StringPatternFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.string().nullable().optional(),
|
||||
ne: z.string().nullable().optional(),
|
||||
in: z.array(z.string()).min(1).optional(),
|
||||
notIn: z.array(z.string()).min(1).optional(),
|
||||
like: z.string().min(1).optional(),
|
||||
notLike: z.string().min(1).optional(),
|
||||
startsWith: z.string().min(1).optional(),
|
||||
endsWith: z.string().min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'StringPatternFilter' });
|
||||
|
||||
const NumberFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.number().optional(),
|
||||
ne: z.number().optional(),
|
||||
lt: z.number().optional(),
|
||||
lte: z.number().optional(),
|
||||
gt: z.number().optional(),
|
||||
gte: z.number().optional(),
|
||||
in: z.array(z.number()).min(1).optional(),
|
||||
notIn: z.array(z.number()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'NumberFilter' });
|
||||
|
||||
const NumberFilterNullableSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: z.number().nullable().optional(),
|
||||
ne: z.number().nullable().optional(),
|
||||
lt: z.number().optional(),
|
||||
lte: z.number().optional(),
|
||||
gt: z.number().optional(),
|
||||
gte: z.number().optional(),
|
||||
in: z.array(z.number()).min(1).optional(),
|
||||
notIn: z.array(z.number()).min(1).optional(),
|
||||
}),
|
||||
).meta({ id: 'NumberFilterNullable' });
|
||||
|
||||
const DateFilterSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: isoDatetimeToDate.optional(),
|
||||
ne: isoDatetimeToDate.optional(),
|
||||
gt: isoDatetimeToDate.optional(),
|
||||
gte: isoDatetimeToDate.optional(),
|
||||
lt: isoDatetimeToDate.optional(),
|
||||
lte: isoDatetimeToDate.optional(),
|
||||
}),
|
||||
).meta({ id: 'DateFilter' });
|
||||
|
||||
const DateFilterNullableSchema = atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: isoDatetimeToDate.nullable().optional(),
|
||||
ne: isoDatetimeToDate.nullable().optional(),
|
||||
gt: isoDatetimeToDate.optional(),
|
||||
gte: isoDatetimeToDate.optional(),
|
||||
lt: isoDatetimeToDate.optional(),
|
||||
lte: isoDatetimeToDate.optional(),
|
||||
}),
|
||||
).meta({ id: 'DateFilterNullable' });
|
||||
|
||||
const BoolFilterSchema = z.strictObject({ eq: z.boolean() }).meta({ id: 'BoolFilter' });
|
||||
|
||||
const enumFilterSchema = <T extends z.core.util.EnumLike>(values: z.ZodEnum<T>, id: string) =>
|
||||
atLeastOneKey(
|
||||
z.strictObject({
|
||||
eq: values.optional(),
|
||||
ne: values.optional(),
|
||||
in: z.array(values).min(1).optional(),
|
||||
notIn: z.array(values).min(1).optional(),
|
||||
}),
|
||||
).meta({ id });
|
||||
|
||||
const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType');
|
||||
const EnumFilterAssetVisibilitySchema = enumFilterSchema(AssetVisibilitySchema, 'EnumFilterAssetVisibility');
|
||||
|
||||
const StringSimilarityFilterSchema = z
|
||||
.strictObject({
|
||||
matches: z.string().min(1),
|
||||
})
|
||||
.meta({ id: 'StringSimilarityFilter' });
|
||||
|
||||
export const SearchOrderSchema = z
|
||||
.strictObject({
|
||||
field: SearchOrderFieldSchema.default(SearchOrderField.FileCreatedAt),
|
||||
direction: AssetOrderSchema.default(AssetOrder.Desc),
|
||||
})
|
||||
.meta({ id: 'SearchOrder' });
|
||||
|
||||
const SearchFilterBranchSchema = z
|
||||
.strictObject({
|
||||
id: IdFilterSchema.optional(),
|
||||
libraryId: IdFilterNullableSchema.optional(),
|
||||
type: EnumFilterAssetTypeSchema.optional(),
|
||||
visibility: EnumFilterAssetVisibilitySchema.optional(),
|
||||
isFavorite: BoolFilterSchema.optional(),
|
||||
isMotion: BoolFilterSchema.optional(),
|
||||
isOffline: BoolFilterSchema.optional(),
|
||||
isEncoded: BoolFilterSchema.optional(),
|
||||
hasAlbums: BoolFilterSchema.optional(),
|
||||
hasPeople: BoolFilterSchema.optional(),
|
||||
hasTags: BoolFilterSchema.optional(),
|
||||
city: StringFilterNullableSchema.optional(),
|
||||
state: StringFilterNullableSchema.optional(),
|
||||
country: StringFilterNullableSchema.optional(),
|
||||
make: StringFilterNullableSchema.optional(),
|
||||
model: StringFilterNullableSchema.optional(),
|
||||
lensModel: StringFilterNullableSchema.optional(),
|
||||
description: StringPatternFilterSchema.optional(),
|
||||
originalFileName: StringPatternFilterSchema.optional(),
|
||||
originalPath: StringPatternFilterSchema.optional(),
|
||||
ocr: StringSimilarityFilterSchema.optional(),
|
||||
rating: NumberFilterNullableSchema.optional(),
|
||||
fileSizeInBytes: NumberFilterSchema.optional(),
|
||||
takenAt: DateFilterSchema.optional(),
|
||||
createdAt: DateFilterSchema.optional(),
|
||||
updatedAt: DateFilterSchema.optional(),
|
||||
trashedAt: DateFilterNullableSchema.optional(),
|
||||
personIds: IdsFilterSchema.optional(),
|
||||
tagIds: IdsFilterSchema.optional(),
|
||||
albumIds: IdsFilterSchema.optional(),
|
||||
checksum: StringFilterSchema.optional(),
|
||||
encodedVideoPath: StringFilterSchema.optional(),
|
||||
})
|
||||
.meta({ id: 'SearchFilterBranch' });
|
||||
|
||||
export const SearchFilterSchema = SearchFilterBranchSchema.extend({
|
||||
or: z.array(SearchFilterBranchSchema).min(1).optional(),
|
||||
}).meta({ id: 'SearchFilter' });
|
||||
|
||||
export type IdFilter = z.infer<typeof IdFilterSchema>;
|
||||
export type IdFilterNullable = z.infer<typeof IdFilterNullableSchema>;
|
||||
export type IdsFilter = z.infer<typeof IdsFilterSchema>;
|
||||
export type StringFilter = z.infer<typeof StringFilterSchema>;
|
||||
export type StringFilterNullable = z.infer<typeof StringFilterNullableSchema>;
|
||||
export type StringPatternFilter = z.infer<typeof StringPatternFilterSchema>;
|
||||
export type NumberFilter = z.infer<typeof NumberFilterSchema>;
|
||||
export type NumberFilterNullable = z.infer<typeof NumberFilterNullableSchema>;
|
||||
export type DateFilter = z.infer<typeof DateFilterSchema>;
|
||||
export type DateFilterNullable = z.infer<typeof DateFilterNullableSchema>;
|
||||
export type SearchOrder = z.infer<typeof SearchOrderSchema>;
|
||||
export type SearchFilter = z.infer<typeof SearchFilterSchema>;
|
||||
export type SearchFilterBranch = z.infer<typeof SearchFilterBranchSchema>;
|
||||
|
||||
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}
|
||||
export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {}
|
||||
export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkflowStepConfig } from '@immich/plugin-sdk';
|
||||
import type { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import z from 'zod';
|
||||
|
||||
const WorkflowTriggerResponseSchema = z
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import z from 'zod';
|
||||
|
||||
export enum AuthType {
|
||||
@@ -866,7 +867,7 @@ export enum JobName {
|
||||
Ocr = 'Ocr',
|
||||
|
||||
// Workflow
|
||||
WorkflowAssetCreate = 'WorkflowAssetCreate',
|
||||
WorkflowAssetTrigger = 'WorkflowAssetTrigger',
|
||||
}
|
||||
|
||||
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
|
||||
@@ -1164,11 +1165,6 @@ export enum PluginContext {
|
||||
|
||||
export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
|
||||
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = 'AssetCreate',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
export const WorkflowTriggerSchema = z
|
||||
.enum(WorkflowTrigger)
|
||||
.describe('Plugin trigger type')
|
||||
@@ -1180,3 +1176,12 @@ export enum WorkflowType {
|
||||
}
|
||||
|
||||
export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' });
|
||||
|
||||
export enum SearchOrderField {
|
||||
FileCreatedAt = 'fileCreatedAt',
|
||||
LocalDateTime = 'localDateTime',
|
||||
FileSizeInBytes = 'fileSizeInBytes',
|
||||
Rating = 'rating',
|
||||
}
|
||||
|
||||
export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' });
|
||||
|
||||