Files
SpotiFLAC-Mobile/lib/widgets/app_bottom_sheet.dart
T

168 lines
5.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
/// The drag handle shown at the top of every modal sheet.
///
/// Seventeen copies of this container existed across the app in two sizes
/// (40x4 tinted `onSurfaceVariant`, 32x4 tinted `outlineVariant`) with four
/// different margins. Sheets that opt into Material's own `showDragHandle`
/// get an equivalent affordance from the framework and should not add this.
class AppSheetHandle extends StatelessWidget {
const AppSheetHandle({super.key, this.margin});
/// Overrides the default spacing around the handle. Sheets whose first row
/// already carries top padding pass [EdgeInsets.zero].
final EdgeInsetsGeometry? margin;
static const double _width = 40;
static const double _height = 4;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
return ExcludeSemantics(
child: Center(
child: Container(
width: _width,
height: _height,
margin:
margin ??
EdgeInsets.only(top: tokens.gapMd, bottom: tokens.gapSm),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(_height / 2),
),
),
),
);
}
}
/// Standard chrome for modal sheet content: drag handle, optional title block,
/// height cap, keyboard inset and bottom safe area, so each sheet only supplies
/// its own body.
class AppBottomSheet extends StatelessWidget {
const AppBottomSheet({
super.key,
required this.child,
this.showHandle = true,
this.title,
this.subtitle,
this.maxHeightFactor,
});
final Widget child;
final bool showHandle;
/// Leading title row. Rendered with the app's single sheet-title style
/// instead of the three variants that had accumulated across sheets.
final String? title;
/// Secondary line under [title], e.g. the track a sheet is acting on.
final String? subtitle;
/// Caps the sheet at this fraction of the screen height.
final double? maxHeightFactor;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final theme = Theme.of(context);
Widget content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showHandle) const AppSheetHandle(),
if (title != null)
Padding(
padding: EdgeInsets.fromLTRB(
tokens.gapXl,
tokens.gapLg,
tokens.gapXl,
subtitle == null ? tokens.gapSm : tokens.gapXs,
),
child: Text(
title!,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (subtitle != null)
Padding(
padding: EdgeInsets.fromLTRB(
tokens.gapXl,
0,
tokens.gapXl,
tokens.gapMd,
),
child: Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Flexible(child: child),
],
);
if (maxHeightFactor != null) {
content = ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * maxHeightFactor!,
),
child: content,
);
}
// Sheets containing text fields must lift above the keyboard; doing it here
// means no call site has to remember `viewInsets`.
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.only(bottom: keyboardInset),
child: SafeArea(top: false, child: content),
);
}
}
/// Opens a modal bottom sheet wrapped in [AppBottomSheet].
///
/// The shape comes from `ThemeData.bottomSheetTheme`, which is derived from
/// [AppTokens.radiusSheet]; call sites no longer pass a `shape` and therefore
/// cannot drift from the scale.
Future<T?> showAppBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
String? title,
String? subtitle,
double? maxHeightFactor,
bool isScrollControlled = true,
bool isDismissible = true,
bool enableDrag = true,
bool useRootNavigator = false,
bool showHandle = true,
Color? backgroundColor,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: isScrollControlled,
isDismissible: isDismissible,
enableDrag: enableDrag,
useRootNavigator: useRootNavigator,
backgroundColor: backgroundColor,
builder: (sheetContext) => AppBottomSheet(
showHandle: showHandle,
title: title,
subtitle: subtitle,
maxHeightFactor: maxHeightFactor,
child: builder(sheetContext),
),
);
}