From b320f24c9327651ef66705878a1788f3d290f2e3 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Fri, 29 May 2026 18:40:57 +0900 Subject: [PATCH] feat(ui): add shared app snackbar styling --- .../lib/widgets/app_snack_bar.dart | 133 ++++++++++++++++++ .../test/widgets/app_snack_bar_test.dart | 106 ++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 apps/chanora_flutter/lib/widgets/app_snack_bar.dart create mode 100644 apps/chanora_flutter/test/widgets/app_snack_bar_test.dart diff --git a/apps/chanora_flutter/lib/widgets/app_snack_bar.dart b/apps/chanora_flutter/lib/widgets/app_snack_bar.dart new file mode 100644 index 0000000..3d57437 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/app_snack_bar.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +/// Semantic tones for lightweight, Material 3 SnackBars. +enum AppSnackBarVariant { neutral, success, warning, error } + +/// Shared SnackBar styling used across the production app. +class AppSnackBar { + const AppSnackBar._(); + + static const double _desktopMaxWidth = 560; + static const double _radius = 16; + static const double _elevation = 3; + + /// Theme-level defaults for uncustomized SnackBars. + static SnackBarThemeData theme(ColorScheme scheme) { + return SnackBarThemeData( + behavior: SnackBarBehavior.floating, + elevation: _elevation, + backgroundColor: scheme.surfaceContainerHigh, + contentTextStyle: TextStyle(color: scheme.onSurface), + actionTextColor: scheme.primary, + disabledActionTextColor: scheme.onSurfaceVariant, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(_radius)), + ), + ); + } + + /// Build a consistently light, floating SnackBar while preserving caller + /// controlled behavior such as duration, margin, action, and dismissal. + static SnackBar build({ + required BuildContext context, + required Widget content, + AppSnackBarVariant variant = AppSnackBarVariant.neutral, + SnackBarAction? action, + Duration? duration, + EdgeInsetsGeometry? margin, + DismissDirection dismissDirection = DismissDirection.down, + }) { + final scheme = Theme.of(context).colorScheme; + final viewWidth = MediaQuery.sizeOf(context).width; + final useDesktopCap = viewWidth >= 600; + final snackBarWidth = useDesktopCap && margin == null + ? _desktopMaxWidth + : null; + final effectiveMargin = useDesktopCap && margin != null + ? _desktopCappedMargin(context, margin) + : margin; + final foreground = _foregroundColor(scheme, variant); + return SnackBar( + behavior: SnackBarBehavior.floating, + width: snackBarWidth, + margin: effectiveMargin, + elevation: _elevation, + backgroundColor: _backgroundColor(scheme, variant), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(_radius)), + ), + dismissDirection: dismissDirection, + duration: duration ?? const Duration(milliseconds: 4000), + content: DefaultTextStyle.merge( + style: TextStyle(color: foreground), + child: IconTheme.merge( + data: IconThemeData(color: foreground), + child: content, + ), + ), + action: action, + ); + } + + static EdgeInsetsGeometry _desktopCappedMargin( + BuildContext context, + EdgeInsetsGeometry margin, + ) { + final viewWidth = MediaQuery.sizeOf(context).width; + final resolved = margin.resolve(Directionality.of(context)); + final extraHorizontal = + (viewWidth - _desktopMaxWidth).clamp(0.0, viewWidth) / 2; + return EdgeInsets.fromLTRB( + resolved.left + extraHorizontal, + resolved.top, + resolved.right + extraHorizontal, + resolved.bottom, + ); + } + + static SnackBar text({ + required BuildContext context, + required String message, + AppSnackBarVariant variant = AppSnackBarVariant.neutral, + SnackBarAction? action, + Duration? duration, + EdgeInsetsGeometry? margin, + DismissDirection dismissDirection = DismissDirection.down, + int? maxLines, + TextOverflow? overflow, + }) { + return build( + context: context, + variant: variant, + action: action, + duration: duration, + margin: margin, + dismissDirection: dismissDirection, + content: Text(message, maxLines: maxLines, overflow: overflow), + ); + } + + static Color _backgroundColor( + ColorScheme scheme, + AppSnackBarVariant variant, + ) { + return switch (variant) { + AppSnackBarVariant.neutral => scheme.surfaceContainerHigh, + AppSnackBarVariant.success => scheme.tertiaryContainer, + AppSnackBarVariant.warning => scheme.secondaryContainer, + AppSnackBarVariant.error => scheme.errorContainer, + }; + } + + static Color _foregroundColor( + ColorScheme scheme, + AppSnackBarVariant variant, + ) { + return switch (variant) { + AppSnackBarVariant.neutral => scheme.onSurface, + AppSnackBarVariant.success => scheme.onTertiaryContainer, + AppSnackBarVariant.warning => scheme.onSecondaryContainer, + AppSnackBarVariant.error => scheme.onErrorContainer, + }; + } +} diff --git a/apps/chanora_flutter/test/widgets/app_snack_bar_test.dart b/apps/chanora_flutter/test/widgets/app_snack_bar_test.dart new file mode 100644 index 0000000..29689f0 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/app_snack_bar_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/widgets/app_snack_bar.dart'; + +void main() { + test('app snackbar theme uses light Material 3 surface styling', () { + final scheme = ColorScheme.fromSeed(seedColor: const Color(0xFF3F51B5)); + final theme = AppSnackBar.theme(scheme); + + expect(theme.behavior, SnackBarBehavior.floating); + expect(theme.backgroundColor, scheme.surfaceContainerHigh); + expect(theme.contentTextStyle?.color, scheme.onSurface); + expect(theme.actionTextColor, scheme.primary); + expect(theme.shape, isA()); + }); + + testWidgets('app snackbar helper applies bounded light error variant', ( + tester, + ) async { + tester.view.physicalSize = const Size(1000, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const seed = Color(0xFF3F51B5); + final scheme = ColorScheme.fromSeed(seedColor: seed); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: true, + colorSchemeSeed: seed, + snackBarTheme: AppSnackBar.theme(scheme), + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + AppSnackBar.text( + context: context, + message: 'Could not connect', + variant: AppSnackBarVariant.error, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ); + }, + child: const Text('Show'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Show')); + await tester.pump(); + + final snackBar = tester.widget(find.byType(SnackBar)); + expect(snackBar.behavior, SnackBarBehavior.floating); + expect(snackBar.width, 560); + expect(snackBar.backgroundColor, scheme.errorContainer); + + final text = tester.widget(find.text('Could not connect')); + expect(text.maxLines, 3); + expect(text.overflow, TextOverflow.ellipsis); + }); + + testWidgets('app snackbar desktop width cap survives custom margin', ( + tester, + ) async { + tester.view.physicalSize = const Size(1000, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + AppSnackBar.text( + context: context, + message: 'Private message from observer', + margin: const EdgeInsets.all(24), + ), + ); + }, + child: const Text('Show'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Show')); + await tester.pump(); + + final snackBar = tester.widget(find.byType(SnackBar)); + expect(snackBar.width, isNull); + expect(snackBar.margin, const EdgeInsets.fromLTRB(244, 24, 244, 24)); + }); +}