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,
};
}
}