Compare commits

...

11 Commits

Author SHA1 Message Date
shenlong c42cea5ca9 refactor: use widget previews for ui showcase (#28548)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-29 20:22:47 +00:00
Jason Rasmussen da8505f61d feat: more plugin triggers and methods (#28690) 2026-05-29 14:02:07 -04:00
Alex 58586483dc feat: render album's name in workflow step card (#28680)
* feat: render album name in step card body

* clean up

* i18n
2026-05-29 10:37:37 -05:00
pneuly a838167f11 fix(ml): pass model_root_dir to OcrOptions for RapidOCR compatibility (#28610)
* fix(ml): pass model_root_dir to OcrOptions for RapidOCR compatibility

Fix a TypeError (Path(None)) when the OCR model is invoked, caused by an upstream change in RapidOCR v3.8.1 (RapidAI/RapidOCR@8ea9626).
RapidOCR now internally calls `Path(cfg.get("model_root_dir"))`. Since `model_root_dir` was missing from `OcrOptions`, it evaluated to `None` and triggered a `TypeError: argument should be a str or an os.PathLike`.
This fix adds the missing `model_root_dir` argument to prevent the error.
Ref: #28331

* fix(ml-test): update OCR tests for RapidOCR schema change

* chore(ml-test): remove unused `cache_dir` parameter from `TextRecognizer`

* Revert "chore(ml-test): remove unused `cache_dir` parameter from `TextRecognizer`"

This reverts commit 007ad7b3f2.

* fix(ml): use self.cache_dir for model_root_dir in OcrOptions
2026-05-28 22:54:04 -04:00
Mert b189fc571c fix: make ts a peer dependency for swagger (#28677)
make ts a peer dependency
2026-05-28 22:04:25 +00:00
Jason Rasmussen 96923f6115 refactor: plugin sdk types (#28674) 2026-05-28 22:04:15 +00:00
shenlong 0d6cce4a5b fix: api repositories using stale endpoint (#28667)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 16:44:11 -05:00
shenlong 55947cb227 refactor: drop metadata scope (#28668)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 16:42:59 -05:00
Jason Rasmussen 8783180cf3 refactor: plugin manifest (#28673) 2026-05-28 17:23:49 -04:00
Jason Rasmussen 134c0d4dfb feat: search by album name and id (#28672) 2026-05-28 17:01:47 -04:00
Alex aecf8ec88b fix: timeline scroll flicker (#28653)
* test: fix scroll flicker

* lint
2026-05-28 08:20:54 -05:00
108 changed files with 1124 additions and 3065 deletions
-4
View File
@@ -72,10 +72,6 @@ jobs:
run: flutter pub get run: flutter pub get
working-directory: ./mobile/packages/ui 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 - name: Generate translation files
run: mise //mobile:codegen:translation run: mise //mobile:codegen:translation
+18
View File
@@ -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. 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 ## IDE setup
### Lint / format extensions ### Lint / format extensions
+1
View File
@@ -2233,6 +2233,7 @@
"slideshow_repeat": "Repeat slideshow", "slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings", "slideshow_settings": "Slideshow settings",
"smart_album": "Smart album",
"sort_albums_by": "Sort albums by...", "sort_albums_by": "Sort albums by...",
"sort_created": "Date created", "sort_created": "Date created",
"sort_items": "Number of items", "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_batch_num=max_batch_size if max_batch_size else 6,
rec_img_shape=(3, 48, 320), rec_img_shape=(3, 48, 320),
lang_type=self.language, lang_type=self.language,
model_root_dir=self.cache_dir,
) )
) )
return session return session
+18 -3
View File
@@ -1028,7 +1028,12 @@ class TestOcr:
text_recognizer.load() text_recognizer.load()
rapid_recognizer.assert_called_once_with( 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: 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() text_recognizer.load()
rapid_recognizer.assert_called_once_with( 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( def test_ignore_other_custom_max_batch_size(
@@ -1056,7 +1066,12 @@ class TestOcr:
text_recognizer.load() text_recognizer.load()
rapid_recognizer.assert_called_once_with( 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,
)
) )
+3 -3
View File
@@ -54,8 +54,8 @@ lockfile = true
[tasks.plugins] [tasks.plugins]
run = [ run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile", "pnpm --filter @immich/sdk --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 build",
] ]
[tasks.open-api-typescript] [tasks.open-api-typescript]
@@ -108,7 +108,7 @@ depends = "//:plugins"
dir = "docker" dir = "docker"
interactive = true interactive = true
env = { COMPOSE_BAKE = true } env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.prod.yml up --remove-orphans" run = "docker compose -f ./docker-compose.prod.yml up --build --remove-orphans"
depends_post = "//:prod-down" depends_post = "//:prod-down"
[tasks.prod-scale] [tasks.prod-scale]
@@ -143,13 +143,8 @@ class AppConfig {
}) })
as T; as T;
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) { factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> overrides) =>
var config = const AppConfig(); overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
for (final MapEntry(key: key, value: value) in entries.entries) {
config = config.write(key, value);
}
return config;
}
AppConfig write<T extends Object>(MetadataKey<T> key, T value) { AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
return switch (key) { return switch (key) {
+7 -18
View File
@@ -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/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataScope {
user, // keys with this scope are deleted on logout
system;
const MetadataScope();
}
enum MetadataKey<T extends Object> { enum MetadataKey<T extends Object> {
// Theme // Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)), themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
@@ -32,14 +25,11 @@ enum MetadataKey<T extends Object> {
viewerTapToNavigate<bool>(), viewerTapToNavigate<bool>(),
// Network // Network
networkAutoEndpointSwitching<bool>(scope: .system), networkAutoEndpointSwitching<bool>(),
networkPreferredWifiName<String>(scope: .system), networkPreferredWifiName<String>(),
networkLocalEndpoint<String>(scope: .system), networkLocalEndpoint<String>(),
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)), networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>( networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
scope: .system,
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
// Album // Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)), albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
@@ -60,7 +50,7 @@ enum MetadataKey<T extends Object> {
timelineStorageIndicator<bool>(), timelineStorageIndicator<bool>(),
// Log // Log
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)), logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
// Map // Map
mapShowFavoriteOnly<bool>(), mapShowFavoriteOnly<bool>(),
@@ -83,10 +73,9 @@ enum MetadataKey<T extends Object> {
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)), slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)); slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final MetadataScope scope;
final _MetadataCodec<T>? _codecOverride; 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); _MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
@@ -34,21 +34,33 @@ class MetadataRepository extends DriftDatabaseRepository {
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get()); 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 { Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (value == _appConfig.read(key)) { if (value == _appConfig.read(key)) {
return; return;
} }
if (value == defaultConfig.read(key)) { if (value == defaultConfig.read(key)) {
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go(); return clear([key]);
} else {
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
} }
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
_appConfig = _appConfig.write(key, value); _appConfig = _appConfig.write(key, value);
} }
+2 -2
View File
@@ -13,7 +13,7 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class ApiService { class ApiService {
late ApiClient _apiClient; final ApiClient _apiClient = ApiClient(basePath: '');
late UsersApi usersApi; late UsersApi usersApi;
late AuthenticationApi authenticationApi; late AuthenticationApi authenticationApi;
@@ -54,7 +54,7 @@ class ApiService {
} }
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint); _apiClient.basePath = endpoint;
_apiClient.client = NetworkRepository.client; _apiClient.client = NetworkRepository.client;
usersApi = UsersApi(_apiClient); usersApi = UsersApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient);
+7 -1
View File
@@ -110,7 +110,7 @@ class AuthService {
/// - Authentication repository data /// - Authentication repository data
/// - Current user information /// - Current user information
/// - Access token /// - Access token
/// - Asset ETag /// - Server-specific endpoint configuration
/// ///
/// All deletions are executed in parallel using [Future.wait]. /// All deletions are executed in parallel using [Future.wait].
Future<void> clearLocalData() async { Future<void> clearLocalData() async {
@@ -120,6 +120,12 @@ class AuthService {
_authRepository.clearLocalData(), _authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser), Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken), Store.delete(StoreKey.accessToken),
MetadataRepository.instance.clear(const [
.networkAutoEndpointSwitching,
.networkPreferredWifiName,
.networkLocalEndpoint,
.networkExternalEndpointList,
]),
]); ]);
} }
+21 -3
View File
@@ -508,12 +508,18 @@ class AlbumsApi {
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters) /// Filter albums containing this asset ID (ignores other parameters)
/// ///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned: /// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
/// ///
/// * [bool] isShared: /// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter /// 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 // ignore: prefer_const_declarations
final apiPath = r'/albums'; final apiPath = r'/albums';
@@ -527,12 +533,18 @@ class AlbumsApi {
if (assetId != null) { if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId)); queryParams.addAll(_queryParams('', 'assetId', assetId));
} }
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (isOwned != null) { if (isOwned != null) {
queryParams.addAll(_queryParams('', 'isOwned', isOwned)); queryParams.addAll(_queryParams('', 'isOwned', isOwned));
} }
if (isShared != null) { if (isShared != null) {
queryParams.addAll(_queryParams('', 'isShared', isShared)); queryParams.addAll(_queryParams('', 'isShared', isShared));
} }
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
const contentTypes = <String>[]; const contentTypes = <String>[];
@@ -557,13 +569,19 @@ class AlbumsApi {
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters) /// Filter albums containing this asset ID (ignores other parameters)
/// ///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned: /// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
/// ///
/// * [bool] isShared: /// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter /// 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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+1 -1
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class ApiClient { class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,}); ApiClient({this.basePath = '/api', this.authentication,});
final String basePath; String basePath;
final Authentication? authentication; final Authentication? authentication;
var _client = Client(); var _client = Client();
+3 -3
View File
@@ -77,7 +77,7 @@ class JobName {
static const versionCheck = JobName._(r'VersionCheck'); static const versionCheck = JobName._(r'VersionCheck');
static const ocrQueueAll = JobName._(r'OcrQueueAll'); static const ocrQueueAll = JobName._(r'OcrQueueAll');
static const ocr = JobName._(r'Ocr'); 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]. /// List of all possible values in this [enum][JobName].
static const values = <JobName>[ static const values = <JobName>[
@@ -135,7 +135,7 @@ class JobName {
versionCheck, versionCheck,
ocrQueueAll, ocrQueueAll,
ocr, ocr,
workflowAssetCreate, workflowAssetTrigger,
]; ];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@@ -228,7 +228,7 @@ class JobNameTypeTransformer {
case r'VersionCheck': return JobName.versionCheck; case r'VersionCheck': return JobName.versionCheck;
case r'OcrQueueAll': return JobName.ocrQueueAll; case r'OcrQueueAll': return JobName.ocrQueueAll;
case r'Ocr': return JobName.ocr; case r'Ocr': return JobName.ocr;
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate; case r'WorkflowAssetTrigger': return JobName.workflowAssetTrigger;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
+14 -3
View File
@@ -18,6 +18,7 @@ class PluginTemplateResponseDto {
this.steps = const [], this.steps = const [],
required this.title, required this.title,
required this.trigger, required this.trigger,
this.uiHints = const [],
}); });
/// Template description /// Template description
@@ -34,13 +35,17 @@ class PluginTemplateResponseDto {
WorkflowTrigger trigger; WorkflowTrigger trigger;
/// Ui hints, for example \"smart-album\"
List<String> uiHints;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto && bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
other.description == description && other.description == description &&
other.key == key && other.key == key &&
_deepEquality.equals(other.steps, steps) && _deepEquality.equals(other.steps, steps) &&
other.title == title && other.title == title &&
other.trigger == trigger; other.trigger == trigger &&
_deepEquality.equals(other.uiHints, uiHints);
@override @override
int get hashCode => int get hashCode =>
@@ -49,10 +54,11 @@ class PluginTemplateResponseDto {
(key.hashCode) + (key.hashCode) +
(steps.hashCode) + (steps.hashCode) +
(title.hashCode) + (title.hashCode) +
(trigger.hashCode); (trigger.hashCode) +
(uiHints.hashCode);
@override @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() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -61,6 +67,7 @@ class PluginTemplateResponseDto {
json[r'steps'] = this.steps; json[r'steps'] = this.steps;
json[r'title'] = this.title; json[r'title'] = this.title;
json[r'trigger'] = this.trigger; json[r'trigger'] = this.trigger;
json[r'uiHints'] = this.uiHints;
return json; return json;
} }
@@ -78,6 +85,9 @@ class PluginTemplateResponseDto {
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']), steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
title: mapValueOfType<String>(json, r'title')!, title: mapValueOfType<String>(json, r'title')!,
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, 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; return null;
@@ -130,6 +140,7 @@ class PluginTemplateResponseDto {
'steps', 'steps',
'title', 'title',
'trigger', 'trigger',
'uiHints',
}; };
} }
+3
View File
@@ -24,11 +24,13 @@ class WorkflowTrigger {
String toJson() => value; String toJson() => value;
static const assetCreate = WorkflowTrigger._(r'AssetCreate'); static const assetCreate = WorkflowTrigger._(r'AssetCreate');
static const assetMetadataExtraction = WorkflowTrigger._(r'AssetMetadataExtraction');
static const personRecognized = WorkflowTrigger._(r'PersonRecognized'); static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
/// List of all possible values in this [enum][WorkflowTrigger]. /// List of all possible values in this [enum][WorkflowTrigger].
static const values = <WorkflowTrigger>[ static const values = <WorkflowTrigger>[
assetCreate, assetCreate,
assetMetadataExtraction,
personRecognized, personRecognized,
]; ];
@@ -69,6 +71,7 @@ class WorkflowTriggerTypeTransformer {
if (data != null) { if (data != null) {
switch (data) { switch (data) {
case r'AssetCreate': return WorkflowTrigger.assetCreate; case r'AssetCreate': return WorkflowTrigger.assetCreate;
case r'AssetMetadataExtraction': return WorkflowTrigger.assetMetadataExtraction;
case r'PersonRecognized': return WorkflowTrigger.personRecognized; case r'PersonRecognized': return WorkflowTrigger.personRecognized;
default: default:
if (!allowNull) { if (!allowNull) {
+60
View File
@@ -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);
}
}
+1 -1
View File
@@ -185,5 +185,5 @@ packages:
source: hosted source: hosted
version: "15.2.0" version: "15.2.0"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_ui
publish_to: none publish_to: none
environment: environment:
sdk: '>=3.11.0 <4.0.0' sdk: '>=3.12.0 <4.0.0'
dependencies: dependencies:
flutter: flutter:
-11
View File
@@ -1,11 +0,0 @@
# Build artifacts
build/
# Test cache and generated files
.dart_tool/
.packages
.flutter-plugins
.flutter-plugins-dependencies
# IDE-specific files
.vscode/
-30
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

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;
}
-55
View File
@@ -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,
),
),
),
],
),
),
),
),
);
}
}
-377
View File
@@ -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"
-47
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

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"
}
]
}
+31 -2
View File
@@ -1627,6 +1627,17 @@
"type": "string" "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", "name": "isOwned",
"required": false, "required": false,
@@ -1644,6 +1655,15 @@
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
},
{
"name": "name",
"required": false,
"in": "query",
"description": "Album name (exact match)",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -18151,7 +18171,7 @@
"VersionCheck", "VersionCheck",
"OcrQueueAll", "OcrQueueAll",
"Ocr", "Ocr",
"WorkflowAssetCreate" "WorkflowAssetTrigger"
], ],
"type": "string" "type": "string"
}, },
@@ -20199,6 +20219,13 @@
"trigger": { "trigger": {
"$ref": "#/components/schemas/WorkflowTrigger", "$ref": "#/components/schemas/WorkflowTrigger",
"description": "Workflow trigger" "description": "Workflow trigger"
},
"uiHints": {
"description": "Ui hints, for example \"smart-album\"",
"items": {
"type": "string"
},
"type": "array"
} }
}, },
"required": [ "required": [
@@ -20206,7 +20233,8 @@
"key", "key",
"steps", "steps",
"title", "title",
"trigger" "trigger",
"uiHints"
], ],
"type": "object" "type": "object"
}, },
@@ -26327,6 +26355,7 @@
"description": "Plugin trigger type", "description": "Plugin trigger type",
"enum": [ "enum": [
"AssetCreate", "AssetCreate",
"AssetMetadataExtraction",
"PersonRecognized" "PersonRecognized"
], ],
"type": "string" "type": "string"
+9
View File
@@ -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 @@ @@ -143,19 +143,19 @@
); );
} }
+62 -16
View File
@@ -7,8 +7,8 @@
"wasmPath": "dist/plugin.wasm", "wasmPath": "dist/plugin.wasm",
"templates": [ "templates": [
{ {
"name": "auto-archive-screenshots", "name": "screenshots-smart-album",
"title": "Auto-archive screenshots", "title": "Archive screenshots",
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album", "description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
"trigger": "AssetCreate", "trigger": "AssetCreate",
"steps": [ "steps": [
@@ -20,19 +20,41 @@
"caseSensitive": false "caseSensitive": false
} }
}, },
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
},
{ {
"method": "immich-plugin-core#assetArchive", "method": "immich-plugin-core#assetArchive",
"config": { "config": {
"inverse": false "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": [ "methods": [
@@ -65,7 +87,25 @@
}, },
"required": ["pattern"] "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", "name": "filterFileType",
@@ -85,7 +125,7 @@
}, },
"required": ["fileTypes"] "required": ["fileTypes"]
}, },
"uiHints": ["filter"] "uiHints": ["Filter"]
}, },
{ {
"name": "filterPerson", "name": "filterPerson",
@@ -99,7 +139,7 @@
"array": true, "array": true,
"title": "Person IDs", "title": "Person IDs",
"description": "List of person to match", "description": "List of person to match",
"uiHint": "personI" "uiHint": "personId"
}, },
"matchAny": { "matchAny": {
"type": "boolean", "type": "boolean",
@@ -110,7 +150,7 @@
}, },
"required": ["personIds"] "required": ["personIds"]
}, },
"uiHints": ["filter"] "uiHints": ["Filter"]
}, },
{ {
"name": "assetArchive", "name": "assetArchive",
@@ -187,7 +227,13 @@
"title": "Album IDs", "title": "Album IDs",
"array": true, "array": true,
"description": "Target album IDs", "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"] "required": ["albumIds"]
@@ -272,14 +318,14 @@
"type": "string", "type": "string",
"title": "Album ID", "title": "Album ID",
"description": "Target album ID", "description": "Target album ID",
"uiHint": "albumId" "uiHint": "AlbumId"
}, },
"albumIds": { "albumIds": {
"type": "string", "type": "string",
"title": "Album IDs", "title": "Album IDs",
"description": "Target album IDs", "description": "Target album IDs",
"array": true, "array": true,
"uiHint": "albumId" "uiHint": "AlbumId"
} }
} }
} }
+1
View File
@@ -13,6 +13,7 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"@extism/js-pdk": "^1.0.1", "@extism/js-pdk": "^1.0.1",
"@immich/sdk": "workspace:*",
"@immich/plugin-sdk": "workspace:*", "@immich/plugin-sdk": "workspace:*",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"typescript": "^6.0.0" "typescript": "^6.0.0"
+9 -3
View File
@@ -1,14 +1,20 @@
// copy from // keep in sync with plugin-sdk/host-functions.ts';
// import '@immich/plugin-sdk/host-functions';
declare module 'extism:host' { declare module 'extism:host' {
interface user { interface user {
albumAddAssets(ptr: PTR): I64; searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64; addAssetsToAlbums(ptr: PTR): I64;
} }
} }
// keep in sync with manifest.json
declare module 'main' { declare module 'main' {
// filters
export function assetFileFilter(): I32; export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
// updates
export function assetFavorite(): I32; export function assetFavorite(): I32;
export function assetVisibility(): I32; export function assetVisibility(): I32;
export function assetArchive(): I32; export function assetArchive(): I32;
+29 -13
View File
@@ -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 = { type AssetFileFilterConfig = {
pattern: string; 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 = () => { export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => { return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true; const target = config.inverse ? false : true;
@@ -89,28 +98,35 @@ export const assetLock = () => {
}; };
export const assetTrash = () => { export const assetTrash = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => ({ // TODO use trash/untrash host functions
changes: { return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
asset: config.inverse
? { deletedAt: null, status: AssetStatus.Active }
: { deletedAt: new Date(), status: AssetStatus.Trashed },
},
}));
}; };
export const assetAddToAlbums = () => { 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) { if (config.albumIds.length === 0) {
// noop if (!config.albumName) {
return {}; 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) { if (config.albumIds.length === 1) {
functions.albumAddAssets(config.albumIds[0], [data.asset.id]); functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
return {}; return {};
} }
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [data.asset.id] }); functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {}; return {};
}); });
}; };
+3 -2
View File
@@ -2,7 +2,6 @@
"name": "@immich/plugin-sdk", "name": "@immich/plugin-sdk",
"version": "0.0.0", "version": "0.0.0",
"description": "", "description": "",
"main": "index.js",
"type": "module", "type": "module",
"exports": { "exports": {
"./host-functions": { "./host-functions": {
@@ -11,7 +10,8 @@
}, },
".": { ".": {
"import": "./dist/index.js", "import": "./dist/index.js",
"types": "./dist/index.d.ts" "types": "./dist/index.d.ts",
"default": "./dist/index.js"
} }
}, },
"scripts": { "scripts": {
@@ -27,6 +27,7 @@
"packageManager": "pnpm@10.33.4", "packageManager": "pnpm@10.33.4",
"devDependencies": { "devDependencies": {
"@extism/js-pdk": "^1.1.1", "@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
"@types/node": "^24.12.4", "@types/node": "^24.12.4",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"tsc-alias": "^1.8.16", "tsc-alias": "^1.8.16",
-33
View File
@@ -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',
}
+58 -30
View File
@@ -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' { declare module 'extism:host' {
interface user { interface user {
albumAddAssets(ptr: PTR): I64; searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64; addAssetsToAlbums(ptr: PTR): I64;
} }
} }
const host = Host.getFunctions(); type AlbumsToAssets = {
type HostFunctionName = keyof typeof host; assetIds: string[];
albumIds: string[];
};
type HostFunctionSuccessResult<T> = { success: true; response: T }; type HostFunctionSuccessResult<T> = { success: true; response: T };
type HostFunctionErrorResult = { type HostFunctionErrorResult = {
success: false; success: false;
@@ -17,35 +31,49 @@ type HostFunctionResult<T> =
| HostFunctionSuccessResult<T> | HostFunctionSuccessResult<T>
| HostFunctionErrorResult; | HostFunctionErrorResult;
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => { type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args })); type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
const fn = host[name];
const handler = Memory.find(fn(pointer1.offset));
try { export const hostFunctions = (authToken: string) => {
const result = JSON.parse(handler.readString()) as HostFunctionResult<R>; const host = Host.getFunctions();
type HostFunctionName = keyof typeof host;
if (result.success) { const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
return result.response; 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( return {
`Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`, // album
); searchAlbums: (dto: AlbumSearchDto) =>
} finally { call<[AlbumSearchDto], AlbumResponseDto[]>('searchAlbums', authToken, [
handler.free(); dto,
pointer1.free(); ]),
} 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
View File
@@ -1,4 +1,3 @@
export * from 'src/enum.js';
export * from 'src/host-functions.js'; export * from 'src/host-functions.js';
export * from 'src/sdk.js'; export * from 'src/sdk.js';
export * from 'src/types.js'; export * from 'src/types.js';
+18 -8
View File
@@ -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 { hostFunctions } from 'src/host-functions.js';
import type { import type {
ConfigValue, ConfigValue,
WorkflowEventPayload, WorkflowEventPayload,
WorkflowResponse, WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js'; } from 'src/types.js';
export const wrapper = < export const wrapper = <
@@ -19,19 +20,28 @@ export const wrapper = <
const input = Host.inputString(); const input = Host.inputString();
try { try {
const event = JSON.parse(input) as WorkflowEventPayload<T, TConfig>; const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
// const debug = event.workflow.debug ?? false; const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug( 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 = const response = fn(event) ?? {};
fn({ ...event, functions: hostFunctions(event.workflow.authToken) }) ??
{}; // if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug( 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); const output = JSON.stringify(response);
+19 -18
View File
@@ -1,10 +1,4 @@
import type { import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
AssetStatus,
AssetType,
AssetVisibility,
WorkflowTrigger,
WorkflowType,
} from 'src/enum.js';
type DeepPartial<T> = T extends Date type DeepPartial<T> = T extends Date
? T ? T
@@ -21,6 +15,12 @@ export type WorkflowEventMap = {
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T]; export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction',
PersonRecognized = 'PersonRecognized',
}
export type WorkflowEventPayload< export type WorkflowEventPayload<
T extends WorkflowType = WorkflowType, T extends WorkflowType = WorkflowType,
TConfig = WorkflowStepConfig, TConfig = WorkflowStepConfig,
@@ -48,6 +48,8 @@ export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
changes?: WorkflowChanges<T>; changes?: WorkflowChanges<T>;
/** data to be passed to the next workflow step */ /** data to be passed to the next workflow step */
data?: Record<string, unknown>; data?: Record<string, unknown>;
/** update step config */
config?: WorkflowStepConfig;
}; };
export type WorkflowStepConfig = { export type WorkflowStepConfig = {
@@ -66,24 +68,23 @@ export type AssetV1 = {
asset: { asset: {
id: string; id: string;
ownerId: string; ownerId: string;
type: AssetType; type: AssetTypeEnum;
originalPath: string; originalPath: string;
fileCreatedAt: Date; fileCreatedAt: string;
fileModifiedAt: Date; fileModifiedAt: string;
isFavorite: boolean; isFavorite: boolean;
checksum: Buffer; // sha1 checksum checksum: Buffer; // sha1 checksum
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
updatedAt: Date; updatedAt: string;
createdAt: Date; createdAt: string;
originalFileName: string; originalFileName: string;
isOffline: boolean; isOffline: boolean;
libraryId: string | null; libraryId: string | null;
isExternal: boolean; isExternal: boolean;
deletedAt: Date | null; deletedAt: string | null;
localDateTime: Date; localDateTime: string;
stackId: string | null; stackId: string | null;
duplicateId: string | null; duplicateId: string | null;
status: AssetStatus;
visibility: AssetVisibility; visibility: AssetVisibility;
isEdited: boolean; isEdited: boolean;
exifInfo: { exifInfo: {
@@ -93,8 +94,8 @@ export type AssetV1 = {
exifImageHeight: number | null; exifImageHeight: number | null;
fileSizeInByte: number | null; fileSizeInByte: number | null;
orientation: string | null; orientation: string | null;
dateTimeOriginal: Date | null; dateTimeOriginal: string | null;
modifyDate: Date | null; modifyDate: string | null;
lensModel: string | null; lensModel: string | null;
fNumber: number | null; fNumber: number | null;
focalLength: number | null; focalLength: number | null;
@@ -116,7 +117,7 @@ export type AssetV1 = {
autoStackId: string | null; autoStackId: string | null;
rating: number | null; rating: number | null;
tags: string[] | null; tags: string[] | null;
updatedAt: Date | null; updatedAt: string | null;
} | null; } | null;
}; };
}; };
+10 -3
View File
@@ -1535,6 +1535,8 @@ export type PluginTemplateResponseDto = {
title: string; title: string;
/** Workflow trigger */ /** Workflow trigger */
trigger: WorkflowTrigger; trigger: WorkflowTrigger;
/** Ui hints, for example "smart-album" */
uiHints: string[];
}; };
export type QueueResponseDto = { export type QueueResponseDto = {
/** Whether the queue is paused */ /** Whether the queue is paused */
@@ -3597,18 +3599,22 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/** /**
* List all albums * List all albums
*/ */
export function getAllAlbums({ assetId, isOwned, isShared }: { export function getAllAlbums({ assetId, id, isOwned, isShared, name }: {
assetId?: string; assetId?: string;
id?: string;
isOwned?: boolean; isOwned?: boolean;
isShared?: boolean; isShared?: boolean;
name?: string;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AlbumResponseDto[]; data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({ }>(`/albums${QS.query(QS.explode({
assetId, assetId,
id,
isOwned, isOwned,
isShared isShared,
name
}))}`, { }))}`, {
...opts ...opts
})); }));
@@ -7075,6 +7081,7 @@ export enum WorkflowType {
} }
export enum WorkflowTrigger { export enum WorkflowTrigger {
AssetCreate = "AssetCreate", AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction",
PersonRecognized = "PersonRecognized" PersonRecognized = "PersonRecognized"
} }
export enum QueueJobStatus { export enum QueueJobStatus {
@@ -7140,7 +7147,7 @@ export enum JobName {
VersionCheck = "VersionCheck", VersionCheck = "VersionCheck",
OcrQueueAll = "OcrQueueAll", OcrQueueAll = "OcrQueueAll",
Ocr = "Ocr", Ocr = "Ocr",
WorkflowAssetCreate = "WorkflowAssetCreate" WorkflowAssetTrigger = "WorkflowAssetTrigger"
} }
export enum SearchSuggestionType { export enum SearchSuggestionType {
Country = "country", Country = "country",
+14 -6
View File
@@ -10,7 +10,7 @@ overrides:
sharp: ^0.34.5 sharp: ^0.34.5
webpackbar: ^7.0.0 webpackbar: ^7.0.0
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54= packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k=
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA= pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
@@ -320,6 +320,9 @@ importers:
'@immich/plugin-sdk': '@immich/plugin-sdk':
specifier: workspace:* specifier: workspace:*
version: link:../plugin-sdk version: link:../plugin-sdk
'@immich/sdk':
specifier: workspace:*
version: link:../sdk
esbuild: esbuild:
specifier: ^0.28.0 specifier: ^0.28.0
version: 0.28.0 version: 0.28.0
@@ -332,6 +335,9 @@ importers:
'@extism/js-pdk': '@extism/js-pdk':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
'@immich/sdk':
specifier: workspace:*
version: link:../sdk
'@types/node': '@types/node':
specifier: ^24.12.4 specifier: ^24.12.4
version: 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) 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': '@nestjs/swagger':
specifier: ^11.4.2 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': '@nestjs/websockets':
specifier: ^11.0.4 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) 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) 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: nestjs-zod:
specifier: ^5.3.0 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: nodemailer:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.7 version: 8.0.7
@@ -3795,6 +3801,7 @@ packages:
class-transformer: '*' class-transformer: '*'
class-validator: '*' class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0 reflect-metadata: ^0.1.12 || ^0.2.0
typescript: '*'
peerDependenciesMeta: peerDependenciesMeta:
'@fastify/static': '@fastify/static':
optional: true optional: true
@@ -16570,7 +16577,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - 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: dependencies:
'@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2) '@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 path-to-regexp: 8.4.2
reflect-metadata: 0.2.2 reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.6 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)': '@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: dependencies:
@@ -23229,14 +23237,14 @@ snapshots:
'@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1) '@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1)
tslib: 2.8.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: dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1 deepmerge: 4.3.1
rxjs: 7.8.2 rxjs: 7.8.2
zod: 4.3.6 zod: 4.3.6
optionalDependencies: 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: {} next-tick@1.1.0: {}
+3
View File
@@ -60,6 +60,9 @@ packageExtensions:
dependencies: dependencies:
node-addon-api: '*' node-addon-api: '*'
node-gyp: '*' node-gyp: '*'
'@nestjs/swagger':
peerDependencies:
typescript: '*'
dedupePeerDependents: false dedupePeerDependents: false
preferWorkspacePackages: true preferWorkspacePackages: true
injectWorkspacePackages: true injectWorkspacePackages: true
+4 -2
View File
@@ -13,14 +13,15 @@ FROM builder AS server
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ./server ./server/ COPY ./server ./server/
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/ COPY ./packages/plugin-sdk ./packages/plugin-sdk/
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --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-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.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_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 SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
FROM builder AS web FROM builder AS web
@@ -66,6 +67,7 @@ ENV MISE_DISABLE_TOOLS=flutter
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install mise install
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-core ./packages/plugin-core/ COPY ./packages/plugin-core ./packages/plugin-core/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/ COPY ./packages/plugin-sdk ./packages/plugin-sdk/
+1
View File
@@ -68,6 +68,7 @@ run = [
[tasks.ci-medium] [tasks.ci-medium]
run = [ run = [
{ task = ":install" }, { task = ":install" },
{ task = "//:plugins" },
{ task = "//packages/plugin-core:build" }, { task = "//packages/plugin-core:build" },
{ task = ":test-medium --run" }, { task = ":test-medium --run" },
] ]
@@ -1,5 +1,5 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowController } from 'src/controllers/workflow.controller'; import { WorkflowController } from 'src/controllers/workflow.controller';
import { WorkflowTrigger } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { WorkflowService } from 'src/services/workflow.service'; import { WorkflowService } from 'src/services/workflow.service';
import request from 'supertest'; import request from 'supertest';
+2
View File
@@ -65,6 +65,8 @@ const UpdateAlbumSchema = z
const GetAlbumsSchema = z const GetAlbumsSchema = z
.object({ .object({
id: z.uuidv4().optional().describe('Album ID'),
name: z.string().optional().describe('Album name (exact match)'),
isOwned: stringToBool isOwned: stringToBool
.optional() .optional()
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'), .describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
+1
View File
@@ -38,6 +38,7 @@ const PluginManifestTemplateSchema = z
description: z.string().min(1).describe('Template description'), description: z.string().min(1).describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'), trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'), steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).optional().default([]).describe('Ui hints, for example "smart-album"'),
}) })
.meta({ id: 'PluginManifestTemplateDto' }); .meta({ id: 'PluginManifestTemplateDto' });
+5 -1
View File
@@ -1,6 +1,7 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { JsonSchemaDto } from 'src/dtos/json-schema.dto'; 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 { asPluginKey } from 'src/utils/workflow';
import z from 'zod'; import z from 'zod';
@@ -58,6 +59,7 @@ const PluginTemplateResponseSchema = z
description: z.string().describe('Template description'), description: z.string().describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'), trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'), steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).describe('Ui hints, for example "smart-album"'),
}) })
.meta({ id: 'PluginTemplateResponseDto' }); .meta({ id: 'PluginTemplateResponseDto' });
@@ -91,6 +93,7 @@ export type PluginTemplate = {
config?: Record<string, unknown> | null; config?: Record<string, unknown> | null;
enabled?: boolean; enabled?: boolean;
}>; }>;
uiHints: string[];
}; };
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => { 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, config: step.config ?? null,
enabled: step.enabled, enabled: step.enabled,
})), })),
uiHints: template.uiHints ?? [],
}; };
}; };
+2 -2
View File
@@ -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 { createZodDto } from 'nestjs-zod';
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum'; import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import z from 'zod'; import z from 'zod';
const WorkflowTriggerResponseSchema = z const WorkflowTriggerResponseSchema = z
+2 -6
View File
@@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import z from 'zod'; import z from 'zod';
export enum AuthType { export enum AuthType {
@@ -866,7 +867,7 @@ export enum JobName {
Ocr = 'Ocr', Ocr = 'Ocr',
// Workflow // Workflow
WorkflowAssetCreate = 'WorkflowAssetCreate', WorkflowAssetTrigger = 'WorkflowAssetTrigger',
} }
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' }); 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 const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
export const WorkflowTriggerSchema = z export const WorkflowTriggerSchema = z
.enum(WorkflowTrigger) .enum(WorkflowTrigger)
.describe('Plugin trigger type') .describe('Plugin trigger type')
+6 -1
View File
@@ -209,11 +209,16 @@ export class AlbumRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] }) @GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<MapAlbumDto[]> { getAll(
ownerId: string,
options: { id?: string; isOwned?: boolean; isShared?: boolean; name?: string } = {},
): Promise<MapAlbumDto[]> {
return this.buildAlbumBaseQuery(ownerId, options) return this.buildAlbumBaseQuery(ownerId, options)
.selectAll('album') .selectAll('album')
.select(withAlbumUsers(ownerId)) .select(withAlbumUsers(ownerId))
.select(withSharedLink) .select(withSharedLink)
.$if(!!options.id, (qb) => qb.where('album.id', '=', options.id!))
.$if(!!options.name, (qb) => qb.where('album.albumName', '=', options.name!))
.orderBy('album.createdAt', 'desc') .orderBy('album.createdAt', 'desc')
.execute(); .execute();
} }
@@ -45,10 +45,10 @@ export class WorkflowRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
search(dto: WorkflowSearchDto & { ownerId?: string }) { search(dto: WorkflowSearchDto & { userId?: string }) {
return this.queryBuilder() return this.queryBuilder()
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!)) .$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!)) .$if(!!dto.userId, (qb) => qb.where('ownerId', '=', dto.userId!))
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!)) .$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!)) .$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
@@ -103,6 +103,10 @@ export class WorkflowRepository {
}); });
} }
async updateStep(id: string, dto: Updateable<WorkflowStepTable>) {
await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute();
}
private async replaceAndReturn(tx: Kysely<DB>, workflowId: string, steps?: WorkflowStepUpsert[]) { private async replaceAndReturn(tx: Kysely<DB>, workflowId: string, steps?: WorkflowStepUpsert[]) {
if (steps) { if (steps) {
await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute(); await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute();
+1 -1
View File
@@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
@@ -9,7 +10,6 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from '@immich/sql-tools'; } from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { WorkflowTrigger } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
@Table('workflow') @Table('workflow')
+2 -5
View File
@@ -37,15 +37,12 @@ export class AlbumService extends BaseService {
}; };
} }
async getAll( async getAll({ user: { id: ownerId } }: AuthDto, { assetId, ...rest }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
{ user: { id: ownerId } }: AuthDto,
{ assetId, isOwned, isShared }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
const albums = assetId const albums = assetId
? await this.albumRepository.getByAssetId(ownerId, assetId) ? await this.albumRepository.getByAssetId(ownerId, assetId)
: await this.albumRepository.getAll(ownerId, { isOwned, isShared }); : await this.albumRepository.getAll(ownerId, rest);
if (albums.length === 0) { if (albums.length === 0) {
return []; return [];
+103 -32
View File
@@ -1,10 +1,15 @@
import { CurrentPlugin } from '@extism/extism'; import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk'; import {
WorkflowChanges,
WorkflowEventData,
WorkflowEventPayload,
WorkflowResponse,
WorkflowTrigger,
} from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common'; import { HttpException, UnauthorizedException } from '@nestjs/common';
import _ from 'lodash';
import { join } from 'node:path'; import { join } from 'node:path';
import { OnEvent, OnJob } from 'src/decorators'; import { DummyValue, OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto'; import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
@@ -16,11 +21,11 @@ import {
JobName, JobName,
JobStatus, JobStatus,
QueueName, QueueName,
WorkflowTrigger,
WorkflowType, WorkflowType,
} from 'src/enum'; } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service'; import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types'; import { JobOf } from 'src/types';
@@ -32,9 +37,11 @@ const dummy = () => {
type ExecuteOptions<T extends WorkflowType> = { type ExecuteOptions<T extends WorkflowType> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>; read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
write: (changes: WorkflowChanges<T>) => Promise<void>; write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
}; };
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
export class WorkflowExecutionService extends BaseService { export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string; private jwtSecret!: string;
@@ -59,21 +66,26 @@ export class WorkflowExecutionService extends BaseService {
const albumService = BaseService.create(AlbumService, this); const albumService = BaseService.create(AlbumService, this);
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) => const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, args) => albumService.getAll(authDto, ...args));
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, args) => albumService.create(authDto, ...args));
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args), albumService.addAssets(authDto, ...args),
); );
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) => const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args), albumService.addAssetsToAlbums(authDto, ...args),
); );
const functions = { const functions = {
albumAddAssets, searchAlbums,
createAlbum,
addAssetsToAlbum,
addAssetsToAlbums, addAssetsToAlbums,
}; };
const stubs = { const stubs: typeof functions = {
albumAddAssets: dummy, searchAlbums: dummy,
createAlbum: dummy,
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy, addAssetsToAlbums: dummy,
}; };
@@ -247,20 +259,36 @@ export class WorkflowExecutionService extends BaseService {
} }
@OnEvent({ name: 'AssetCreate' }) @OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) { onAssetCreate({ asset: { ownerId: userId, id: assetId } }: ArgOf<'AssetCreate'>) {
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate }; return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
const items = await this.workflowRepository.search(dto); }
@OnEvent({ name: 'AssetMetadataExtracted' })
onAssetMetadataExtracted({ userId, assetId, source }: ArgOf<'AssetMetadataExtracted'>) {
// prevent loops
// TODO loop detection in job service directly
if (source === 'sidecar-write') {
return;
}
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
items.map((workflow) => ({ items.map((workflow) => ({
name: JobName.WorkflowAssetCreate, name: JobName.WorkflowAssetTrigger,
data: { workflowId: workflow.id, assetId: asset.id }, data: { workflowId: workflow.id, assetId, trigger },
})), })),
); );
} }
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow }) @OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) { handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => { return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
switch (type) { switch (type) {
case WorkflowType.AssetV1: { case WorkflowType.AssetV1: {
return { return {
@@ -271,19 +299,35 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId, authUserId: asset.ownerId,
}; };
}, },
write: async (changes) => { write: async (auth, changes) => {
if (changes.asset) { const asset = changes.asset;
await this.assetRepository.update({ if (!asset) {
id: assetId, return;
..._.omitBy(
{
isFavorite: changes.asset?.isFavorite,
visibility: changes.asset?.visibility,
},
_.isUndefined,
),
});
} }
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined,
// TODO allow setting to null
longitude: asset.exifInfo?.longitude ?? undefined,
// TODO allow setting to null
latitude: asset.exifInfo?.latitude ?? undefined,
// TODO allow setting to null
description: asset.exifInfo?.description ?? undefined,
rating: asset.exifInfo?.rating,
// TODO add to update dto
// make: asset.exifInfo?.make,
// model: asset.exifInfo?.model,
// city: asset.exifInfo?.city,
// state: asset.exifInfo?.state,
// country: asset.exifInfo?.country,
// lensModel: asset.exifInfo?.lensModel,
// fNumber: asset.exifInfo?.fNumber,
// fps: asset.exifInfo?.fps,
// iso: asset.exifInfo?.iso,
});
}, },
} satisfies ExecuteOptions<typeof type>; } satisfies ExecuteOptions<typeof type>;
} }
@@ -301,7 +345,19 @@ export class WorkflowExecutionService extends BaseService {
} }
// TODO infer from steps // TODO infer from steps
const type = 'AssetV1' as T; let type: T | undefined;
for (const targetType of Object.values(WorkflowType)) {
const missing = workflow.steps.some((step) => !step.types.includes(targetType));
if (!missing) {
type = targetType as unknown as T;
break;
}
}
if (!type) {
throw new Error('Unable to infer workflow event type from steps');
}
const handler = getHandler(type); const handler = getHandler(type);
if (!handler) { if (!handler) {
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`); this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
@@ -337,10 +393,25 @@ export class WorkflowExecutionService extends BaseService {
payload, payload,
); );
if (result?.changes) { if (result?.changes) {
await write(result.changes); await write(
{
user: {
id: readResult.authUserId,
},
session: {
id: DummyValue.UUID,
hasElevatedPermission: true,
},
} as AuthDto,
result.changes,
);
({ data } = await read(type)); ({ data } = await read(type));
} }
if (result?.config) {
await this.workflowRepository.updateStep(step.id, { config: result.config });
}
const shouldContinue = result?.workflow?.continue ?? true; const shouldContinue = result?.workflow?.continue ?? true;
if (!shouldContinue) { if (!shouldContinue) {
break; break;
+3 -3
View File
@@ -1,4 +1,4 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk'; import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
@@ -11,7 +11,7 @@ import {
WorkflowTriggerResponseDto, WorkflowTriggerResponseDto,
WorkflowUpdateDto, WorkflowUpdateDto,
} from 'src/dtos/workflow.dto'; } from 'src/dtos/workflow.dto';
import { Permission, WorkflowTrigger } from 'src/enum'; import { Permission } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository'; import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow'; import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
@@ -23,7 +23,7 @@ export class WorkflowService extends BaseService {
} }
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> { async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id }); const workflows = await this.workflowRepository.search({ ...dto, userId: auth.user.id });
return workflows.map((workflow) => mapWorkflow(workflow)); return workflows.map((workflow) => mapWorkflow(workflow));
} }
+2 -2
View File
@@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { ShallowDehydrateObject } from 'kysely'; import { ShallowDehydrateObject } from 'kysely';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants'; import { VECTOR_EXTENSIONS } from 'src/constants';
@@ -29,7 +30,6 @@ import {
TranscodeTarget, TranscodeTarget,
UserMetadataKey, UserMetadataKey,
VideoCodec, VideoCodec,
WorkflowTrigger,
WorkflowType, WorkflowType,
} from 'src/enum'; } from 'src/enum';
@@ -404,7 +404,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob } | { name: JobName.Ocr; data: IEntityJob }
// Workflow // Workflow
| { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } } | { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
// Editor // Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
+2 -1
View File
@@ -1,4 +1,5 @@
import { WorkflowTrigger, WorkflowType } from 'src/enum'; import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowType } from 'src/enum';
import { isMethodCompatible } from 'src/utils/workflow'; import { isMethodCompatible } from 'src/utils/workflow';
const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [ const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [
+3 -1
View File
@@ -1,9 +1,11 @@
import { WorkflowTrigger, WorkflowType } from 'src/enum'; import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowType } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository'; import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = { export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
[WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
}; };
export const getWorkflowTriggers = () => export const getWorkflowTriggers = () =>
@@ -1,5 +1,6 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { WorkflowTrigger, WorkflowType } from 'src/enum'; import { WorkflowType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository'; import { PluginRepository } from 'src/repositories/plugin.repository';
@@ -1,8 +1,8 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk'; import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { AssetVisibility, LogLevel, WorkflowTrigger } from 'src/enum'; import { AssetVisibility, LogLevel } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository'; import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
@@ -12,6 +12,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository'; import { PluginRepository } from 'src/repositories/plugin.repository';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { WorkflowExecutionService } from 'src/services/workflow-execution.service'; import { WorkflowExecutionService } from 'src/services/workflow-execution.service';
@@ -33,8 +34,9 @@ class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
CryptoRepository, CryptoRepository,
DatabaseRepository, DatabaseRepository,
LoggingRepository, LoggingRepository,
StorageRepository,
PluginRepository, PluginRepository,
StorageRepository,
UserRepository,
WorkflowRepository, WorkflowRepository,
], ],
mock: [ConfigRepository], mock: [ConfigRepository],
@@ -137,7 +139,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive' }], steps: [{ method: 'immich-plugin-core#assetArchive' }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Archive, visibility: AssetVisibility.Archive,
@@ -154,7 +156,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }], steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
@@ -173,7 +175,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock' }], steps: [{ method: 'immich-plugin-core#assetLock' }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Locked, visibility: AssetVisibility.Locked,
@@ -190,7 +192,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }], steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
@@ -209,7 +211,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite' }], steps: [{ method: 'immich-plugin-core#assetFavorite' }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
}); });
@@ -224,13 +226,59 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }], steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false }); await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
}); });
}); });
describe('assetAddToAlbums', () => { describe('assetAddToAlbums', () => {
it('should create an album by name', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [], albumName: 'Screenshots' } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
const albums = await ctx.get(AlbumRepository).getAll(user.id);
expect(albums).toHaveLength(1);
const album = albums[0]!;
expect(album.albumName).toEqual('Screenshots');
const updated = await ctx.get(WorkflowRepository).get(workflow.id);
expect(updated?.steps[0].config).toEqual({ albumIds: [album.id], albumName: 'Screenshots' });
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
it('should not use the name when there is an albumId', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id], albumName: 'Screenshots' } },
],
});
const albums = await ctx.get(AlbumRepository).getAll(user.id);
expect(albums).toHaveLength(1);
expect(albums[0].albumName).toEqual(album.albumName);
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
it('should add an asset to an album', async () => { it('should add an asset to an album', async () => {
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
@@ -242,7 +290,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }], steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
}); });
@@ -261,7 +309,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }], steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id);
await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id);
@@ -279,7 +327,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }], steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
}); });
@@ -64,7 +64,7 @@
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} /> <Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each} {/each}
</div> </div>
{:else if schema.uiHint === 'albumId'} {:else if schema.uiHint === 'AlbumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} /> <SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array} {:else if schema.enum && schema.array}
<Field {label} {description}> <Field {label} {description}>
@@ -2,7 +2,7 @@
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte'; import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo } from '@immich/sdk'; import { getAlbumInfo } from '@immich/sdk';
import { IconButton, LoadingSpinner } from '@immich/ui'; import { IconButton, Text, LoadingSpinner } from '@immich/ui';
import { mdiTrashCanOutline } from '@mdi/js'; import { mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -46,5 +46,22 @@
/> />
</div> </div>
</div> </div>
{:catch}
<div class="flex justify-between gap-2">
<div class="flex flex-col gap-1">
<Text>{$t('unknown')}</Text>
<Text color="muted" size="small" variant="italic">{albumId}</Text>
</div>
<div class="">
<IconButton
icon={mdiTrashCanOutline}
shape="round"
color="danger"
variant="ghost"
onclick={onDelete}
aria-label={$t('remove')}
/>
</div>
</div>
{/await} {/await}
</div> </div>

Some files were not shown because too many files have changed in this diff Show More