mirror of
https://github.com/immich-app/immich.git
synced 2026-07-15 21:32:32 +03:00
refactor: ui icon buttons implicit loading (#29263)
* refactor: icon buttons implicit loading * chore: cleanup * feat: ui color override (#29264) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
13
mobile/packages/ui/lib/src/color_override.dart
Normal file
13
mobile/packages/ui/lib/src/color_override.dart
Normal file
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
53
mobile/packages/ui/test/color_override_test.dart
Normal file
53
mobile/packages/ui/test/color_override_test.dart
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user