initial commit
This commit is contained in:
commit
a747bdbd27
10 changed files with 1721 additions and 0 deletions
10
lib/material_adaptive_sheets.dart
Normal file
10
lib/material_adaptive_sheets.dart
Normal 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";
|
||||
23
lib/src/extensions/global_rect.dart
Normal file
23
lib/src/extensions/global_rect.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
9
lib/src/extensions/is_desktop.dart
Normal file
9
lib/src/extensions/is_desktop.dart
Normal 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);
|
||||
}
|
||||
53
lib/src/extensions/show_adaptive_menu.dart
Normal file
53
lib/src/extensions/show_adaptive_menu.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/src/extensions/show_adaptive_popover.dart
Normal file
41
lib/src/extensions/show_adaptive_popover.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/src/extensions/show_adaptive_sheet.dart
Normal file
38
lib/src/extensions/show_adaptive_sheet.dart
Normal 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
20
lib/src/widgets/adaptive_breakpoint.dart
Normal file
20
lib/src/widgets/adaptive_breakpoint.dart
Normal 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;
|
||||
}
|
||||
25
lib/src/widgets/adaptive_menu_button.dart
Normal file
25
lib/src/widgets/adaptive_menu_button.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
33
lib/src/widgets/popover_layout.dart
Normal file
33
lib/src/widgets/popover_layout.dart
Normal 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;
|
||||
}
|
||||
42
lib/src/widgets/sheet_layout.dart
Normal file
42
lib/src/widgets/sheet_layout.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue