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
143 changed files with 1315 additions and 3880 deletions
-4
View File
@@ -72,10 +72,6 @@ jobs:
run: flutter pub get
working-directory: ./mobile/packages/ui
- name: Install dependencies for UI Showcase
run: flutter pub get
working-directory: ./mobile/packages/ui/showcase
- name: Generate translation files
run: mise //mobile:codegen:translation
+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.
#### UI components and widget previews
Shared design-system widgets (buttons, inputs, forms) live in the
[`immich_ui` package](https://github.com/immich-app/immich/tree/main/mobile/packages/ui/)
under `mobile/packages/ui/`. Components are defined in `lib/src/components/`
and have matching previews in `lib/src/previews/`.
To inspect a component in isolation with a light/dark toggle and hot reload,
launch [Flutter's Widget Previewer](https://docs.flutter.dev/tools/widget-previewer):
```bash
cd mobile/packages/ui
flutter widget-preview start
```
In VS Code or Android Studio with the Flutter plugin, the previewer
auto-starts when you open the **Flutter Widget Preview** tab in the sidebar.
## IDE setup
### Lint / format extensions
@@ -95,7 +95,6 @@ describe('/server', () => {
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
prerelease: null,
});
});
});
@@ -21,18 +21,18 @@ describe('/system-config', () => {
const response1 = await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
.send({ ...config, newVersionCheck: { enabled: false } });
expect(response1.status).toBe(200);
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } });
const response2 = await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
.send({ ...config, newVersionCheck: { enabled: true } });
expect(response2.status).toBe(200);
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } });
});
it('should reject an invalid config entry', async () => {
+1 -4
View File
@@ -305,8 +305,6 @@
"refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration",
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
"release_channel_release_candidate": "Release candidate",
"release_channel_stable": "Stable",
"remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default",
@@ -444,8 +442,6 @@
"user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.",
"users_page_description": "Admin users page",
"version_check_channel": "Release channel",
"version_check_channel_description": "Pick the release channel you want to get version announcements for",
"version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with {server}",
"version_check_settings": "Version Check",
@@ -2237,6 +2233,7 @@
"slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings",
"smart_album": "Smart album",
"sort_albums_by": "Sort albums by...",
"sort_created": "Date created",
"sort_items": "Number of items",
@@ -64,6 +64,7 @@ class TextRecognizer(InferenceModel):
rec_batch_num=max_batch_size if max_batch_size else 6,
rec_img_shape=(3, 48, 320),
lang_type=self.language,
model_root_dir=self.cache_dir,
)
)
return session
+18 -3
View File
@@ -1028,7 +1028,12 @@ class TestOcr:
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
OcrOptions(
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
)
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
@@ -1041,7 +1046,12 @@ class TestOcr:
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
OcrOptions(
session=ort_session.return_value,
rec_batch_num=4,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
)
def test_ignore_other_custom_max_batch_size(
@@ -1056,7 +1066,12 @@ class TestOcr:
text_recognizer.load()
rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
OcrOptions(
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
)
+3 -3
View File
@@ -54,8 +54,8 @@ lockfile = true
[tasks.plugins]
run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build",
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core build",
]
[tasks.open-api-typescript]
@@ -108,7 +108,7 @@ depends = "//:plugins"
dir = "docker"
interactive = 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"
[tasks.prod-scale]
@@ -143,13 +143,8 @@ class AppConfig {
})
as T;
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) {
var config = const AppConfig();
for (final MapEntry(key: key, value: value) in entries.entries) {
config = config.write(key, value);
}
return config;
}
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> overrides) =>
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
return switch (key) {
+7 -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/providers/album/album_sort_by_options.provider.dart';
enum MetadataScope {
user, // keys with this scope are deleted on logout
system;
const MetadataScope();
}
enum MetadataKey<T extends Object> {
// Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
@@ -32,14 +25,11 @@ enum MetadataKey<T extends Object> {
viewerTapToNavigate<bool>(),
// Network
networkAutoEndpointSwitching<bool>(scope: .system),
networkPreferredWifiName<String>(scope: .system),
networkLocalEndpoint<String>(scope: .system),
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(
scope: .system,
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
networkAutoEndpointSwitching<bool>(),
networkPreferredWifiName<String>(),
networkLocalEndpoint<String>(),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
// Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
@@ -60,7 +50,7 @@ enum MetadataKey<T extends Object> {
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
// Map
mapShowFavoriteOnly<bool>(),
@@ -83,10 +73,9 @@ enum MetadataKey<T extends Object> {
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final MetadataScope scope;
final _MetadataCodec<T>? _codecOverride;
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
const MetadataKey({_MetadataCodec<T>? codec}) : _codecOverride = codec;
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
@@ -34,21 +34,33 @@ class MetadataRepository extends DriftDatabaseRepository {
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
Future<void> clear(Iterable<MetadataKey> keys) async {
if (keys.isEmpty) {
return;
}
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.metadataEntity)..where((row) => row.key.isIn(names))).go();
for (final key in keys) {
_appConfig = _appConfig.write(key, defaultConfig.read(key));
}
}
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (value == _appConfig.read(key)) {
return;
}
if (value == defaultConfig.read(key)) {
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
} else {
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
return clear([key]);
}
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
_appConfig = _appConfig.write(key, value);
}
@@ -2,12 +2,16 @@ import 'package:immich_mobile/utils/semver.dart';
import 'package:openapi/api.dart';
class ServerVersion extends SemVer {
const ServerVersion({required super.major, required super.minor, required super.patch, super.prerelease});
const ServerVersion({required super.major, required super.minor, required super.patch});
ServerVersion.fromDto(ServerVersionResponseDto dto)
: super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease);
@override
String toString() {
return 'ServerVersion(major: $major, minor: $minor, patch: $patch)';
}
bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) {
return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease);
ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) {
return this >= SemVer(major: major, minor: minor, patch: patch);
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class ApiService {
late ApiClient _apiClient;
final ApiClient _apiClient = ApiClient(basePath: '');
late UsersApi usersApi;
late AuthenticationApi authenticationApi;
@@ -54,7 +54,7 @@ class ApiService {
}
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint);
_apiClient.basePath = endpoint;
_apiClient.client = NetworkRepository.client;
usersApi = UsersApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient);
+7 -1
View File
@@ -110,7 +110,7 @@ class AuthService {
/// - Authentication repository data
/// - Current user information
/// - Access token
/// - Asset ETag
/// - Server-specific endpoint configuration
///
/// All deletions are executed in parallel using [Future.wait].
Future<void> clearLocalData() async {
@@ -120,6 +120,12 @@ class AuthService {
_authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
MetadataRepository.instance.clear(const [
.networkAutoEndpointSwitching,
.networkPreferredWifiName,
.networkLocalEndpoint,
.networkExternalEndpointList,
]),
]);
}
+20 -52
View File
@@ -1,42 +1,36 @@
enum SemVerType { major, minor, patch, prerelease }
enum SemVerType { major, minor, patch }
class SemVer {
final int major;
final int minor;
final int patch;
final int? prerelease;
const SemVer({required this.major, required this.minor, required this.patch, this.prerelease});
const SemVer({required this.major, required this.minor, required this.patch});
@override
String toString() {
return '$major.$minor.$patch${prerelease == null ? '' : '-rc.$prerelease'}';
return '$major.$minor.$patch';
}
SemVer copyWith({int? major, int? minor, int? patch, int? prerelease}) {
return SemVer(
major: major ?? this.major,
minor: minor ?? this.minor,
patch: patch ?? this.patch,
prerelease: prerelease ?? this.prerelease,
);
SemVer copyWith({int? major, int? minor, int? patch}) {
return SemVer(major: major ?? this.major, minor: minor ?? this.minor, patch: patch ?? this.patch);
}
static final _pattern = RegExp(r'^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?(?:[-+].*)?$', caseSensitive: false);
factory SemVer.fromString(String version) {
final match = _pattern.firstMatch(version);
if (match == null) {
if (version.toLowerCase().startsWith("v")) {
version = version.substring(1);
}
final parts = version.split("-")[0].split('.');
if (parts.length != 3) {
throw FormatException('Invalid semantic version string: $version');
}
final prerelease = match.group(4);
return SemVer(
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
prerelease: prerelease == null ? null : int.parse(prerelease),
);
try {
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
} catch (e) {
throw FormatException('Invalid semantic version string: $version');
}
}
bool operator >(SemVer other) {
@@ -46,10 +40,7 @@ class SemVer {
if (minor != other.minor) {
return minor > other.minor;
}
if (patch != other.patch) {
return patch > other.patch;
}
return _comparePrerelease(other) > 0;
return patch > other.patch;
}
bool operator <(SemVer other) {
@@ -59,23 +50,7 @@ class SemVer {
if (minor != other.minor) {
return minor < other.minor;
}
if (patch != other.patch) {
return patch < other.patch;
}
return _comparePrerelease(other) < 0;
}
int _comparePrerelease(SemVer other) {
if (prerelease == other.prerelease) {
return 0;
}
if (prerelease == null) {
return 1;
}
if (other.prerelease == null) {
return -1;
}
return prerelease!.compareTo(other.prerelease!);
return patch < other.patch;
}
bool operator >=(SemVer other) {
@@ -92,11 +67,7 @@ class SemVer {
return true;
}
return other is SemVer &&
other.major == major &&
other.minor == minor &&
other.patch == patch &&
other.prerelease == prerelease;
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
}
SemVerType? differenceType(SemVer other) {
@@ -109,13 +80,10 @@ class SemVer {
if (patch != other.patch) {
return SemVerType.patch;
}
if (prerelease != other.prerelease) {
return SemVerType.prerelease;
}
return null;
}
@override
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode ^ prerelease.hashCode;
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
}
@@ -50,7 +50,9 @@ class AppBarServerInfo extends HookConsumerWidget {
divider,
_ServerInfoItem(
label: "server_version".tr(),
text: serverInfoState.serverVersion.major > 0 ? "${serverInfoState.serverVersion}" : "--",
text: serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "--",
),
divider,
_ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true),
@@ -58,7 +60,9 @@ class AppBarServerInfo extends HookConsumerWidget {
divider,
_ServerInfoItem(
label: "latest_version".tr(),
text: serverInfoState.latestVersion!.major > 0 ? "${serverInfoState.latestVersion!}" : "--",
text: serverInfoState.latestVersion!.major > 0
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
: "--",
tooltip: true,
icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate
? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12)
-3
View File
@@ -513,9 +513,6 @@ Class | Method | HTTP request | Description
- [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [ReleaseChannel](doc//ReleaseChannel.md)
- [ReleaseEventV1](doc//ReleaseEventV1.md)
- [ReleaseType](doc//ReleaseType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- [RotateParameters](doc//RotateParameters.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
-3
View File
@@ -258,9 +258,6 @@ part 'model/ratings_response.dart';
part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
part 'model/release_channel.dart';
part 'model/release_event_v1.dart';
part 'model/release_type.dart';
part 'model/reverse_geocoding_state_response_dto.dart';
part 'model/rotate_parameters.dart';
part 'model/search_album_response_dto.dart';
+21 -3
View File
@@ -508,12 +508,18 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters)
///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
///
/// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? isOwned, bool? isShared, }) async {
///
/// * [String] name:
/// Album name (exact match)
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums';
@@ -527,12 +533,18 @@ class AlbumsApi {
if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (isOwned != null) {
queryParams.addAll(_queryParams('', 'isOwned', isOwned));
}
if (isShared != null) {
queryParams.addAll(_queryParams('', 'isShared', isShared));
}
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
const contentTypes = <String>[];
@@ -557,13 +569,19 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters)
///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
///
/// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? isOwned, bool? isShared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, isOwned: isOwned, isShared: isShared, );
///
/// * [String] name:
/// Album name (exact match)
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, id: id, isOwned: isOwned, isShared: isShared, name: name, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+1 -7
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,});
final String basePath;
String basePath;
final Authentication? authentication;
var _client = Client();
@@ -562,12 +562,6 @@ class ApiClient {
return ReactionLevelTypeTransformer().decode(value);
case 'ReactionType':
return ReactionTypeTypeTransformer().decode(value);
case 'ReleaseChannel':
return ReleaseChannelTypeTransformer().decode(value);
case 'ReleaseEventV1':
return ReleaseEventV1.fromJson(value);
case 'ReleaseType':
return ReleaseTypeTypeTransformer().decode(value);
case 'ReverseGeocodingStateResponseDto':
return ReverseGeocodingStateResponseDto.fromJson(value);
case 'RotateParameters':
-6
View File
@@ -157,12 +157,6 @@ String parameterToString(dynamic value) {
if (value is ReactionType) {
return ReactionTypeTypeTransformer().encode(value).toString();
}
if (value is ReleaseChannel) {
return ReleaseChannelTypeTransformer().encode(value).toString();
}
if (value is ReleaseType) {
return ReleaseTypeTypeTransformer().encode(value).toString();
}
if (value is SearchSuggestionType) {
return SearchSuggestionTypeTypeTransformer().encode(value).toString();
}
+3 -3
View File
@@ -77,7 +77,7 @@ class JobName {
static const versionCheck = JobName._(r'VersionCheck');
static const ocrQueueAll = JobName._(r'OcrQueueAll');
static const ocr = JobName._(r'Ocr');
static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate');
static const workflowAssetTrigger = JobName._(r'WorkflowAssetTrigger');
/// List of all possible values in this [enum][JobName].
static const values = <JobName>[
@@ -135,7 +135,7 @@ class JobName {
versionCheck,
ocrQueueAll,
ocr,
workflowAssetCreate,
workflowAssetTrigger,
];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@@ -228,7 +228,7 @@ class JobNameTypeTransformer {
case r'VersionCheck': return JobName.versionCheck;
case r'OcrQueueAll': return JobName.ocrQueueAll;
case r'Ocr': return JobName.ocr;
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate;
case r'WorkflowAssetTrigger': return JobName.workflowAssetTrigger;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
+14 -3
View File
@@ -18,6 +18,7 @@ class PluginTemplateResponseDto {
this.steps = const [],
required this.title,
required this.trigger,
this.uiHints = const [],
});
/// Template description
@@ -34,13 +35,17 @@ class PluginTemplateResponseDto {
WorkflowTrigger trigger;
/// Ui hints, for example \"smart-album\"
List<String> uiHints;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
other.description == description &&
other.key == key &&
_deepEquality.equals(other.steps, steps) &&
other.title == title &&
other.trigger == trigger;
other.trigger == trigger &&
_deepEquality.equals(other.uiHints, uiHints);
@override
int get hashCode =>
@@ -49,10 +54,11 @@ class PluginTemplateResponseDto {
(key.hashCode) +
(steps.hashCode) +
(title.hashCode) +
(trigger.hashCode);
(trigger.hashCode) +
(uiHints.hashCode);
@override
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]';
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger, uiHints=$uiHints]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -61,6 +67,7 @@ class PluginTemplateResponseDto {
json[r'steps'] = this.steps;
json[r'title'] = this.title;
json[r'trigger'] = this.trigger;
json[r'uiHints'] = this.uiHints;
return json;
}
@@ -78,6 +85,9 @@ class PluginTemplateResponseDto {
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
title: mapValueOfType<String>(json, r'title')!,
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
uiHints: json[r'uiHints'] is Iterable
? (json[r'uiHints'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
@@ -130,6 +140,7 @@ class PluginTemplateResponseDto {
'steps',
'title',
'trigger',
'uiHints',
};
}
-85
View File
@@ -1,85 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Release channel
class ReleaseChannel {
/// Instantiate a new enum with the provided [value].
const ReleaseChannel._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const stable = ReleaseChannel._(r'stable');
static const releaseCandidate = ReleaseChannel._(r'releaseCandidate');
/// List of all possible values in this [enum][ReleaseChannel].
static const values = <ReleaseChannel>[
stable,
releaseCandidate,
];
static ReleaseChannel? fromJson(dynamic value) => ReleaseChannelTypeTransformer().decode(value);
static List<ReleaseChannel> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseChannel>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseChannel.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseChannel] to String,
/// and [decode] dynamic data back to [ReleaseChannel].
class ReleaseChannelTypeTransformer {
factory ReleaseChannelTypeTransformer() => _instance ??= const ReleaseChannelTypeTransformer._();
const ReleaseChannelTypeTransformer._();
String encode(ReleaseChannel data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseChannel.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseChannel? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'stable': return ReleaseChannel.stable;
case r'releaseCandidate': return ReleaseChannel.releaseCandidate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseChannelTypeTransformer] instance.
static ReleaseChannelTypeTransformer? _instance;
}
-133
View File
@@ -1,133 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseEventV1 {
/// Returns a new [ReleaseEventV1] instance.
ReleaseEventV1({
required this.checkedAt,
required this.isAvailable,
required this.releaseVersion,
required this.serverVersion,
required this.type,
});
/// When the server last checked for a latest version. As an ISO timestamp
String checkedAt;
/// Whether a new version is available
bool isAvailable;
ServerVersionResponseDto releaseVersion;
ServerVersionResponseDto serverVersion;
ReleaseType type;
@override
bool operator ==(Object other) => identical(this, other) || other is ReleaseEventV1 &&
other.checkedAt == checkedAt &&
other.isAvailable == isAvailable &&
other.releaseVersion == releaseVersion &&
other.serverVersion == serverVersion &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(checkedAt.hashCode) +
(isAvailable.hashCode) +
(releaseVersion.hashCode) +
(serverVersion.hashCode) +
(type.hashCode);
@override
String toString() => 'ReleaseEventV1[checkedAt=$checkedAt, isAvailable=$isAvailable, releaseVersion=$releaseVersion, serverVersion=$serverVersion, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checkedAt'] = this.checkedAt;
json[r'isAvailable'] = this.isAvailable;
json[r'releaseVersion'] = this.releaseVersion;
json[r'serverVersion'] = this.serverVersion;
json[r'type'] = this.type;
return json;
}
/// Returns a new [ReleaseEventV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ReleaseEventV1? fromJson(dynamic value) {
upgradeDto(value, "ReleaseEventV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return ReleaseEventV1(
checkedAt: mapValueOfType<String>(json, r'checkedAt')!,
isAvailable: mapValueOfType<bool>(json, r'isAvailable')!,
releaseVersion: ServerVersionResponseDto.fromJson(json[r'releaseVersion'])!,
serverVersion: ServerVersionResponseDto.fromJson(json[r'serverVersion'])!,
type: ReleaseType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<ReleaseEventV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseEventV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseEventV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ReleaseEventV1> mapFromJson(dynamic json) {
final map = <String, ReleaseEventV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ReleaseEventV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ReleaseEventV1-objects as value to a dart map
static Map<String, List<ReleaseEventV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ReleaseEventV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ReleaseEventV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checkedAt',
'isAvailable',
'releaseVersion',
'serverVersion',
'type',
};
}
-103
View File
@@ -1,103 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseType {
/// Instantiate a new enum with the provided [value].
const ReleaseType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const major = ReleaseType._(r'major');
static const premajor = ReleaseType._(r'premajor');
static const minor = ReleaseType._(r'minor');
static const preminor = ReleaseType._(r'preminor');
static const patch_ = ReleaseType._(r'patch');
static const prepatch = ReleaseType._(r'prepatch');
static const prerelease = ReleaseType._(r'prerelease');
static const release = ReleaseType._(r'release');
/// List of all possible values in this [enum][ReleaseType].
static const values = <ReleaseType>[
major,
premajor,
minor,
preminor,
patch_,
prepatch,
prerelease,
release,
];
static ReleaseType? fromJson(dynamic value) => ReleaseTypeTypeTransformer().decode(value);
static List<ReleaseType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseType] to String,
/// and [decode] dynamic data back to [ReleaseType].
class ReleaseTypeTypeTransformer {
factory ReleaseTypeTypeTransformer() => _instance ??= const ReleaseTypeTypeTransformer._();
const ReleaseTypeTypeTransformer._();
String encode(ReleaseType data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'major': return ReleaseType.major;
case r'premajor': return ReleaseType.premajor;
case r'minor': return ReleaseType.minor;
case r'preminor': return ReleaseType.preminor;
case r'patch': return ReleaseType.patch_;
case r'prepatch': return ReleaseType.prepatch;
case r'prerelease': return ReleaseType.prerelease;
case r'release': return ReleaseType.release;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseTypeTypeTransformer] instance.
static ReleaseTypeTypeTransformer? _instance;
}
+6 -22
View File
@@ -16,61 +16,47 @@ class ServerVersionResponseDto {
required this.major,
required this.minor,
required this.patch_,
required this.prerelease,
});
/// Major version number
///
/// Minimum value: 0
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int major;
/// Minor version number
///
/// Minimum value: 0
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int minor;
/// Patch version number
///
/// Minimum value: 0
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int patch_;
/// Pre-release version number
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? prerelease;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto &&
other.major == major &&
other.minor == minor &&
other.patch_ == patch_ &&
other.prerelease == prerelease;
other.patch_ == patch_;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(major.hashCode) +
(minor.hashCode) +
(patch_.hashCode) +
(prerelease == null ? 0 : prerelease!.hashCode);
(patch_.hashCode);
@override
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]';
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'major'] = this.major;
json[r'minor'] = this.minor;
json[r'patch'] = this.patch_;
if (this.prerelease != null) {
json[r'prerelease'] = this.prerelease;
} else {
// json[r'prerelease'] = null;
}
return json;
}
@@ -86,7 +72,6 @@ class ServerVersionResponseDto {
major: mapValueOfType<int>(json, r'major')!,
minor: mapValueOfType<int>(json, r'minor')!,
patch_: mapValueOfType<int>(json, r'patch')!,
prerelease: mapValueOfType<int>(json, r'prerelease'),
);
}
return null;
@@ -137,7 +122,6 @@ class ServerVersionResponseDto {
'major',
'minor',
'patch',
'prerelease',
};
}
@@ -13,32 +13,26 @@ part of openapi.api;
class SystemConfigNewVersionCheckDto {
/// Returns a new [SystemConfigNewVersionCheckDto] instance.
SystemConfigNewVersionCheckDto({
required this.channel,
required this.enabled,
});
ReleaseChannel channel;
/// Enabled
bool enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto &&
other.channel == channel &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(channel.hashCode) +
(enabled.hashCode);
@override
String toString() => 'SystemConfigNewVersionCheckDto[channel=$channel, enabled=$enabled]';
String toString() => 'SystemConfigNewVersionCheckDto[enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'channel'] = this.channel;
json[r'enabled'] = this.enabled;
return json;
}
@@ -52,7 +46,6 @@ class SystemConfigNewVersionCheckDto {
final json = value.cast<String, dynamic>();
return SystemConfigNewVersionCheckDto(
channel: ReleaseChannel.fromJson(json[r'channel'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
@@ -101,7 +94,6 @@ class SystemConfigNewVersionCheckDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'channel',
'enabled',
};
}
+3
View File
@@ -24,11 +24,13 @@ class WorkflowTrigger {
String toJson() => value;
static const assetCreate = WorkflowTrigger._(r'AssetCreate');
static const assetMetadataExtraction = WorkflowTrigger._(r'AssetMetadataExtraction');
static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
/// List of all possible values in this [enum][WorkflowTrigger].
static const values = <WorkflowTrigger>[
assetCreate,
assetMetadataExtraction,
personRecognized,
];
@@ -69,6 +71,7 @@ class WorkflowTriggerTypeTransformer {
if (data != null) {
switch (data) {
case r'AssetCreate': return WorkflowTrigger.assetCreate;
case r'AssetMetadataExtraction': return WorkflowTrigger.assetMetadataExtraction;
case r'PersonRecognized': return WorkflowTrigger.personRecognized;
default:
if (!allowNull) {
+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
version: "15.2.0"
sdks:
dart: ">=3.11.0 <4.0.0"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_ui
publish_to: none
environment:
sdk: '>=3.11.0 <4.0.0'
sdk: '>=3.12.0 <4.0.0'
dependencies:
flutter:
-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"
}
]
}
@@ -116,7 +116,7 @@ void main() {
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0));
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
@@ -559,7 +559,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
@@ -587,7 +587,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0));
await sut.sync();
verifyInOrder([
@@ -617,7 +617,7 @@ void main() {
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
-66
View File
@@ -88,71 +88,5 @@ void main() {
expect(version2.minor, 2);
expect(version2.patch, 3);
});
test('Orders later prerelease above earlier prerelease', () {
const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc2 > rc1, isTrue);
expect(rc1 < rc2, isTrue);
expect(rc1 == rc2, isFalse);
});
test('Final release outranks its prerelease of the same version', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const release = SemVer(major: 1, minor: 151, patch: 0);
expect(release > rc, isTrue);
expect(rc < release, isTrue);
});
test('Higher major outranks a prerelease regardless of ordinal', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 9);
const next = SemVer(major: 2, minor: 0, patch: 0);
expect(next > rc, isTrue);
});
test('Equal prerelease versions compare as equal', () {
const a = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3);
const b = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3);
expect(a == b, isTrue);
expect(a > b, isFalse);
expect(a < b, isFalse);
});
test('Reports prerelease difference type', () {
const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc1.differenceType(rc2), SemVerType.prerelease);
});
test('toString includes prerelease suffix when present', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc.toString(), '1.151.0-rc.2');
});
test('Parses prerelease ordinal from -rc strings', () {
final dotted = SemVer.fromString('1.151.0-rc.2');
expect(dotted.major, 1);
expect(dotted.minor, 151);
expect(dotted.patch, 0);
expect(dotted.prerelease, 2);
expect(SemVer.fromString('v1.151.0-rc.3').prerelease, 3);
expect(SemVer.fromString('1.2.3-rc.2+build.5').prerelease, 2);
});
test('Plain version string has null prerelease', () {
expect(SemVer.fromString('3.0.0').prerelease, isNull);
});
test('Invalid rc suffixes parse without error and have null prerelease', () {
final debug = SemVer.fromString('1.2.3-debug');
expect(debug.major, 1);
expect(debug.minor, 2);
expect(debug.patch, 3);
expect(debug.prerelease, isNull);
expect(SemVer.fromString('1.2.3+build.5').prerelease, isNull);
expect(SemVer.fromString('1.151.0-rc4').prerelease, isNull);
});
});
}
+35 -76
View File
@@ -1627,6 +1627,17 @@
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "isOwned",
"required": false,
@@ -1644,6 +1655,15 @@
"schema": {
"type": "boolean"
}
},
{
"name": "name",
"required": false,
"in": "query",
"description": "Album name (exact match)",
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -18151,7 +18171,7 @@
"VersionCheck",
"OcrQueueAll",
"Ocr",
"WorkflowAssetCreate"
"WorkflowAssetTrigger"
],
"type": "string"
},
@@ -20199,6 +20219,13 @@
"trigger": {
"$ref": "#/components/schemas/WorkflowTrigger",
"description": "Workflow trigger"
},
"uiHints": {
"description": "Ui hints, for example \"smart-album\"",
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
@@ -20206,7 +20233,8 @@
"key",
"steps",
"title",
"trigger"
"trigger",
"uiHints"
],
"type": "object"
},
@@ -20800,58 +20828,6 @@
],
"type": "string"
},
"ReleaseChannel": {
"description": "Release channel",
"enum": [
"stable",
"releaseCandidate"
],
"type": "string"
},
"ReleaseEventV1": {
"properties": {
"checkedAt": {
"description": "When the server last checked for a latest version. As an ISO timestamp",
"type": "string"
},
"isAvailable": {
"description": "Whether a new version is available",
"type": "boolean"
},
"releaseVersion": {
"$ref": "#/components/schemas/ServerVersionResponseDto"
},
"serverVersion": {
"$ref": "#/components/schemas/ServerVersionResponseDto"
},
"type": {
"$ref": "#/components/schemas/ReleaseType",
"description": "Release type",
"nullable": true
}
},
"required": [
"checkedAt",
"isAvailable",
"releaseVersion",
"serverVersion",
"type"
],
"type": "object"
},
"ReleaseType": {
"enum": [
"major",
"premajor",
"minor",
"preminor",
"patch",
"prepatch",
"prerelease",
"release"
],
"type": "string"
},
"ReverseGeocodingStateResponseDto": {
"properties": {
"lastImportFileName": {
@@ -21521,40 +21497,26 @@
"major": {
"description": "Major version number",
"maximum": 9007199254740991,
"minimum": 0,
"minimum": -9007199254740991,
"type": "integer"
},
"minor": {
"description": "Minor version number",
"maximum": 9007199254740991,
"minimum": 0,
"minimum": -9007199254740991,
"type": "integer"
},
"patch": {
"description": "Patch version number",
"maximum": 9007199254740991,
"minimum": 0,
"minimum": -9007199254740991,
"type": "integer"
},
"prerelease": {
"description": "Pre-release version number",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer",
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Added"
}
]
}
},
"required": [
"major",
"minor",
"patch",
"prerelease"
"patch"
],
"type": "object"
},
@@ -24575,16 +24537,12 @@
},
"SystemConfigNewVersionCheckDto": {
"properties": {
"channel": {
"$ref": "#/components/schemas/ReleaseChannel"
},
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"channel",
"enabled"
],
"type": "object"
@@ -26397,6 +26355,7 @@
"description": "Plugin trigger type",
"enum": [
"AssetCreate",
"AssetMetadataExtraction",
"PersonRecognized"
],
"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 @@
);
}
+62 -16
View File
@@ -7,8 +7,8 @@
"wasmPath": "dist/plugin.wasm",
"templates": [
{
"name": "auto-archive-screenshots",
"title": "Auto-archive screenshots",
"name": "screenshots-smart-album",
"title": "Archive screenshots",
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
"trigger": "AssetCreate",
"steps": [
@@ -20,19 +20,41 @@
"caseSensitive": false
}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
},
{
"method": "immich-plugin-core#assetArchive",
"config": {
"inverse": false
}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumName": "Screenshots",
"albumIds": []
}
}
]
],
"uiHints": ["SmartAlbum"]
},
{
"name": "missing-timezone-smart-album",
"title": "Missing timezone",
"description": "Automatically create an album for assets without a time zone",
"trigger": "AssetMetadataExtraction",
"steps": [
{
"method": "immich-plugin-core#assetMissingTimeZoneFilter",
"config": {}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumName": "Missing time zone",
"albumIds": []
}
}
],
"uiHints": ["SmartAlbum"]
}
],
"methods": [
@@ -65,7 +87,25 @@
},
"required": ["pattern"]
},
"uiHints": ["filter"]
"uiHints": ["Filter"]
},
{
"name": "assetMissingTimeZoneFilter",
"title": "Filter by missing time zone",
"description": "Filter assets that have no time zone information",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"inverse": {
"type": "boolean",
"title": "Inverse",
"description": "Missing by default, set to true to filter assets with a time zone",
"default": false
}
}
},
"uiHints": ["Filter"]
},
{
"name": "filterFileType",
@@ -85,7 +125,7 @@
},
"required": ["fileTypes"]
},
"uiHints": ["filter"]
"uiHints": ["Filter"]
},
{
"name": "filterPerson",
@@ -99,7 +139,7 @@
"array": true,
"title": "Person IDs",
"description": "List of person to match",
"uiHint": "personI"
"uiHint": "personId"
},
"matchAny": {
"type": "boolean",
@@ -110,7 +150,7 @@
},
"required": ["personIds"]
},
"uiHints": ["filter"]
"uiHints": ["Filter"]
},
{
"name": "assetArchive",
@@ -187,7 +227,13 @@
"title": "Album IDs",
"array": true,
"description": "Target album IDs",
"uiHint": "albumId"
"uiHint": "AlbumId"
},
"albumName": {
"type": "string",
"title": "Album name",
"array": true,
"description": "Use an album with this name if one exists, otherwise create a new one"
}
},
"required": ["albumIds"]
@@ -272,14 +318,14 @@
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"uiHint": "albumId"
"uiHint": "AlbumId"
},
"albumIds": {
"type": "string",
"title": "Album IDs",
"description": "Target album IDs",
"array": true,
"uiHint": "albumId"
"uiHint": "AlbumId"
}
}
}
+1
View File
@@ -13,6 +13,7 @@
"license": "AGPL-3.0",
"devDependencies": {
"@extism/js-pdk": "^1.0.1",
"@immich/sdk": "workspace:*",
"@immich/plugin-sdk": "workspace:*",
"esbuild": "^0.28.0",
"typescript": "^6.0.0"
+9 -3
View File
@@ -1,14 +1,20 @@
// copy from
// import '@immich/plugin-sdk/host-functions';
// keep in sync with plugin-sdk/host-functions.ts';
declare module 'extism:host' {
interface user {
albumAddAssets(ptr: PTR): I64;
searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
}
}
// keep in sync with manifest.json
declare module 'main' {
// filters
export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
// updates
export function assetFavorite(): I32;
export function assetVisibility(): I32;
export function assetArchive(): I32;
+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 = {
pattern: string;
@@ -41,6 +42,14 @@ export const assetFileFilter = () => {
});
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
});
};
export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true;
@@ -89,28 +98,35 @@ export const assetLock = () => {
};
export const assetTrash = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => ({
changes: {
asset: config.inverse
? { deletedAt: null, status: AssetStatus.Active }
: { deletedAt: new Date(), status: AssetStatus.Trashed },
},
}));
// TODO use trash/untrash host functions
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
// noop
return {};
if (!config.albumName) {
return {};
}
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
}
if (config.albumIds.length === 1) {
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
return {};
}
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [data.asset.id] });
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {};
});
};
+3 -2
View File
@@ -2,7 +2,6 @@
"name": "@immich/plugin-sdk",
"version": "0.0.0",
"description": "",
"main": "index.js",
"type": "module",
"exports": {
"./host-functions": {
@@ -11,7 +10,8 @@
},
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
@@ -27,6 +27,7 @@
"packageManager": "pnpm@10.33.4",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
"@types/node": "^24.12.4",
"esbuild": "^0.28.0",
"tsc-alias": "^1.8.16",
-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' {
interface user {
albumAddAssets(ptr: PTR): I64;
searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
}
}
const host = Host.getFunctions();
type HostFunctionName = keyof typeof host;
type AlbumsToAssets = {
assetIds: string[];
albumIds: string[];
};
type HostFunctionSuccessResult<T> = { success: true; response: T };
type HostFunctionErrorResult = {
success: false;
@@ -17,35 +31,49 @@ type HostFunctionResult<T> =
| HostFunctionSuccessResult<T>
| HostFunctionErrorResult;
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
const fn = host[name];
const handler = Memory.find(fn(pointer1.offset));
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
try {
const result = JSON.parse(handler.readString()) as HostFunctionResult<R>;
export const hostFunctions = (authToken: string) => {
const host = Host.getFunctions();
type HostFunctionName = keyof typeof host;
if (result.success) {
return result.response;
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
const fn = host[name];
const handler = Memory.find(fn(pointer1.offset));
try {
const result = JSON.parse(handler.readString()) as HostFunctionResult<R>;
if (result.success) {
return result.response;
}
throw new Error(
`Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`,
);
} finally {
handler.free();
pointer1.free();
}
};
throw new Error(
`Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`,
);
} finally {
handler.free();
pointer1.free();
}
return {
// album
searchAlbums: (dto: AlbumSearchDto) =>
call<[AlbumSearchDto], AlbumResponseDto[]>('searchAlbums', authToken, [
dto,
]),
createAlbum: (dto: CreateAlbumDto) =>
call<[CreateAlbumDto], AlbumResponseDto>('createAlbum', authToken, [dto]),
addAssetsToAlbum: (albumId: string, assetIds: string[]) =>
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
'addAssetsToAlbum',
authToken,
[albumId, { ids: assetIds }],
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
};
};
type AlbumsToAssets = {
assetIds: string[];
albumIds: string[];
};
export const hostFunctions = (authToken: string) => ({
albumAddAssets: (albumId: string, assetIds: string[]) =>
call('albumAddAssets', authToken, [albumId, { ids: assetIds }]),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
});
-1
View File
@@ -1,4 +1,3 @@
export * from 'src/enum.js';
export * from 'src/host-functions.js';
export * from 'src/sdk.js';
export * from 'src/types.js';
+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 type {
ConfigValue,
WorkflowEventPayload,
WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js';
export const wrapper = <
@@ -19,19 +20,28 @@ export const wrapper = <
const input = Host.inputString();
try {
const event = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
// const debug = event.workflow.debug ?? false;
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${JSON.stringify(event.config)}`,
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response =
fn({ ...event, functions: hostFunctions(event.workflow.authToken) }) ??
{};
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}`,
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
+19 -18
View File
@@ -1,10 +1,4 @@
import type {
AssetStatus,
AssetType,
AssetVisibility,
WorkflowTrigger,
WorkflowType,
} from 'src/enum.js';
import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
type DeepPartial<T> = T extends Date
? T
@@ -21,6 +15,12 @@ export type WorkflowEventMap = {
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction',
PersonRecognized = 'PersonRecognized',
}
export type WorkflowEventPayload<
T extends WorkflowType = WorkflowType,
TConfig = WorkflowStepConfig,
@@ -48,6 +48,8 @@ export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
changes?: WorkflowChanges<T>;
/** data to be passed to the next workflow step */
data?: Record<string, unknown>;
/** update step config */
config?: WorkflowStepConfig;
};
export type WorkflowStepConfig = {
@@ -66,24 +68,23 @@ export type AssetV1 = {
asset: {
id: string;
ownerId: string;
type: AssetType;
type: AssetTypeEnum;
originalPath: string;
fileCreatedAt: Date;
fileModifiedAt: Date;
fileCreatedAt: string;
fileModifiedAt: string;
isFavorite: boolean;
checksum: Buffer; // sha1 checksum
livePhotoVideoId: string | null;
updatedAt: Date;
createdAt: Date;
updatedAt: string;
createdAt: string;
originalFileName: string;
isOffline: boolean;
libraryId: string | null;
isExternal: boolean;
deletedAt: Date | null;
localDateTime: Date;
deletedAt: string | null;
localDateTime: string;
stackId: string | null;
duplicateId: string | null;
status: AssetStatus;
visibility: AssetVisibility;
isEdited: boolean;
exifInfo: {
@@ -93,8 +94,8 @@ export type AssetV1 = {
exifImageHeight: number | null;
fileSizeInByte: number | null;
orientation: string | null;
dateTimeOriginal: Date | null;
modifyDate: Date | null;
dateTimeOriginal: string | null;
modifyDate: string | null;
lensModel: string | null;
fNumber: number | null;
focalLength: number | null;
@@ -116,7 +117,7 @@ export type AssetV1 = {
autoStackId: string | null;
rating: number | null;
tags: string[] | null;
updatedAt: Date | null;
updatedAt: string | null;
} | null;
};
};
+10 -30
View File
@@ -1535,6 +1535,8 @@ export type PluginTemplateResponseDto = {
title: string;
/** Workflow trigger */
trigger: WorkflowTrigger;
/** Ui hints, for example "smart-album" */
uiHints: string[];
};
export type QueueResponseDto = {
/** Whether the queue is paused */
@@ -2074,8 +2076,6 @@ export type ServerVersionResponseDto = {
minor: number;
/** Patch version number */
patch: number;
/** Pre-release version number */
prerelease: number | null;
};
export type VersionCheckStateResponseDto = {
/** Last check timestamp */
@@ -2423,7 +2423,6 @@ export type SystemConfigMetadataDto = {
faces: SystemConfigFacesDto;
};
export type SystemConfigNewVersionCheckDto = {
channel: ReleaseChannel;
/** Enabled */
enabled: boolean;
};
@@ -2769,16 +2768,6 @@ export type WorkflowShareResponseDto = {
trigger: WorkflowTrigger;
};
export type LicenseResponseDto = UserLicense;
export type ReleaseEventV1 = {
/** When the server last checked for a latest version. As an ISO timestamp */
checkedAt: string;
/** Whether a new version is available */
isAvailable: boolean;
releaseVersion: ServerVersionResponseDto;
serverVersion: ServerVersionResponseDto;
/** Release type */
"type": ReleaseType;
};
export type SyncAckV1 = {};
export type SyncAlbumDeleteV1 = {
/** Album ID */
@@ -3610,18 +3599,22 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/**
* List all albums
*/
export function getAllAlbums({ assetId, isOwned, isShared }: {
export function getAllAlbums({ assetId, id, isOwned, isShared, name }: {
assetId?: string;
id?: string;
isOwned?: boolean;
isShared?: boolean;
name?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({
assetId,
id,
isOwned,
isShared
isShared,
name
}))}`, {
...opts
}));
@@ -7088,6 +7081,7 @@ export enum WorkflowType {
}
export enum WorkflowTrigger {
AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction",
PersonRecognized = "PersonRecognized"
}
export enum QueueJobStatus {
@@ -7153,7 +7147,7 @@ export enum JobName {
VersionCheck = "VersionCheck",
OcrQueueAll = "OcrQueueAll",
Ocr = "Ocr",
WorkflowAssetCreate = "WorkflowAssetCreate"
WorkflowAssetTrigger = "WorkflowAssetTrigger"
}
export enum SearchSuggestionType {
Country = "country",
@@ -7318,10 +7312,6 @@ export enum LogLevel {
Error = "error",
Fatal = "fatal"
}
export enum ReleaseChannel {
Stable = "stable",
ReleaseCandidate = "releaseCandidate"
}
export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
@@ -7330,16 +7320,6 @@ export enum AssetOrderBy {
TakenAt = "takenAt",
CreatedAt = "createdAt"
}
export enum ReleaseType {
Major = "major",
Premajor = "premajor",
Minor = "minor",
Preminor = "preminor",
Patch = "patch",
Prepatch = "prepatch",
Prerelease = "prerelease",
Release = "release"
}
export enum UserMetadataKey {
Preferences = "preferences",
License = "license",
+34 -33
View File
@@ -10,7 +10,7 @@ overrides:
sharp: ^0.34.5
webpackbar: ^7.0.0
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k=
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
@@ -320,6 +320,9 @@ importers:
'@immich/plugin-sdk':
specifier: workspace:*
version: link:../plugin-sdk
'@immich/sdk':
specifier: workspace:*
version: link:../sdk
esbuild:
specifier: ^0.28.0
version: 0.28.0
@@ -332,6 +335,9 @@ importers:
'@extism/js-pdk':
specifier: ^1.1.1
version: 1.1.1
'@immich/sdk':
specifier: workspace:*
version: link:../sdk
'@types/node':
specifier: ^24.12.4
version: 24.12.4
@@ -389,7 +395,7 @@ importers:
version: 6.1.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/swagger':
specifier: ^11.4.2
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
'@nestjs/websockets':
specifier: ^11.0.4
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-socket.io@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -536,7 +542,7 @@ importers:
version: 8.0.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
nestjs-zod:
specifier: ^5.3.0
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6)
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6)
nodemailer:
specifier: ^8.0.0
version: 8.0.7
@@ -571,8 +577,8 @@ importers:
specifier: ^1.6.3
version: 1.6.4
semver:
specifier: ^7.8.1
version: 7.8.1
specifier: ^7.6.2
version: 7.8.0
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -3795,6 +3801,7 @@ packages:
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
typescript: '*'
peerDependenciesMeta:
'@fastify/static':
optional: true
@@ -11243,11 +11250,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.8.1:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'}
hasBin: true
send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
@@ -16305,7 +16307,7 @@ snapshots:
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.8.1
semver: 7.8.0
tar: 6.2.1
transitivePeerDependencies:
- encoding
@@ -16575,7 +16577,7 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)':
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16586,6 +16588,7 @@ snapshots:
path-to-regexp: 8.4.2
reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.6
typescript: 6.0.3
'@nestjs/testing@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)':
dependencies:
@@ -17762,7 +17765,7 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/runtime': 7.29.7
'@babel/runtime': 7.29.2
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
@@ -18466,7 +18469,7 @@ snapshots:
'@typescript-eslint/visitor-keys': 8.59.4
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.1
semver: 7.8.0
tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
@@ -19566,7 +19569,7 @@ snapshots:
dot-prop: 10.1.0
env-paths: 3.0.0
json-schema-typed: 8.0.2
semver: 7.8.1
semver: 7.8.0
uint8array-extras: 1.5.0
config-chain@1.1.13:
@@ -19738,7 +19741,7 @@ snapshots:
postcss-modules-scope: 3.2.1(postcss@8.5.15)
postcss-modules-values: 4.0.0(postcss@8.5.15)
postcss-value-parser: 4.2.0
semver: 7.8.1
semver: 7.8.0
optionalDependencies:
webpack: 5.107.0(postcss@8.5.15)
@@ -20606,7 +20609,7 @@ snapshots:
find-up: 5.0.0
globals: 15.15.0
lodash.memoize: 4.1.2
semver: 7.8.1
semver: 7.8.0
eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(prettier@3.8.3):
dependencies:
@@ -20629,7 +20632,7 @@ snapshots:
postcss: 8.5.15
postcss-load-config: 3.1.4(postcss@8.5.15)
postcss-safe-parser: 7.0.1(postcss@8.5.15)
semver: 7.8.1
semver: 7.8.0
svelte-eslint-parser: 1.6.1(svelte@5.55.8(@typescript-eslint/types@8.59.4))
optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -21107,7 +21110,7 @@ snapshots:
minimatch: 3.1.5
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.8.1
semver: 7.8.0
tapable: 2.3.3
typescript: 5.9.3
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
@@ -21543,7 +21546,7 @@ snapshots:
history@4.10.1:
dependencies:
'@babel/runtime': 7.29.7
'@babel/runtime': 7.29.2
loose-envify: 1.4.0
resolve-pathname: 3.0.0
tiny-invariant: 1.3.3
@@ -22131,7 +22134,7 @@ snapshots:
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.8.1
semver: 7.8.0
just-compare@2.3.0: {}
@@ -22417,7 +22420,7 @@ snapshots:
make-dir@4.0.0:
dependencies:
semver: 7.8.1
semver: 7.8.0
maplibre-gl@5.24.0:
dependencies:
@@ -23234,14 +23237,14 @@ snapshots:
'@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1)
tslib: 2.8.1
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6):
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6):
dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1
rxjs: 7.8.2
zod: 4.3.6
optionalDependencies:
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
next-tick@1.1.0: {}
@@ -23252,7 +23255,7 @@ snapshots:
node-abi@3.92.0:
dependencies:
semver: 7.8.1
semver: 7.8.0
optional: true
node-abort-controller@3.1.1: {}
@@ -23293,7 +23296,7 @@ snapshots:
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.1
semver: 7.8.0
tar: 7.5.15
tinyglobby: 0.2.16
undici: 6.25.0
@@ -23531,7 +23534,7 @@ snapshots:
got: 12.6.1
registry-auth-token: 5.1.1
registry-url: 6.0.1
semver: 7.8.1
semver: 7.8.0
package-manager-detector@1.6.0: {}
@@ -23919,7 +23922,7 @@ snapshots:
cosmiconfig: 8.3.6(typescript@6.0.3)
jiti: 1.21.7
postcss: 8.5.15
semver: 7.8.1
semver: 7.8.0
webpack: 5.107.0(postcss@8.5.15)
transitivePeerDependencies:
- typescript
@@ -24974,14 +24977,12 @@ snapshots:
semver-diff@4.0.0:
dependencies:
semver: 7.8.1
semver: 7.8.0
semver@6.3.1: {}
semver@7.8.0: {}
semver@7.8.1: {}
send@0.19.2:
dependencies:
debug: 2.6.9
@@ -25516,7 +25517,7 @@ snapshots:
postcss: 8.5.15
postcss-scss: 4.0.9(postcss@8.5.15)
postcss-selector-parser: 7.1.1
semver: 7.8.1
semver: 7.8.0
optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -26224,7 +26225,7 @@ snapshots:
is-yarn-global: 0.4.1
latest-version: 7.0.0
pupa: 3.3.0
semver: 7.8.1
semver: 7.8.0
semver-diff: 4.0.0
xdg-basedir: 5.1.0
+3
View File
@@ -60,6 +60,9 @@ packageExtensions:
dependencies:
node-addon-api: '*'
node-gyp: '*'
'@nestjs/swagger':
peerDependencies:
typescript: '*'
dedupePeerDependents: false
preferWorkspacePackages: true
injectWorkspacePackages: true
+4 -2
View File
@@ -13,14 +13,15 @@ FROM builder AS server
WORKDIR /usr/src/app
COPY ./server ./server/
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
FROM builder AS web
@@ -66,6 +67,7 @@ ENV MISE_DISABLE_TOOLS=flutter
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-core ./packages/plugin-core/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
+1
View File
@@ -68,6 +68,7 @@ run = [
[tasks.ci-medium]
run = [
{ task = ":install" },
{ task = "//:plugins" },
{ task = "//packages/plugin-core:build" },
{ task = ":test-medium --run" },
]
+1 -1
View File
@@ -106,7 +106,7 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.8.1",
"semver": "^7.6.2",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
-3
View File
@@ -1,5 +1,4 @@
import { CronExpression } from '@nestjs/schedule';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import {
AudioCodec,
Colorspace,
@@ -136,7 +135,6 @@ export type SystemConfig = {
};
newVersionCheck: {
enabled: boolean;
channel: ReleaseChannel;
};
nightlyTasks: {
startTime: string;
@@ -346,7 +344,6 @@ export const defaults = Object.freeze<SystemConfig>({
},
newVersionCheck: {
enabled: true,
channel: ReleaseChannel.Stable,
},
nightlyTasks: {
startTime: '00:00',
@@ -1,5 +1,5 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowController } from 'src/controllers/workflow.controller';
import { WorkflowTrigger } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { WorkflowService } from 'src/services/workflow.service';
import request from 'supertest';
-10
View File
@@ -265,13 +265,3 @@ export class HistoryBuilder {
return this;
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export const extraModels: Function[] = [];
export const ExtraModel = (): ClassDecorator => {
// eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-unsafe-function-type
return (object: Function) => {
extraModels.push(object);
};
};

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