Merge pull request #7 from EdisonJwa/salvage/app-snack-bar
Add shared app snackbar styling
This commit is contained in:
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user