initial commit

This commit is contained in:
Henry Hiles 2026-11-03 14:46:28 -05:00
commit a747bdbd27
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
10 changed files with 1721 additions and 0 deletions

View file

@ -0,0 +1,10 @@
export "src/extensions/global_rect.dart";
export "src/extensions/is_desktop.dart";
export "src/extensions/show_adaptive_menu.dart";
export "src/extensions/show_adaptive_popover.dart";
export "src/extensions/show_adaptive_sheet.dart";
export "src/widgets/adaptive_breakpoint.dart";
export "src/widgets/adaptive_menu_button.dart";
export "src/widgets/popover_layout.dart";
export "src/widgets/sheet_layout.dart";

View file

@ -0,0 +1,23 @@
import "package:material_ui/material_ui.dart";
extension GlobalRect on BuildContext {
/// Where the widget this context belongs to is on screen, e.g. to open a
/// menu or popover next to it.
Rect get globalRect {
final overlay = Overlay.of(this).context.findRenderObject() as RenderBox;
return switch (findRenderObject()) {
final RenderBox box =>
box.localToGlobal(.zero, ancestor: overlay) & box.size,
_ => Rect.zero,
};
}
/// [position] on screen as an empty rect, like where text was tapped, to
/// open a popover there.
Rect globalRectAt(Offset position) {
final overlay = Overlay.of(this).context.findRenderObject() as RenderBox;
return overlay.globalToLocal(position) & .zero;
}
}

View file

@ -0,0 +1,9 @@
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/src/widgets/adaptive_breakpoint.dart";
extension IsDesktop on BuildContext {
/// Whether the window is wider than the closest [AdaptiveBreakpoint], so
/// menus and popovers should be used instead of bottom sheets.
bool get isDesktop =>
MediaQuery.sizeOf(this).width > AdaptiveBreakpoint.of(this);
}

View file

@ -0,0 +1,53 @@
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/src/extensions/global_rect.dart";
import "package:material_adaptive_sheets/src/extensions/is_desktop.dart";
extension ShowAdaptiveMenu on BuildContext {
/// Shows [items] in a popup menu on desktop, or a bottom sheet on mobile.
///
/// On desktop, the menu opens at [globalPosition] if given (e.g. where the
/// user right clicked), or over the widget this context belongs to.
///
/// Returns the value of the selected [PopupMenuItem], if any.
Future<T?> showAdaptiveMenu<T>(
List<PopupMenuEntry<T>> items, {
Offset? globalPosition,
}) {
if (!isDesktop) {
return showModalBottomSheet<T>(
context: this,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => SafeArea(
child: SingleChildScrollView(
padding: const .only(bottom: 8),
// PopupMenuItems must be inside something with the menu role
child: Semantics(
role: .menu,
explicitChildNodes: true,
child: ListTileTheme.merge(
contentPadding: const .symmetric(horizontal: 24),
minTileHeight: 48,
titleTextStyle: Theme.of(context).textTheme.titleMedium,
child: Column(mainAxisSize: .min, children: items),
),
),
),
),
);
}
final overlay = Overlay.of(this).context.findRenderObject() as RenderBox;
final anchor = switch (globalPosition) {
final position? => position & Size.zero,
null => globalRect,
};
return showMenu<T>(
context: this,
constraints: .loose(.infinite),
position: .fromRect(anchor, Offset.zero & overlay.size),
items: items,
);
}
}

View file

@ -0,0 +1,41 @@
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/src/extensions/global_rect.dart";
import "package:material_adaptive_sheets/src/extensions/is_desktop.dart";
import "package:material_adaptive_sheets/src/extensions/show_adaptive_sheet.dart";
import "package:material_adaptive_sheets/src/widgets/popover_layout.dart";
extension ShowAdaptivePopover on BuildContext {
/// Shows the widget from [builder] in a popover next to [anchor], or the
/// widget this context belongs to, on desktop, or a bottom sheet on mobile.
/// Use the context passed to [builder] to close it.
///
/// [constraints] limits the size of the popover on desktop.
Future<T?> showAdaptivePopover<T>(
WidgetBuilder builder, {
Rect? anchor,
BoxConstraints constraints = const .new(maxWidth: 420, maxHeight: 600),
}) {
if (!isDesktop) return showAdaptiveSheet<T>(builder);
final rect = anchor ?? globalRect;
return showDialog<T>(
context: this,
barrierColor: Colors.transparent,
useSafeArea: false,
builder: (context) => CustomSingleChildLayout(
delegate: PopoverLayout(rect),
child: ConstrainedBox(
constraints: constraints,
child: Material(
elevation: 8,
clipBehavior: .antiAlias,
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: .circular(16),
child: Builder(builder: builder),
),
),
),
);
}
}

