diff --git a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart index 1b4a8ffd..4b47e09c 100644 --- a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart +++ b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart @@ -230,8 +230,9 @@ class _ContentState extends State<_Content> { left: NhamSpacing.sp3, right: NhamSpacing.sp3, top: NhamSpacing.sp3, - // pb-24 (96px) to clear the floating FAB. - bottom: bottomInset + 96, + // Clear the FAB's resting footprint (44 + 20 bottom) with a small + // gap — no more than that, so the scroll doesn't end in dead space. + bottom: bottomInset + 76, ), children: [ // SECTION 1 — week strip + the paged day-viewer (greeting now lives @@ -271,8 +272,10 @@ class _ContentState extends State<_Content> { WeightChart(todayDate: widget.todayDate, args: widget.args), ], ), - // SECTION 3 — Consistency. + // SECTION 3 — Consistency. Last section: no trailing margin, so the + // scroll ends right under the heatmap instead of a dead gap. _Section( + last: true, children: [ SectionHeader( title: tr('dashboard.consistency'), @@ -441,7 +444,7 @@ class _DashboardSkeleton extends StatelessWidget { left: NhamSpacing.sp3, right: NhamSpacing.sp3, top: NhamSpacing.sp3, - bottom: bottomInset + 96, + bottom: bottomInset + 76, ), children: const [ // Week-strip row — four day pills. @@ -478,15 +481,17 @@ class _DashboardSkeleton extends StatelessWidget { } } -/// A dashboard section: `mb-4 gap-1.5` (16px bottom margin, 6px inner gap). +/// A dashboard section: `mb-4 gap-1.5` (16px bottom margin, 6px inner gap). The +/// final section passes [last] to drop the bottom margin. class _Section extends StatelessWidget { - const _Section({required this.children}); + const _Section({required this.children, this.last = false}); final List children; + final bool last; @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), + padding: EdgeInsets.only(bottom: last ? 0 : NhamSpacing.sp4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart index 2445af89..3502d35d 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart @@ -14,7 +14,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../data/api_client.dart'; import '../../../shared/widgets/widgets.dart'; @@ -113,6 +112,7 @@ class _CompactWeightLogState extends ConsumerState { Future _onSubmit() async { final weightKg = parseDecimalInput(_controller.text); if (weightKg.isNaN || weightKg < _weightMin || weightKg > _weightMax) { + HapticFeedback.heavyImpact(); // warn: rejected input setState(() => _validationError = tr('dashboard.weightCard.invalid')); _showFeedback( _Feedback(_FeedbackKind.error, tr('dashboard.weightCard.invalidValue')), @@ -172,84 +172,66 @@ class _CompactWeightLogState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Always "Today's weight" — this field only ever logs today. No leading + // icon: the label alone carries the meaning and reads cleaner. Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), - child: Row( - children: [ - const Icon(LucideIcons.scale, size: 16, color: kInkSecondary), - const SizedBox(width: NhamSpacing.sp2), - Text( - (_hasTodayWeight - ? tr('dashboard.weightCard.todaysWeight') - : tr('dashboard.weightCard.logWeight')) - .toUpperCase(), - style: dashEyebrow(), - ), - ], + child: Text( + tr('dashboard.weightCard.todaysWeight').toUpperCase(), + style: dashEyebrow(), ), ), - // IntrinsicHeight + stretch so the field's filled area and the button - // are the exact same height. (A fixed height on the field alone doesn't - // work: isDense sizes the InputDecorator fill to its content and centers - // it, leaving transparent gaps — so the cream fill read shorter than the - // solid button.) - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: TextField( - controller: _controller, - onChanged: _onChanged, - enabled: !_pending, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')), - ], - autocorrect: false, - cursorColor: NhamColors.accent, - // Substantial value type — a number entry reads as data, not - // body copy. - style: dashValue(color: kInk), - decoration: InputDecoration( - isDense: true, - filled: true, - // Soft warm fill instead of a hairline outline — the field - // reads as a tappable surface, the way native mobile inputs - // do, and the border only appears on focus / error. - fillColor: hasError - ? NhamColors.danger.withValues(alpha: 0.06) - : kFieldFill, - // Vertical padding sets the field's intrinsic height (≈ the - // control height); the button stretches to match it. - contentPadding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: 15, - ), - // Suffix in-flow (no Positioned overlay → no overlap). - suffixText: tr('dashboard.units.kg'), - suffixStyle: dashMeta(color: kInkSecondary), - border: _border(Colors.transparent), - enabledBorder: _border( - hasError ? NhamColors.danger : Colors.transparent), - focusedBorder: _border( - hasError ? NhamColors.danger : NhamColors.accent), - disabledBorder: _border(Colors.transparent), - ), - ), - ), - const SizedBox(width: NhamSpacing.sp2), - _SubmitButton( - label: submitLabel, - pending: _pending, - pressed: _pressed, - onTapDown: () => setState(() => _pressed = true), - onTapUp: () => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: _pending ? null : _onSubmit, - ), - ], + // Field on its own row, full width. + TextField( + controller: _controller, + onChanged: _onChanged, + enabled: !_pending, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')), + ], + autocorrect: false, + cursorColor: NhamColors.accent, + // Substantial value type — a number entry reads as data, not body + // copy. + style: dashValue(color: kInk), + decoration: InputDecoration( + isDense: true, + filled: true, + // Soft warm fill instead of a hairline outline — the field reads as + // a tappable surface, the way native mobile inputs do, and the + // border only appears on focus / error. + fillColor: hasError + ? NhamColors.danger.withValues(alpha: 0.06) + : kFieldFill, + contentPadding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: 15, + ), + // Suffix in-flow (no Positioned overlay → no overlap). + suffixText: tr('dashboard.units.kg'), + suffixStyle: dashMeta(color: kInkSecondary), + border: _border(Colors.transparent), + enabledBorder: + _border(hasError ? NhamColors.danger : Colors.transparent), + focusedBorder: + _border(hasError ? NhamColors.danger : NhamColors.accent), + disabledBorder: _border(Colors.transparent), + ), + ), + const SizedBox(height: NhamSpacing.sp2), + // Submit button beneath the field, full width — a clearer, more + // thumb-friendly target than a cramped side-by-side button. + SizedBox( + width: double.infinity, + child: _SubmitButton( + label: submitLabel, + pending: _pending, + pressed: _pressed, + onTapDown: () => setState(() => _pressed = true), + onTapUp: () => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: _pending ? null : _onSubmit, ), ), if (_feedback != null) @@ -322,8 +304,11 @@ class _SubmitButton extends StatelessWidget { child: Opacity( opacity: pending ? 0.55 : 1, child: Container( - // No fixed height — the parent Row stretches it to the field's height. - padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp5), + // Stands on its own row now — own comfortable vertical height. + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp5, + vertical: NhamSpacing.sp3, + ), alignment: Alignment.center, decoration: BoxDecoration( color: pressed && !pending ? NhamColors.btnHover : NhamColors.btn, diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart index 3f55e94b..b40f7291 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart @@ -2,13 +2,22 @@ /// (`components/dashboard/today/meal-trigger.tsx` `FloatingMealTrigger`, /// rendered under `md:hidden`). /// -/// A fixed bottom-right FAB (44x44, radius 16, btn color, soft shadow, -/// UtensilsCrossed icon) that expands into a compact meal-input bar above it -/// (slide+fade in, 160ms). Submitting navigates to `/logging?meal=…`. +/// A 44x44 FAB (radius 16, btn color, soft shadow, UtensilsCrossed icon) that +/// expands into a compact meal-input bar (slide+fade in, 160ms). Submitting +/// navigates to `/logging?meal=…`. +/// +/// The FAB is *draggable* — the user can pick it up and drop it anywhere; on +/// release it snaps to the nearest left/right edge (the familiar "chat-head" / +/// movable-FAB pattern) at whatever height they left it, so it never sits over +/// content they care about. The chosen spot persists for the session via +/// [mealFabPositionProvider]. The expanding input bar re-anchors above or below +/// the FAB depending on where it lives. library; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:go_router/go_router.dart'; @@ -16,16 +25,33 @@ import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; -class FloatingMealTrigger extends StatefulWidget { +/// Session-scoped FAB position (top-left, in the dashboard content's local +/// coordinate space). Null → resolve to the default bottom-right resting spot. +/// Held in a provider so it survives tab switches and dashboard rebuilds. +final mealFabPositionProvider = StateProvider((ref) => null); + +// FAB geometry / movement bounds. +const double _fabSize = 44; // h-11 w-11 +const double _fabMargin = NhamSpacing.sp4; // 16 — edge inset +const double _fabBottomGap = NhamSpacing.sp5; // 20 — default bottom rest + +class FloatingMealTrigger extends ConsumerStatefulWidget { const FloatingMealTrigger({super.key}); @override - State createState() => _FloatingMealTriggerState(); + ConsumerState createState() => + _FloatingMealTriggerState(); } -class _FloatingMealTriggerState extends State { +class _FloatingMealTriggerState extends ConsumerState { bool _expanded = false; bool _fabPressed = false; + bool _dragging = false; + + /// Live FAB position while the session value is being moved; falls back to the + /// provider, then the default, resolved against the current bounds each build. + Offset? _pos; + final TextEditingController _controller = TextEditingController(); final FocusNode _focus = FocusNode(); @@ -37,11 +63,13 @@ class _FloatingMealTriggerState extends State { } void _open() { + HapticFeedback.lightImpact(); setState(() => _expanded = true); WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus()); } void _close() { + HapticFeedback.selectionClick(); _focus.unfocus(); setState(() => _expanded = false); } @@ -49,90 +77,167 @@ class _FloatingMealTriggerState extends State { void _submit() { final meal = _controller.text.trim(); if (meal.isEmpty) return; + HapticFeedback.mediumImpact(); // commit cue _controller.clear(); - _close(); + _focus.unfocus(); + setState(() => _expanded = false); context.go('/logging?meal=${Uri.encodeComponent(meal)}'); } + // ── Position helpers ─────────────────────────────────────────────────────── + + Offset _defaultPos(double w, double h) => + Offset(w - _fabSize - _fabMargin, h - _fabSize - _fabBottomGap); + + Offset _clampPos(Offset p, double w, double h) { + final maxX = (w - _fabSize - _fabMargin).clamp(_fabMargin, double.infinity); + final maxY = + (h - _fabSize - _fabBottomGap).clamp(_fabMargin, double.infinity); + return Offset( + p.dx.clamp(_fabMargin, maxX).toDouble(), + p.dy.clamp(_fabMargin, maxY).toDouble(), + ); + } + + Offset _resolvePos(double w, double h) { + final stored = _pos ?? ref.read(mealFabPositionProvider); + return _clampPos(stored ?? _defaultPos(w, h), w, h); + } + + void _onDragStart() { + HapticFeedback.selectionClick(); // pick-up cue + setState(() { + _dragging = true; + _fabPressed = true; + }); + } + + void _onDragUpdate(Offset delta, double w, double h) { + setState(() { + _pos = _clampPos((_pos ?? _resolvePos(w, h)) + delta, w, h); + }); + } + + void _onDragEnd(double w, double h) { + final p = _pos ?? _resolvePos(w, h); + // Snap to whichever vertical edge the FAB's center is closer to. + final center = p.dx + _fabSize / 2; + final snappedX = center < w / 2 ? _fabMargin : (w - _fabSize - _fabMargin); + final snapped = _clampPos(Offset(snappedX, p.dy), w, h); + setState(() { + _pos = snapped; + _dragging = false; + _fabPressed = false; + }); + ref.read(mealFabPositionProvider.notifier).state = snapped; + HapticFeedback.lightImpact(); // settle cue + } + @override Widget build(BuildContext context) { - return Stack( - children: [ - // Expanding meal-input bar: fixed right-4 bottom-20 left-4. - Positioned( - left: NhamSpacing.sp4, // left-4 - right: NhamSpacing.sp4, // right-4 - bottom: 80, // bottom-20 - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 160), - transitionBuilder: - (child, anim) => FadeTransition( - opacity: anim, - child: SlideTransition( - // y 8 → 0. - position: Tween( - begin: const Offset(0, 8 / 44), - end: Offset.zero, - ).animate(anim), - child: child, + return LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + final pos = _resolvePos(w, h); + + // Anchor the input bar above the FAB when there's room overhead, else + // below it — so it stays visually attached wherever the FAB lives. + final showAbove = pos.dy > h * 0.4; + final barTop = showAbove ? null : pos.dy + _fabSize + 8; + final barBottom = showAbove ? (h - pos.dy + 8) : null; + + return SizedBox.expand( + child: Stack( + children: [ + // Expanding meal-input bar, re-anchored to the FAB. + Positioned( + left: _fabMargin, + right: _fabMargin, + top: barTop, + bottom: barBottom, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 8 / 44), + end: Offset.zero, + ).animate(anim), + child: child, + ), ), + child: _expanded + ? _MealInputBar( + key: const ValueKey('meal-input'), + controller: _controller, + focusNode: _focus, + onSubmit: _submit, + ) + : const SizedBox.shrink(key: ValueKey('hidden')), ), - child: - _expanded - ? _MealInputBar( - key: const ValueKey('meal-input'), - controller: _controller, - focusNode: _focus, - onSubmit: _submit, - ) - : const SizedBox.shrink(key: ValueKey('hidden')), - ), - ), + ), - // The FAB: fixed right-4 bottom-5, 44x44 rounded-2xl. - Positioned( - right: NhamSpacing.sp4, // right-4 - bottom: NhamSpacing.sp5, // bottom-5 - child: Semantics( - button: true, - label: - _expanded - ? tr('dashboard.mealTrigger.close') - : tr('dashboard.logMeal'), - child: GestureDetector( - onTapDown: (_) => setState(() => _fabPressed = true), - onTapUp: (_) => setState(() => _fabPressed = false), - onTapCancel: () => setState(() => _fabPressed = false), - onTap: _expanded ? _close : _open, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 44, // h-11 w-11 - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _fabPressed ? NhamColors.btnHover : NhamColors.btn, - borderRadius: BorderRadius.circular( - NhamRadii.containerLg, - ), // 16 - boxShadow: const [ - // shadow-[0_4px_16px_rgba(44,36,22,0.18)]. - BoxShadow( - color: Color(0x2E2C2416), // #2C2416 @ 18% - blurRadius: 16, - offset: Offset(0, 4), + // The draggable FAB. + Positioned( + left: pos.dx, + top: pos.dy, + width: _fabSize, + height: _fabSize, + child: Semantics( + button: true, + label: _expanded + ? tr('dashboard.mealTrigger.close') + : tr('dashboard.logMeal'), + child: GestureDetector( + onTapDown: (_) => setState(() => _fabPressed = true), + onTapUp: (_) => setState(() => _fabPressed = false), + onTapCancel: () => setState(() => _fabPressed = false), + onTap: _expanded ? _close : _open, + // Drag to reposition; snaps to an edge on release. + onPanStart: (_) => _onDragStart(), + onPanUpdate: (d) => _onDragUpdate(d.delta, w, h), + onPanEnd: (_) => _onDragEnd(w, h), + child: AnimatedScale( + // Lift slightly while held — a tactile "picked up" feel. + scale: _dragging ? 1.12 : 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + alignment: Alignment.center, + decoration: BoxDecoration( + color: + _fabPressed ? NhamColors.btnHover : NhamColors.btn, + borderRadius: + BorderRadius.circular(NhamRadii.containerLg), // 16 + boxShadow: [ + // Deeper shadow while dragging reads as "above" the + // surface; resting shadow matches the web FAB. + BoxShadow( + color: const Color(0x2E2C2416), // #2C2416 @ 18% + blurRadius: _dragging ? 24 : 16, + offset: Offset(0, _dragging ? 8 : 4), + ), + ], + ), + child: Icon( + _expanded + ? LucideIcons.x + : LucideIcons.utensilsCrossed, + size: 20, // h-5 w-5 + color: Colors.white, + ), + ), ), - ], - ), - child: Icon( - _expanded ? LucideIcons.x : LucideIcons.utensilsCrossed, - size: 20, // h-5 w-5 - color: Colors.white, + ), ), ), - ), + ], ), - ), - ], + ); + }, ); } } diff --git a/apps/mobile-flutter/lib/shared/widgets/nham_primitives.dart b/apps/mobile-flutter/lib/shared/widgets/nham_primitives.dart index 3e048593..096d994b 100644 --- a/apps/mobile-flutter/lib/shared/widgets/nham_primitives.dart +++ b/apps/mobile-flutter/lib/shared/widgets/nham_primitives.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../theme/nham_colors.dart'; import '../../theme/nham_theme.dart'; @@ -29,9 +30,19 @@ class Screen extends StatelessWidget { child: SafeArea( top: top, bottom: bottom, - // Transparent Material ancestor so TextField/InkWell descendants resolve - // their Material lookup without painting over the cream surface. - child: Material(type: MaterialType.transparency, child: child), + // Tap anywhere on empty surface to dismiss the keyboard — the native + // mobile expectation. Interactive descendants (buttons, fields) win the + // gesture arena for their own taps, so only "background" taps drop focus. + // `opaque` makes the whole cream area hittable; `onTap` claims nothing + // that would interfere with scrolling. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + // Transparent Material ancestor so TextField/InkWell descendants + // resolve their Material lookup without painting over the cream + // surface. + child: Material(type: MaterialType.transparency, child: child), + ), ), ); } @@ -161,7 +172,13 @@ class _NhamButtonState extends State { onTapUp: _isDisabled ? null : (_) => setState(() => _pressed = false), onTapCancel: _isDisabled ? null : () => setState(() => _pressed = false), - onTap: _isDisabled ? null : widget.onPressed, + onTap: _isDisabled + ? null + : () { + // Tactile confirm on every primary/secondary/ghost action. + HapticFeedback.lightImpact(); + widget.onPressed?.call(); + }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, diff --git a/apps/mobile-flutter/lib/shell/app_header.dart b/apps/mobile-flutter/lib/shell/app_header.dart index 62b92ec6..1aeb4d04 100644 --- a/apps/mobile-flutter/lib/shell/app_header.dart +++ b/apps/mobile-flutter/lib/shell/app_header.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -145,7 +146,10 @@ class _BackButtonState extends State<_BackButton> { onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), - onTap: widget.onBack, + onTap: () { + HapticFeedback.selectionClick(); + widget.onBack(); + }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, diff --git a/apps/mobile-flutter/lib/shell/tab_scaffold.dart b/apps/mobile-flutter/lib/shell/tab_scaffold.dart index 60845195..6a8f559a 100644 --- a/apps/mobile-flutter/lib/shell/tab_scaffold.dart +++ b/apps/mobile-flutter/lib/shell/tab_scaffold.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import '../theme/nham_colors.dart'; @@ -38,6 +39,9 @@ class _TabScaffoldState extends State ); void _open() { + // Tactile cue only when actually opening from rest — not on a repeat tap + // while already open, nor on every frame of an edge-swipe. + if (_controller.value == 0) HapticFeedback.lightImpact(); _controller.forward(); } @@ -45,6 +49,24 @@ class _TabScaffoldState extends State _controller.reverse(); } + /// Edge-swipe driver: while the user drags in from the left edge, advance the + /// open animation 1:1 with the finger. + void _onEdgeDragUpdate(double delta, double panelWidth) { + _controller.value = + (_controller.value + delta / panelWidth).clamp(0.0, 1.0); + } + + /// Edge-swipe release: fling/threshold decides whether it settles open. + void _onEdgeDragEnd(double velocity) { + final opening = velocity > 200 || (velocity >= -200 && _controller.value > 0.4); + if (opening) { + HapticFeedback.lightImpact(); + _controller.forward(); + } else { + _controller.reverse(); + } + } + @override void dispose() { _controller.dispose(); @@ -53,6 +75,11 @@ class _TabScaffoldState extends State @override Widget build(BuildContext context) { + final media = MediaQuery.of(context); + // Mirror the panel width the drawer itself uses, so an edge-drag maps the + // finger to the panel travel 1:1. + final panelWidth = (media.size.width * 0.88).clamp(0.0, 320.0).toDouble(); + return NavDrawerScope( open: _open, child: Scaffold( @@ -60,6 +87,27 @@ class _TabScaffoldState extends State body: Stack( children: [ widget.navigationShell, + // Left-edge swipe-to-open zone. A narrow strip (matching the native + // Android drawer affordance) so it never steals horizontal swipes + // from page content. Only claims horizontal drags — vertical scroll + // in the strip still reaches the list beneath. Sits below the drawer + // so once open, the drawer's own scrim/panel gestures take over. + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 20, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: (d) => + _onEdgeDragUpdate(d.primaryDelta ?? 0, panelWidth), + onHorizontalDragEnd: (d) => + _onEdgeDragEnd(d.primaryVelocity ?? 0), + // An interrupted swipe (recognizer loses the arena) never fires + // dragEnd — settle from rest so the drawer can't stick halfway. + onHorizontalDragCancel: () => _onEdgeDragEnd(0), + ), + ), _NavDrawer(controller: _controller, onClose: _close), ], ),