mirror of
https://github.com/immich-app/immich.git
synced 2026-07-05 20:57:40 +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(
|
||||
title: 'Immich',
|
||||
debugShowCheckedModeBanner: true,
|
||||
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
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: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<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 {
|
||||
filled,
|
||||
ghost,
|
||||
}
|
||||
enum ImmichVariant { filled, ghost }
|
||||
|
||||
enum ImmichColor {
|
||||
primary,
|
||||
secondary,
|
||||
}
|
||||
enum ImmichColor { primary, secondary }
|
||||
|
||||
enum SnackbarType { info, success, error }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ environment:
|
|||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
material_color_utilities: any
|
||||
|
||||
dev_dependencies:
|
||||
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_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<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