View file

@ -0,0 +1,38 @@
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/src/extensions/is_desktop.dart";
extension ShowAdaptiveSheet on BuildContext {
/// Shows the widget from [builder] in a dialog on desktop, or a bottom sheet
/// on mobile. Use the context passed to [builder] to close it.
///
/// [dialogConstraints] limits the size of the dialog on desktop.
Future<T?> showAdaptiveSheet<T>(
WidgetBuilder builder, {
bool enableDrag = true,
BoxConstraints dialogConstraints = const .new(
maxWidth: 450,
maxHeight: 600,
),
}) => isDesktop
? showDialog<T>(
context: this,
builder: (context) => Dialog(
clipBehavior: .antiAlias,
child: ConstrainedBox(
constraints: dialogConstraints,
child: Builder(builder: builder),
),
),
)
: showModalBottomSheet<T>(
context: this,
isScrollControlled: true,
enableDrag: enableDrag,
constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80),
// Passing constraints lets the sheet shrink to its content, so
// stretch it to the full width like a sheet without them
builder: (context) => SafeArea(
child: SizedBox(width: .infinity, child: builder(context)),
),
);
}

View file

@ -0,0 +1,20 @@
import "package:material_ui/material_ui.dart";
/// Sets the window width above which descendants use the desktop layout, with
/// dialogs, popovers and menus instead of bottom sheets.
class const AdaptiveBreakpoint({
final double width = defaultWidth,
required super.child,
super.key,
}) extends InheritedWidget {
static const defaultWidth = 650.0;
/// The [width] of the closest [AdaptiveBreakpoint], or [defaultWidth].
static double of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AdaptiveBreakpoint>()?.width ??
defaultWidth;
@override
bool updateShouldNotify(AdaptiveBreakpoint oldWidget) =>
oldWidget.width != width;
}

View file

@ -0,0 +1,25 @@
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/src/extensions/show_adaptive_menu.dart";
/// Like [PopupMenuButton], but shows a bottom sheet on mobile.
class const AdaptiveMenuButton<T>({
required final Widget icon,
required final List<PopupMenuEntry<T>> Function(BuildContext context)
itemBuilder,
final ValueChanged<T>? onSelected,
final String? tooltip,
final bool enabled = true,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => IconButton(
icon: icon,
tooltip: tooltip,
onPressed: enabled
? () async {
final value = await context.showAdaptiveMenu(itemBuilder(context));
if (value is T) onSelected?.call(value);
}
: null,
);
}

View file

@ -0,0 +1,33 @@
import "dart:math";
import "package:material_ui/material_ui.dart";
/// Positions a popover below [anchor], or above it if there isn't room below,
/// keeping it fully on screen.
class const PopoverLayout(final Rect anchor) extends SingleChildLayoutDelegate {
static const margin = 8.0;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) =>
constraints.loosen().deflate(const .all(margin));
@override
Offset getPositionForChild(Size size, Size childSize) {
final fitsBelow = anchor.bottom + childSize.height + margin <= size.height;
return .new(
anchor.left.clamp(
margin,
max(margin, size.width - childSize.width - margin),
),
(fitsBelow ? anchor.bottom : anchor.top - childSize.height).clamp(
margin,
max(margin, size.height - childSize.height - margin),
),
);
}
@override
bool shouldRelayout(PopoverLayout oldDelegate) =>
oldDelegate.anchor != anchor;
}

View file

@ -0,0 +1,42 @@
import "package:material_ui/material_ui.dart";
class const SheetLayout({
final Widget? title,
required final List<Widget> children,
required final List<Widget> actions,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
if (title case final title?)
Padding(
padding: const .fromLTRB(24, 24, 24, 0),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.headlineSmall!,
child: title,
),
),
Flexible(
child: SingleChildScrollView(
padding: .fromLTRB(24, title == null ? 24 : 12, 24, 12),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
spacing: 12,
children: children,
),
),
),
Padding(
padding: const .fromLTRB(24, 0, 24, 24),
child: Row(mainAxisAlignment: .end, spacing: 8, children: actions),
),
],
),
);
}