Compare commits

..

3 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
82 changed files with 881 additions and 3127 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
+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"
}
]
}
+1
View File
@@ -26355,6 +26355,7 @@
"description": "Plugin trigger type",
"enum": [
"AssetCreate",
"AssetMetadataExtraction",
"PersonRecognized"
],
"type": "string"
+47 -2
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": [
@@ -29,6 +29,27 @@
{
"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": []
}
}
@@ -68,6 +89,24 @@
},
"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",
"title": "Filter by file type",
@@ -189,6 +228,12 @@
"array": true,
"description": "Target album IDs",
"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"]
+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().toISOString(), status: AssetStatus.Trashed },
},
}));
// TODO use trash/untrash host functions
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
// noop
return {};
if (!config.albumName) {
return {};
}
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
}
if (config.albumIds.length === 1) {
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
return {};
}
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [data.asset.id] });
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {};
});
};
+2 -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": {
-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',
}
+56 -35
View File
@@ -1,15 +1,26 @@
import { type BulkIdResponseDto, type BulkIdsDto } from '@immich/sdk';
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;
@@ -20,39 +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<[string, BulkIdsDto], BulkIdResponseDto[]>(
'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);
+10 -9
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,7 +68,7 @@ export type AssetV1 = {
asset: {
id: string;
ownerId: string;
type: AssetType;
type: AssetTypeEnum;
originalPath: string;
fileCreatedAt: string;
fileModifiedAt: string;
@@ -83,7 +85,6 @@ export type AssetV1 = {
localDateTime: string;
stackId: string | null;
duplicateId: string | null;
status: AssetStatus;
visibility: AssetVisibility;
isEdited: boolean;
exifInfo: {
+1
View File
@@ -7081,6 +7081,7 @@ export enum WorkflowType {
}
export enum WorkflowTrigger {
AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction",
PersonRecognized = "PersonRecognized"
}
export enum QueueJobStatus {
+3
View File
@@ -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
@@ -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';
+2 -1
View File
@@ -1,6 +1,7 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { createZodDto } from 'nestjs-zod';
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
import { asPluginKey } from 'src/utils/workflow';
import z from 'zod';
+2 -2
View File
@@ -1,6 +1,6 @@
import type { WorkflowStepConfig } from '@immich/plugin-sdk';
import type { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { createZodDto } from 'nestjs-zod';
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import z from 'zod';
const WorkflowTriggerResponseSchema = z
+1 -5
View File
@@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import z from 'zod';
export enum AuthType {
@@ -1164,11 +1165,6 @@ export enum PluginContext {
export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
export const WorkflowTriggerSchema = z
.enum(WorkflowTrigger)
.describe('Plugin trigger type')
@@ -103,6 +103,10 @@ export class WorkflowRepository {
});
}
async updateStep(id: string, dto: Updateable<WorkflowStepTable>) {
await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute();
}
private async replaceAndReturn(tx: Kysely<DB>, workflowId: string, steps?: WorkflowStepUpsert[]) {
if (steps) {
await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute();
+1 -1
View File
@@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import {
Column,
CreateDateColumn,
@@ -9,7 +10,6 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { WorkflowTrigger } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
@Table('workflow')
@@ -1,9 +1,15 @@
import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import {
WorkflowChanges,
WorkflowEventData,
WorkflowEventPayload,
WorkflowResponse,
WorkflowTrigger,
} from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common';
import { join } from 'node:path';
import { DummyValue, OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
@@ -15,7 +21,6 @@ import {
JobName,
JobStatus,
QueueName,
WorkflowTrigger,
WorkflowType,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
@@ -61,7 +66,9 @@ export class WorkflowExecutionService extends BaseService {
const albumService = BaseService.create(AlbumService, this);
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, args) => albumService.getAll(authDto, ...args));
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, args) => albumService.create(authDto, ...args));
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args),
);
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
@@ -69,12 +76,16 @@ export class WorkflowExecutionService extends BaseService {
);
const functions = {
albumAddAssets,
searchAlbums,
createAlbum,
addAssetsToAlbum,
addAssetsToAlbums,
};
const stubs = {
albumAddAssets: dummy,
const stubs: typeof functions = {
searchAlbums: dummy,
createAlbum: dummy,
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy,
};
@@ -252,6 +263,17 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
}
@OnEvent({ name: 'AssetMetadataExtracted' })
onAssetMetadataExtracted({ userId, assetId, source }: ArgOf<'AssetMetadataExtracted'>) {
// prevent loops
// TODO loop detection in job service directly
if (source === 'sidecar-write') {
return;
}
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
@@ -286,6 +308,25 @@ export class WorkflowExecutionService extends BaseService {
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined,
// TODO allow setting to null
longitude: asset.exifInfo?.longitude ?? undefined,
// TODO allow setting to null
latitude: asset.exifInfo?.latitude ?? undefined,
// TODO allow setting to null
description: asset.exifInfo?.description ?? undefined,
rating: asset.exifInfo?.rating,
// TODO add to update dto
// make: asset.exifInfo?.make,
// model: asset.exifInfo?.model,
// city: asset.exifInfo?.city,
// state: asset.exifInfo?.state,
// country: asset.exifInfo?.country,
// lensModel: asset.exifInfo?.lensModel,
// fNumber: asset.exifInfo?.fNumber,
// fps: asset.exifInfo?.fps,
// iso: asset.exifInfo?.iso,
});
},
} satisfies ExecuteOptions<typeof type>;
@@ -367,6 +408,10 @@ export class WorkflowExecutionService extends BaseService {
({ data } = await read(type));
}
if (result?.config) {
await this.workflowRepository.updateStep(step.id, { config: result.config });
}
const shouldContinue = result?.workflow?.continue ?? true;
if (!shouldContinue) {
break;
+2 -2
View File
@@ -1,4 +1,4 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk';
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -11,7 +11,7 @@ import {
WorkflowTriggerResponseDto,
WorkflowUpdateDto,
} from 'src/dtos/workflow.dto';
import { Permission, WorkflowTrigger } from 'src/enum';
import { Permission } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
import { BaseService } from 'src/services/base.service';
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
+1 -1
View File
@@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { ShallowDehydrateObject } from 'kysely';
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
@@ -29,7 +30,6 @@ import {
TranscodeTarget,
UserMetadataKey,
VideoCodec,
WorkflowTrigger,
WorkflowType,
} from 'src/enum';
+2 -1
View File
@@ -1,4 +1,5 @@
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowType } from 'src/enum';
import { isMethodCompatible } from 'src/utils/workflow';
const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [
+3 -1
View File
@@ -1,9 +1,11 @@
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowType } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
[WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
};
export const getWorkflowTriggers = () =>
@@ -1,5 +1,6 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { Kysely } from 'kysely';
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { WorkflowType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
@@ -1,8 +1,8 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk';
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { Kysely } from 'kysely';
import { readFileSync } from 'node:fs';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { AssetVisibility, LogLevel, WorkflowTrigger } from 'src/enum';
import { AssetVisibility, LogLevel } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
@@ -12,6 +12,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { DB } from 'src/schema';
import { WorkflowExecutionService } from 'src/services/workflow-execution.service';
@@ -33,8 +34,9 @@ class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
CryptoRepository,
DatabaseRepository,
LoggingRepository,
StorageRepository,
PluginRepository,
StorageRepository,
UserRepository,
WorkflowRepository,
],
mock: [ConfigRepository],
@@ -231,6 +233,52 @@ describe('core plugin', () => {
});
describe('assetAddToAlbums', () => {
it('should create an album by name', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [], albumName: 'Screenshots' } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
const albums = await ctx.get(AlbumRepository).getAll(user.id);
expect(albums).toHaveLength(1);
const album = albums[0]!;
expect(album.albumName).toEqual('Screenshots');
const updated = await ctx.get(WorkflowRepository).get(workflow.id);
expect(updated?.steps[0].config).toEqual({ albumIds: [album.id], albumName: 'Screenshots' });
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
it('should not use the name when there is an albumId', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id], albumName: 'Screenshots' } },
],
});
const albums = await ctx.get(AlbumRepository).getAll(user.id);
expect(albums).toHaveLength(1);
expect(albums[0].albumName).toEqual(album.albumName);
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
it('should add an asset to an album', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
@@ -1,65 +0,0 @@
import type { Attachment } from 'svelte/attachments';
const EDGE_ZONE = 72;
const MAX_SCROLL_SPEED = 22;
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
let node = element.parentElement;
while (node) {
const overflowY = getComputedStyle(node).overflowY;
if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return null;
};
export function dragAutoScroll(isActive: () => boolean): Attachment {
return (node) => {
const element = node as HTMLElement;
let scrollContainer: HTMLElement | null = null;
let pointerY = -1;
let frame: number | null = null;
const trackPointer = (event: DragEvent) => {
pointerY = event.clientY;
};
const tick = () => {
if (scrollContainer && pointerY >= 0) {
const { top, bottom } = scrollContainer.getBoundingClientRect();
let delta = 0;
if (pointerY < top + EDGE_ZONE) {
delta = -MAX_SCROLL_SPEED * Math.min(1, (top + EDGE_ZONE - pointerY) / EDGE_ZONE);
} else if (pointerY > bottom - EDGE_ZONE) {
delta = MAX_SCROLL_SPEED * Math.min(1, (pointerY - (bottom - EDGE_ZONE)) / EDGE_ZONE);
}
if (delta !== 0) {
scrollContainer.scrollBy(0, delta);
}
}
frame = requestAnimationFrame(tick);
};
$effect(() => {
if (!isActive()) {
return;
}
scrollContainer = findScrollContainer(element);
pointerY = -1;
globalThis.addEventListener('dragover', trackPointer);
frame = requestAnimationFrame(tick);
return () => {
globalThis.removeEventListener('dragover', trackPointer);
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
scrollContainer = null;
};
});
};
}
@@ -2,7 +2,7 @@
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo } from '@immich/sdk';
import { IconButton, LoadingSpinner } from '@immich/ui';
import { IconButton, Text, LoadingSpinner } from '@immich/ui';
import { mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -46,5 +46,22 @@
/>
</div>
</div>
{:catch}
<div class="flex justify-between gap-2">
<div class="flex flex-col gap-1">
<Text>{$t('unknown')}</Text>
<Text color="muted" size="small" variant="italic">{albumId}</Text>
</div>
<div class="">
<IconButton
icon={mdiTrashCanOutline}
shape="round"
color="danger"
variant="ghost"
onclick={onDelete}
aria-label={$t('remove')}
/>
</div>
</div>
{/await}
</div>
@@ -1,6 +1,5 @@
<script lang="ts">
import { beforeNavigate, goto, invalidate } from '$app/navigation';
import { dragAutoScroll } from '$lib/attachments/drag-auto-scroll.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
@@ -8,7 +7,7 @@
import { Route } from '$lib/route';
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
import {
ActionBar,
AppShell,
@@ -44,10 +43,7 @@
mdiPlus,
} from '@mdi/js';
import { cloneDeep, isEqual } from 'lodash-es';
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n';
import { createListReorder, GHOST_KEY, type ReorderEntry } from './list-reorder.svelte';
import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
@@ -73,11 +69,6 @@
let isSaving = $state(false);
let editMode = $state<EditMode>('visual');
const reorder = createListReorder(
() => steps,
(next) => (steps = next),
);
const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
@@ -115,6 +106,19 @@
}
};
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
const from = Number(event.dataTransfer.getData('text/plain'));
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
steps = next;
};
const handleDeleteStep = async (index: number) => {
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
if (confirmed) {
@@ -340,51 +344,17 @@
</CardHeader>
</Card>
<div class="hidden" aria-hidden="true" {@attach dragAutoScroll(() => reorder.isDragging)}></div>
{#snippet stepCard(entry: ReorderEntry<WorkflowStepDto>)}
{#each steps as step, index (step.method + index)}
<WorkflowStepCard
step={entry.item}
index={entry.index}
position={entry.index + 1}
isGhost={entry.isGhost}
isSource={entry.isSource}
isDragging={reorder.isDragging}
{step}
{index}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDragStart={reorder.start}
onDragOver={reorder.over}
onDragEnd={reorder.end}
onDrop={reorder.drop}
onDrop={handleDrop}
/>
{/snippet}
{#each reorder.entries as entry (entry.isGhost ? GHOST_KEY : entry.item)}
<div class="w-full" animate:flip={{ duration: 200 }}>
{#if entry.isGhost}
<div transition:fade={{ duration: 120 }}>{@render stepCard(entry)}</div>
{:else}
{@render stepCard(entry)}
{/if}
</div>
{/each}
{#if reorder.isDragging}
<div
class="-mt-4 min-h-12 w-full"
role="listitem"
ondragover={(event) => {
event.preventDefault();
reorder.toEnd();
}}
ondrop={(event) => {
event.preventDefault();
reorder.drop();
}}
></div>
{/if}
<Button
size="small"
fullWidth
@@ -1,5 +1,25 @@
<script module lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo } from '@immich/sdk';
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const albumNameCache = new Map<string, Promise<string>>();
const getAlbumName = (id: string): Promise<string> => {
let albumName = albumNameCache.get(id);
if (!albumName) {
albumName = getAlbumInfo({ ...authManager.params, id })
.then((album) => album.albumName)
.catch(() => id);
albumNameCache.set(id, albumName);
}
return albumName;
};
</script>
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import type { JSONSchemaProperty } from '$lib/types';
import type { WorkflowStepDto } from '@immich/sdk';
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
import {
@@ -17,59 +37,27 @@
type Props = {
step: WorkflowStepDto;
index: number;
position: number;
isGhost: boolean;
isSource: boolean;
isDragging: boolean;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onInsertBefore: (index: number) => void;
onDragStart: (index: number) => void;
onDragOver: (index: number, after: boolean) => void;
onDragEnd: () => void;
onDrop: () => void;
onDrop: (index: number, event: DragEvent) => void;
};
let {
step,
index,
position,
isGhost,
isSource,
isDragging,
onEdit,
onDelete,
onInsertBefore,
onDragStart,
onDragOver,
onDragEnd,
onDrop,
}: Props = $props();
let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props();
const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
const schema = $derived(method?.schema as JSONSchemaProperty | undefined);
const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
);
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint;
const toIds = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : [String(value)]);
let dragImage = $state<Element>();
let isDropTarget = $state(false);
let hoverDrag = $state(false);
const cardStateClass = $derived.by(() => {
if (isGhost) {
return 'pointer-events-none border-2 border-dashed border-primary bg-primary-50/40 shadow-lg';
}
if (isSource) {
return 'border-dashed border-primary-300 bg-primary-50/20';
}
if (hoverDrag) {
return 'border-dashed border-primary';
}
return '';
});
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
const formatConfigValue = (value: unknown): string => {
@@ -119,31 +107,31 @@
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
event.dataTransfer.setDragImage(dragImage, 16, 22);
onDragStart(index);
};
const handleDrop = (event: DragEvent) => {
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
event.preventDefault();
onDrop();
};
const handleDragOver = (event: DragEvent & { currentTarget: HTMLElement }) => {
event.preventDefault();
if (isGhost) {
const from = Number(event.dataTransfer.getData('text/plain'));
if (from === index) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const after = event.clientY > rect.top + rect.height / 2;
onDragOver(index, after);
onDrop(index, event);
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDropTarget = true;
};
const handleDragEnd = () => {
dragImage?.remove();
dragImage = undefined;
hoverDrag = false;
onDragEnd();
isDropTarget = false;
};
</script>
@@ -154,7 +142,6 @@
<button
type="button"
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
class:hidden={isDragging}
aria-label={$t('add_step')}
title={$t('add_step')}
onclick={() => onInsertBefore(index)}
@@ -166,12 +153,20 @@
<div
class="w-full transition-all"
class:opacity-50={isSource}
class:opacity-40={!!dragImage}
class:scale-[0.99]={!!dragImage}
ondragover={handleDragOver}
ondrop={handleDrop}
ondragleave={() => (isDropTarget = false)}
ondrop={(event) => handleDrop(index, event)}
role="listitem"
>
<Card class="shadow-none transition-colors {cardStateClass}">
<Card
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: hoverDrag
? 'border-dashed border-primary'
: ''}"
>
<CardHeader>
<div class="flex items-center gap-2">
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -200,9 +195,7 @@
</div>
<div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate">
{#if !isGhost}
<span class="mr-1 font-bold text-light-500">{position}</span>
{/if}
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
{pluginManager.getMethodLabel(step.method)}
</CardTitle>
{#if method?.description}
@@ -235,15 +228,28 @@
{#if configEntries.length > 0}
<CardBody class="py-3">
<div class="flex flex-wrap items-center gap-1.5">
{#each configEntries as [key, value] (key)}
{#snippet badge(key: string, content: string)}
<Badge
color={isFilter ? 'info' : 'warning'}
shape="round"
size="small"
class="border font-mono {isFilter ? 'border-primary-200' : 'border-warning-200'}"
>
<span class="opacity-60">{key}</span>{formatConfigValue(value)}
<span class="opacity-60">{key}</span>{content}
</Badge>
{/snippet}
{#each configEntries as [key, value] (key)}
{#if getUiHint(key) === 'AlbumId'}
{#each toIds(value) as albumId (albumId)}
{#await getAlbumName(albumId)}
{@render badge($t('album'), '…')}
{:then albumName}
{@render badge($t('album'), `"${truncate(albumName)}"`)}
{/await}
{/each}
{:else}
{@render badge(key, formatConfigValue(value))}
{/if}
{/each}
</div>
</CardBody>
@@ -1,78 +0,0 @@
export const GHOST_KEY = 'reorder-ghost';
export type ReorderEntry<T> = {
item: T;
index: number;
isGhost: boolean;
isSource: boolean;
};
export function createListReorder<T>(getItems: () => T[], setItems: (items: T[]) => void) {
let draggingIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
const entries = $derived.by<ReorderEntry<T>[]>(() => {
const items = getItems();
const list: ReorderEntry<T>[] = items.map((item, index) => ({
item,
index,
isGhost: false,
isSource: index === draggingIndex,
}));
if (
draggingIndex !== null &&
dropIndex !== null &&
dropIndex !== draggingIndex &&
dropIndex !== draggingIndex + 1
) {
list.splice(dropIndex, 0, { item: items[draggingIndex], index: draggingIndex, isGhost: true, isSource: false });
}
return list;
});
return {
get isDragging() {
return draggingIndex !== null;
},
get entries() {
return entries;
},
start(index: number) {
draggingIndex = index;
dropIndex = index;
},
over(index: number, after: boolean) {
if (draggingIndex === null) {
return;
}
dropIndex = Math.max(0, Math.min(index + (after ? 1 : 0), getItems().length));
},
toEnd() {
if (draggingIndex !== null) {
dropIndex = getItems().length;
}
},
end() {
draggingIndex = null;
dropIndex = null;
},
drop() {
if (draggingIndex === null || dropIndex === null) {
return;
}
const target = dropIndex > draggingIndex ? dropIndex - 1 : dropIndex;
if (target !== draggingIndex) {
const next = [...getItems()];
const [moved] = next.splice(draggingIndex, 1);
next.splice(target, 0, moved);
setItems(next);
}
draggingIndex = null;
dropIndex = null;
},
};
}