Compare commits

...

5 Commits

Author SHA1 Message Date
shenlong 5b49a65af4 feat: ui menu item (#29266)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-22 16:33:18 -04:00
shenlong-tanwen d32cff8f2c feat: column button 2026-06-23 01:04:32 +05:30
shenlong-tanwen a66852e2b5 feat: ui color override 2026-06-23 00:59:09 +05:30
shenlong-tanwen 4cf71ed752 chore: cleanup 2026-06-22 23:43:40 +05:30
shenlong-tanwen 05aff251d0 refactor: icon buttons implicit loading 2026-06-22 23:43:40 +05:30
15 changed files with 415 additions and 147 deletions
+2
View File
@@ -1,7 +1,9 @@
export 'src/components/close_button.dart';
export 'src/components/column_button.dart';
export 'src/components/form.dart';
export 'src/components/formatted_text.dart';
export 'src/components/icon_button.dart';
export 'src/components/menu_item.dart';
export 'src/components/password_input.dart';
export 'src/components/text_button.dart';
export 'src/components/text_input.dart';
@@ -0,0 +1,13 @@
import 'package:flutter/widgets.dart';
class ImmichColorOverride extends InheritedWidget {
const ImmichColorOverride({super.key, required this.color, required super.child});
final Color color;
static Color? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ImmichColorOverride>()?.color;
@override
bool updateShouldNotify(ImmichColorOverride oldWidget) => color != oldWidget.color;
}
@@ -16,10 +16,9 @@ class ImmichCloseButton extends StatelessWidget {
@override
Widget build(BuildContext context) => ImmichIconButton(
key: key,
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
}
@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
final bool? loading;
const ImmichColumnButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
this.loading,
});
@override
State<ImmichColumnButton> createState() => _ImmichColumnButtonState();
}
class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
return TextButton(
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
tapTargetSize: .shrinkWrap,
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.xl))),
),
child: ConstrainedBox(
constraints: const .new(maxWidth: 90),
child: Column(
mainAxisSize: .min,
children: [
_isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: Icon(widget.icon, size: ImmichIconSize.md),
const SizedBox(height: ImmichSpacing.sm),
Text(
widget.label,
maxLines: 2,
textAlign: .center,
overflow: .ellipsis,
style: const .new(fontSize: ImmichTextSize.label, fontWeight: .w500),
),
],
),
),
);
}
}
@@ -88,7 +88,7 @@ class _ImmichFormState extends State<ImmichForm> {
builder: (context, _) => ImmichTextButton(
labelText: submitText,
icon: widget.submitIcon,
variant: ImmichVariant.filled,
variant: .filled,
loading: _controller.isLoading,
onPressed: _controller.submit,
disabled: _controller.onSubmit == null,
@@ -94,12 +94,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
final tag = match.group(1)!.toLowerCase();
final content = match.group(2)!;
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
final style = formattedSpan.style ?? _defaultTextStyle(tag);
final span = widget.spanBuilder?.call(tag);
final style = span?.style ?? _defaultTextStyle(tag);
GestureRecognizer? recognizer;
if (formattedSpan.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
if (span?.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = span!.onTap;
_recognizers.add(recognizer);
}
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
@@ -114,19 +114,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
return spans;
}
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ => const FormattedSpan(),
};
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
@override
Widget build(BuildContext context) {
@@ -1,54 +1,80 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/types.dart';
import 'dart:async';
class ImmichIconButton extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichIconButton extends StatefulWidget {
final IconData icon;
final VoidCallback onPressed;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
final bool? loading;
const ImmichIconButton({
super.key,
required this.icon,
required this.onPressed,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
this.color = .primary,
this.variant = .filled,
this.disabled = false,
this.loading,
});
@override
State<ImmichIconButton> createState() => _ImmichIconButtonState();
}
class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
final background = switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
.ghost => Colors.transparent,
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.onPrimary,
ImmichColor.secondary => colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
};
final effectiveOnPressed = disabled ? null : onPressed;
final foreground =
context.colorOverride ??
switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.onPrimary,
.secondary => colorScheme.onSecondary,
},
.ghost => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
};
return IconButton(
icon: Icon(icon),
onPressed: effectiveOnPressed,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
),
icon: _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.sm,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
);
}
}
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichMenu extends StatefulWidget {
final List<Widget> children;
final MenuAnchorChildBuilder builder;
final MenuStyle? style;
final bool consumeOutsideTap;
final Widget? child;
const ImmichMenu({
super.key,
required this.children,
required this.builder,
this.style,
this.consumeOutsideTap = false,
this.child,
});
@override
State<ImmichMenu> createState() => _ImmichMenuState();
}
class _ImmichMenuState extends State<ImmichMenu> {
final _controller = MenuController();
@override
Widget build(BuildContext context) {
return _ImmichMenuScope(
controller: _controller,
child: MenuAnchor(
controller: _controller,
style: widget.style,
consumeOutsideTap: widget.consumeOutsideTap,
menuChildren: widget.children,
builder: widget.builder,
child: widget.child,
),
);
}
}
class _ImmichMenuScope extends InheritedWidget {
final MenuController controller;
const _ImmichMenuScope({required this.controller, required super.child});
static MenuController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_ImmichMenuScope>()?.controller;
@override
bool updateShouldNotify(_ImmichMenuScope oldWidget) => controller != oldWidget.controller;
}
class ImmichMenuItem extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
const ImmichMenuItem({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
});
@override
State<ImmichMenuItem> createState() => _ImmichMenuItemState();
}
class _ImmichMenuItemState extends State<ImmichMenuItem> {
Future<void> _onPressed(MenuController? controller) async {
try {
await widget.onPressed();
} finally {
controller?.close();
}
}
@override
Widget build(BuildContext context) {
final controller = _ImmichMenuScope.maybeOf(context);
return MenuItemButton(
onPressed: widget.disabled ? null : () => _onPressed(controller),
closeOnActivate: controller == null,
style: MenuItemButton.styleFrom(
foregroundColor: context.colorOverride,
alignment: .centerLeft,
padding: const .symmetric(horizontal: ImmichSpacing.lg, vertical: ImmichSpacing.md),
),
leadingIcon: Icon(widget.icon, size: ImmichIconSize.sm),
child: Text(widget.label, style: const .new(fontSize: ImmichTextSize.body)),
);
}
}
@@ -52,7 +52,6 @@ class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
),
autofillHints: [AutofillHints.password],
keyboardType: TextInputType.text,
);
}
}
@@ -1,85 +1,72 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/types.dart';
import 'package:immich_ui/immich_ui.dart';
class ImmichTextButton extends StatelessWidget {
class ImmichTextButton extends StatefulWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool expanded;
final bool loading;
final bool disabled;
final bool? loading;
const ImmichTextButton({
super.key,
required this.labelText,
this.icon,
required this.onPressed,
this.variant = ImmichVariant.filled,
this.color = ImmichColor.primary,
this.variant = .filled,
this.expanded = true,
this.loading = false,
this.disabled = false,
this.loading,
});
Widget _buildButton(ImmichVariant variant) {
final Widget? effectiveIcon = loading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: icon != null
? Icon(icon, fontWeight: FontWeight.w600)
: null;
final hasIcon = effectiveIcon != null;
@override
State<ImmichTextButton> createState() => _ImmichTextButtonState();
}
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
final effectiveOnPressed = disabled || loading ? null : onPressed;
switch (variant) {
case ImmichVariant.filled:
if (hasIcon) {
return ElevatedButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return ElevatedButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
case ImmichVariant.ghost:
if (hasIcon) {
return TextButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return TextButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final button = _buildButton(variant);
if (expanded) {
final Widget? icon = _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: widget.icon != null
? Icon(widget.icon, fontWeight: .w600)
: null;
final label = Text(
widget.labelText,
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
};
if (widget.expanded) {
return SizedBox(width: double.infinity, child: button);
}
return button;
+5
View File
@@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/translation.dart';
extension TranslationHelper on BuildContext {
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
}
extension ColorHelper on BuildContext {
Color? get colorOverride => ImmichColorOverride.maybeOf(this);
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/column_button.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'ColumnButton', name: 'Default')
Widget previewColumnButtonDefault() => const Wrap(
spacing: 12,
runSpacing: 12,
children: [
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.favorite_border_rounded, label: 'Favorite'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.archive_outlined, label: 'Archive'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete'),
],
);
@ImmichPreview(group: 'ColumnButton', name: 'Loading')
Widget previewColumnButtonLoading() => ImmichColumnButton(
onPressed: () => Future<void>.delayed(const .new(seconds: 2)),
icon: Icons.download,
label: 'Download',
);
@ImmichPreview(group: 'ColumnButton', name: 'Disabled')
Widget previewColumnButtonDisabled() =>
const ImmichColumnButton(onPressed: _previewNoop, icon: Icons.ios_share_rounded, label: 'Share', disabled: true);
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/menu_item.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'MenuItem', name: 'Default')
Widget previewMenuItemDefault() => const Column(
mainAxisSize: MainAxisSize.min,
children: [
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.info_outline, label: 'Info'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.help_outline_rounded, label: 'Troubleshoot'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.cast_rounded, label: 'Cast'),
],
);
@ImmichPreview(group: 'MenuItem', name: 'Disabled')
Widget previewMenuItemDisabled() =>
const ImmichMenuItem(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete', disabled: true);
@@ -15,16 +15,6 @@ Widget previewTextButtonVariants() => const Wrap(
],
);
@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,
@@ -42,7 +32,11 @@ Widget previewTextButtonWithIcons() => const Wrap(
);
@ImmichPreview(group: 'TextButton', name: 'Loading')
Widget previewTextButtonLoading() => const _PreviewLoadingDemo();
Widget previewTextButtonLoading() => ImmichTextButton(
onPressed: () => Future<void>.delayed(const Duration(seconds: 2)),
labelText: 'Click me',
expanded: false,
);
@ImmichPreview(group: 'TextButton', name: 'Disabled')
Widget previewTextButtonDisabled() => const Wrap(
@@ -59,30 +53,3 @@ Widget previewTextButtonDisabled() => const Wrap(
),
],
);
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,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/components/icon_button.dart';
import 'test_utils.dart';
void main() {
group('ImmichColorOverride', () {
testWidgets('exposes the override color to descendants', (tester) async {
Color? captured;
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
),
);
expect(captured, Colors.green);
});
testWidgets('maybeOf returns null when there is no override', (tester) async {
Color? captured = Colors.black;
await tester.pumpTestWidget(
Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
);
expect(captured, isNull);
});
testWidgets('a descendant component adopts the override as its foreground', (tester) async {
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: ImmichIconButton(icon: Icons.add, onPressed: () {}),
),
);
final button = tester.widget<IconButton>(find.byType(IconButton));
expect(button.style?.foregroundColor?.resolve(<WidgetState>{}), Colors.green);
});
});
}