From 30a73c11055e776a87d3fb4d0228d972829edc04 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:52:26 +0530 Subject: [PATCH] feat: mobile-ui snackbar (#29260) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/main.dart | 1 + mobile/packages/ui/lib/immich_ui.dart | 1 + .../ui/lib/src/previews/snackbar.dart | 32 ++++++++ mobile/packages/ui/lib/src/snackbar.dart | 58 ++++++++++++++ mobile/packages/ui/lib/src/theme.dart | 76 ++++++++++++++++++- mobile/packages/ui/lib/src/types.dart | 12 +-- mobile/packages/ui/pubspec.lock | 2 +- mobile/packages/ui/pubspec.yaml | 1 + mobile/packages/ui/test/snackbar_test.dart | 35 +++++++++ mobile/packages/ui/test/test_utils.dart | 9 ++- 10 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 mobile/packages/ui/lib/src/previews/snackbar.dart create mode 100644 mobile/packages/ui/lib/src/snackbar.dart create mode 100644 mobile/packages/ui/test/snackbar_test.dart diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 75f1c2221a..f576a7c63c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -263,6 +263,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve child: MaterialApp.router( title: 'Immich', debugShowCheckedModeBanner: true, + scaffoldMessengerKey: scaffoldMessengerKey, localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, locale: context.locale, diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 6158b4f0e1..cd6cec3ff3 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -7,6 +7,7 @@ export 'src/components/text_button.dart'; export 'src/components/text_input.dart'; export 'src/components/url_input.dart'; export 'src/constants.dart'; +export 'src/snackbar.dart'; export 'src/theme.dart'; export 'src/translation.dart'; export 'src/types.dart'; diff --git a/mobile/packages/ui/lib/src/previews/snackbar.dart b/mobile/packages/ui/lib/src/previews/snackbar.dart new file mode 100644 index 0000000000..1f98369e32 --- /dev/null +++ b/mobile/packages/ui/lib/src/previews/snackbar.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/constants.dart'; +import 'package:immich_ui/src/previews.dart'; +import 'package:immich_ui/src/snackbar.dart'; + +@ImmichPreview(group: 'Snackbar', name: 'Types') +Widget previewSnackbarTypes() => const _SnackbarDemo(); + +class _SnackbarDemo extends StatelessWidget { + const _SnackbarDemo(); + + @override + Widget build(BuildContext context) { + return ScaffoldMessenger( + key: scaffoldMessengerKey, + child: Scaffold( + backgroundColor: Colors.transparent, + body: Center( + child: Wrap( + spacing: ImmichSpacing.md, + runSpacing: ImmichSpacing.md, + children: [ + ElevatedButton(onPressed: () => snackbar.info('Info message'), child: const Text('Info')), + ElevatedButton(onPressed: () => snackbar.success('Saved'), child: const Text('Success')), + ElevatedButton(onPressed: () => snackbar.error('Something failed'), child: const Text('Error')), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/packages/ui/lib/src/snackbar.dart b/mobile/packages/ui/lib/src/snackbar.dart new file mode 100644 index 0000000000..a44be8d513 --- /dev/null +++ b/mobile/packages/ui/lib/src/snackbar.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +final scaffoldMessengerKey = GlobalKey(); + +class SnackbarManager { + const SnackbarManager(); + + ScaffoldFeatureController? show(String message, SnackbarType type) { + final messenger = scaffoldMessengerKey.currentState; + final context = scaffoldMessengerKey.currentContext; + if (messenger == null || context == null) { + return null; + } + + messenger.hideCurrentSnackBar(); + return messenger.showSnackBar(_build(context, message, type)); + } + + SnackBar _build(BuildContext context, String message, SnackbarType type) { + final theme = Theme.of(context); + final colors = theme.extension() ?? ImmichColors.harmonized(theme.colorScheme); + final (IconData icon, Color background, Color foreground) = switch (type) { + .info => (Icons.info_rounded, colors.info, colors.onInfo), + .success => (Icons.check_circle_rounded, colors.success, colors.onSuccess), + .error => (Icons.warning_rounded, colors.error, colors.onError), + }; + + return SnackBar( + behavior: .floating, + backgroundColor: background, + duration: const .new(seconds: 4), + shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))), + content: Row( + children: [ + Icon(icon, color: foreground, size: ImmichIconSize.sm), + const SizedBox(width: ImmichSpacing.md), + Expanded( + child: Text( + message, + maxLines: 2, + overflow: .ellipsis, + style: .new(color: foreground, fontWeight: .w600, fontSize: ImmichTextSize.body), + ), + ), + ], + ), + ); + } + + ScaffoldFeatureController? info(String message) => show(message, .info); + + ScaffoldFeatureController? success(String message) => show(message, .success); + + ScaffoldFeatureController? error(String message) => show(message, .error); +} + +const snackbar = SnackbarManager(); diff --git a/mobile/packages/ui/lib/src/theme.dart b/mobile/packages/ui/lib/src/theme.dart index 891b41ee91..6ee4729810 100644 --- a/mobile/packages/ui/lib/src/theme.dart +++ b/mobile/packages/ui/lib/src/theme.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:immich_ui/src/constants.dart'; +import 'package:material_color_utilities/blend/blend.dart'; +import 'package:material_color_utilities/hct/hct.dart'; +import 'package:material_color_utilities/palettes/tonal_palette.dart'; class ImmichThemeProvider extends StatelessWidget { final ColorScheme colorScheme; @@ -11,6 +14,7 @@ class ImmichThemeProvider extends StatelessWidget { Widget build(BuildContext context) { return Theme( data: Theme.of(context).copyWith( + extensions: [ImmichColors.harmonized(colorScheme)], colorScheme: colorScheme, brightness: colorScheme.brightness, inputDecorationTheme: InputDecorationTheme( @@ -19,8 +23,8 @@ class ImmichThemeProvider extends StatelessWidget { final color = states.contains(WidgetState.error) ? colorScheme.error : states.contains(WidgetState.focused) - ? colorScheme.primary - : colorScheme.outline; + ? colorScheme.primary + : colorScheme.outline; return OutlineInputBorder( borderSide: BorderSide(color: color), borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)), @@ -38,3 +42,71 @@ class ImmichThemeProvider extends StatelessWidget { ); } } + +class ImmichColors extends ThemeExtension { + final Color info; + final Color onInfo; + final Color success; + final Color onSuccess; + final Color error; + final Color onError; + + const ImmichColors({ + required this.info, + required this.onInfo, + required this.success, + required this.onSuccess, + required this.error, + required this.onError, + }); + + factory ImmichColors.harmonized(ColorScheme scheme) { + final (info, onInfo) = scheme.harmonized(const Color(0xFF1984E9)); + final (success, onSuccess) = scheme.harmonized(const Color(0xFF10C14D)); + final (error, onError) = scheme.harmonized(const Color(0xFFFA2921)); + return ImmichColors( + info: info, + onInfo: onInfo, + success: success, + onSuccess: onSuccess, + error: error, + onError: onError, + ); + } + + @override + ImmichColors copyWith({Color? info, Color? onInfo, Color? success, Color? onSuccess, Color? error, Color? onError}) { + return ImmichColors( + info: info ?? this.info, + onInfo: onInfo ?? this.onInfo, + success: success ?? this.success, + onSuccess: onSuccess ?? this.onSuccess, + error: error ?? this.error, + onError: onError ?? this.onError, + ); + } + + @override + ImmichColors lerp(ImmichColors? other, double t) { + if (other == null) { + return this; + } + return ImmichColors( + info: Color.lerp(info, other.info, t)!, + onInfo: Color.lerp(onInfo, other.onInfo, t)!, + success: Color.lerp(success, other.success, t)!, + onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!, + error: Color.lerp(error, other.error, t)!, + onError: Color.lerp(onError, other.onError, t)!, + ); + } +} + +extension on ColorScheme { + (Color container, Color onContainer) harmonized(Color seed) { + final hct = Hct.fromInt(Blend.harmonize(seed.toARGB32(), primary.toARGB32())); + final tones = TonalPalette.of(hct.hue, hct.chroma); + final isDark = brightness == Brightness.dark; + return (Color(tones.get(isDark ? 30 : 90)), Color(tones.get(isDark ? 90 : 10))); + } +} diff --git a/mobile/packages/ui/lib/src/types.dart b/mobile/packages/ui/lib/src/types.dart index 2c0c7b7760..8193eb88af 100644 --- a/mobile/packages/ui/lib/src/types.dart +++ b/mobile/packages/ui/lib/src/types.dart @@ -1,9 +1,5 @@ -enum ImmichVariant { - filled, - ghost, -} +enum ImmichVariant { filled, ghost } -enum ImmichColor { - primary, - secondary, -} +enum ImmichColor { primary, secondary } + +enum SnackbarType { info, success, error } diff --git a/mobile/packages/ui/pubspec.lock b/mobile/packages/ui/pubspec.lock index 9d11b49253..b58a985de1 100644 --- a/mobile/packages/ui/pubspec.lock +++ b/mobile/packages/ui/pubspec.lock @@ -92,7 +92,7 @@ packages: source: hosted version: "0.12.19" material_color_utilities: - dependency: transitive + dependency: "direct main" description: name: material_color_utilities sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" diff --git a/mobile/packages/ui/pubspec.yaml b/mobile/packages/ui/pubspec.yaml index 1f44694ace..b924becdc5 100644 --- a/mobile/packages/ui/pubspec.yaml +++ b/mobile/packages/ui/pubspec.yaml @@ -7,6 +7,7 @@ environment: dependencies: flutter: sdk: flutter + material_color_utilities: any dev_dependencies: flutter_test: diff --git a/mobile/packages/ui/test/snackbar_test.dart b/mobile/packages/ui/test/snackbar_test.dart new file mode 100644 index 0000000000..1cc57c6d55 --- /dev/null +++ b/mobile/packages/ui/test/snackbar_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_ui/src/snackbar.dart'; + +import 'test_utils.dart'; + +void main() { + group('SnackbarManager', () { + testWidgets('shows the message', (tester) async { + await tester.pumpTestWidget(const SizedBox()); + + snackbar.success('hello'); + await tester.pump(); + + expect(find.text('hello'), findsOneWidget); + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('replaces the current snackbar', (tester) async { + await tester.pumpTestWidget(const SizedBox()); + + snackbar.info('first'); + await tester.pump(); + snackbar.error('second'); + await tester.pump(); + + expect(find.text('first'), findsNothing); + expect(find.text('second'), findsOneWidget); + }); + + testWidgets('no-ops when the messenger is unmounted', (tester) async { + expect(snackbar.show('x', .info), isNull); + }); + }); +} diff --git a/mobile/packages/ui/test/test_utils.dart b/mobile/packages/ui/test/test_utils.dart index 42cc74da87..36c166649b 100644 --- a/mobile/packages/ui/test/test_utils.dart +++ b/mobile/packages/ui/test/test_utils.dart @@ -1,9 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_ui/src/snackbar.dart'; extension WidgetTesterExtension on WidgetTester { - /// Pumps a widget wrapped in MaterialApp and Scaffold for testing. Future pumpTestWidget(Widget widget) { - return pumpWidget(MaterialApp(home: Scaffold(body: widget))); + return pumpWidget( + MaterialApp( + scaffoldMessengerKey: scaffoldMessengerKey, + home: Scaffold(body: widget), + ), + ); } }