mirror of
https://github.com/immich-app/immich.git
synced 2026-07-14 09:00:59 +00:00
feat: mobile-ui snackbar (#29260)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
ec7c0f9ec8
commit
30a73c1105
10 changed files with 214 additions and 13 deletions
|
|
@ -263,6 +263,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
title: 'Immich',
|
title: 'Immich',
|
||||||
debugShowCheckedModeBanner: true,
|
debugShowCheckedModeBanner: true,
|
||||||
|
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||||
localizationsDelegates: context.localizationDelegates,
|
localizationsDelegates: context.localizationDelegates,
|
||||||
supportedLocales: context.supportedLocales,
|
supportedLocales: context.supportedLocales,
|
||||||
locale: context.locale,
|
locale: context.locale,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export 'src/components/text_button.dart';
|
||||||
export 'src/components/text_input.dart';
|
export 'src/components/text_input.dart';
|
||||||
export 'src/components/url_input.dart';
|
export 'src/components/url_input.dart';
|
||||||
export 'src/constants.dart';
|
export 'src/constants.dart';
|
||||||
|
export 'src/snackbar.dart';
|
||||||
export 'src/theme.dart';
|
export 'src/theme.dart';
|
||||||
export 'src/translation.dart';
|
export 'src/translation.dart';
|
||||||
export 'src/types.dart';
|
export 'src/types.dart';
|
||||||
|
|
|
||||||
32
mobile/packages/ui/lib/src/previews/snackbar.dart
Normal file
32
mobile/packages/ui/lib/src/previews/snackbar.dart
Normal file
|
|
@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
mobile/packages/ui/lib/src/snackbar.dart
Normal file
58
mobile/packages/ui/lib/src/snackbar.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
|
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
|
||||||
|
class SnackbarManager {
|
||||||
|
const SnackbarManager();
|
||||||
|
|
||||||
|
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? 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>() ?? 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<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
|
||||||
|
|
||||||
|
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
|
||||||
|
|
||||||
|
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snackbar = SnackbarManager();
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_ui/src/constants.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 {
|
class ImmichThemeProvider extends StatelessWidget {
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
|
|
@ -11,6 +14,7 @@ class ImmichThemeProvider extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Theme(
|
return Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
|
extensions: [ImmichColors.harmonized(colorScheme)],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
brightness: colorScheme.brightness,
|
brightness: colorScheme.brightness,
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
|
@ -38,3 +42,71 @@ class ImmichThemeProvider extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ImmichColors extends ThemeExtension<ImmichColors> {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
enum ImmichVariant {
|
enum ImmichVariant { filled, ghost }
|
||||||
filled,
|
|
||||||
ghost,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ImmichColor {
|
enum ImmichColor { primary, secondary }
|
||||||
primary,
|
|
||||||
secondary,
|
enum SnackbarType { info, success, error }
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
material_color_utilities: any
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
35
mobile/packages/ui/test/snackbar_test.dart
Normal file
35
mobile/packages/ui/test/snackbar_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_ui/src/snackbar.dart';
|
||||||
|
|
||||||
extension WidgetTesterExtension on WidgetTester {
|
extension WidgetTesterExtension on WidgetTester {
|
||||||
/// Pumps a widget wrapped in MaterialApp and Scaffold for testing.
|
|
||||||
Future<void> pumpTestWidget(Widget widget) {
|
Future<void> pumpTestWidget(Widget widget) {
|
||||||
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
|
return pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||||
|
home: Scaffold(body: widget),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue