mirror of
https://github.com/immich-app/immich.git
synced 2026-02-28 09:38:43 +03:00
feat(mobile): html text (#25739)
* feat: html text * feat: mobile ui showcase (#25827) * feat: mobile ui showcase * remove showcase from main app * update fonts * update code to be loaded from asset * fix ci --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> # Conflicts: # mobile/lib/widgets/common/immich_sliver_app_bar.dart --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
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: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class HtmlTextBoldText extends StatelessWidget {
|
||||
const HtmlTextBoldText({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichHtmlText(
|
||||
'This is <b>bold text</b> and <strong>strong text</strong>.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class HtmlTextLinks extends StatelessWidget {
|
||||
const HtmlTextLinks({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichHtmlText(
|
||||
'Read the <docs-link>documentation</docs-link> or visit <github-link>GitHub</github-link>.',
|
||||
linkHandlers: {
|
||||
'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!')));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class HtmlTextNestedTags extends StatelessWidget {
|
||||
const HtmlTextNestedTags({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichHtmlText(
|
||||
'You can <b>combine <link>bold and links</link></b> together.',
|
||||
linkHandlers: {
|
||||
'link': () {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Nested link clicked!')));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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!';
|
||||
});
|
||||
},
|
||||
child: 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_result.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_result, style: const TextStyle(color: Colors.green)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:showcase/pages/components/examples/html_text_bold_text.dart';
|
||||
import 'package:showcase/pages/components/examples/html_text_links.dart';
|
||||
import 'package:showcase/pages/components/examples/html_text_nested_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 HtmlTextPage extends StatelessWidget {
|
||||
const HtmlTextPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.htmlText.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichHtmlText',
|
||||
subtitle: 'Render text with HTML formatting (bold, links).',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Bold Text',
|
||||
preview: const HtmlTextBoldText(),
|
||||
code: 'html_text_bold_text.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Links',
|
||||
preview: const HtmlTextLinks(),
|
||||
code: 'html_text_links.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Nested Tags',
|
||||
preview: const HtmlTextNestedTags(),
|
||||
code: 'html_text_nested_tags.dart',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
118
mobile/packages/ui/showcase/lib/pages/home_page.dart
Normal file
118
mobile/packages/ui/showcase/lib/pages/home_page.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user