feat(ui): add shared app snackbar styling

This commit is contained in:
Edison Jwa
2026-05-29 18:40:57 +09:00
parent 0e04dac064
commit b320f24c93
2 changed files with 239 additions and 0 deletions
@@ -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,
};
}
}
@@ -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<RoundedRectangleBorder>());
});
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<SnackBar>(find.byType(SnackBar));
expect(snackBar.behavior, SnackBarBehavior.floating);
expect(snackBar.width, 560);
expect(snackBar.backgroundColor, scheme.errorContainer);
final text = tester.widget<Text>(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<SnackBar>(find.byType(SnackBar));
expect(snackBar.width, isNull);
expect(snackBar.margin, const EdgeInsets.fromLTRB(244, 24, 244, 24));
});
}