Five payload bugs, four pages behind dead rows, and one sheet

── The full-address path was reaching the Miler empty ──

`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.

Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.

The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.

── Who the Miler rings ──

One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.

Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.

── Account's rows led nowhere ──

Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.

Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.

── ONE TOUCH is one sheet ──

It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.

Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.

── Sending a second parcel ──

`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.

Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.

── Bundle ──

pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
This commit is contained in:
2026-09-29 12:31:58 +05:30
parent 8427824951
commit c3e25feaea
59 changed files with 3013 additions and 443 deletions

View File

@@ -46,24 +46,43 @@ import '../../widgets/states.dart';
Future<List<CityOption>?> showDestinationSheet(BuildContext context) {
return showDmSheet<List<CityOption>>(
context: context,
builder: (context) => const _DestinationSheet(),
builder: (context) => DestinationPicker(
onPicked: (cities) => Navigator.of(context).pop(cities),
),
);
}
class _DestinationSheet extends StatefulWidget {
const _DestinationSheet();
/// The *where* half, without a sheet around it.
///
/// ── Why this is a widget and not a sheet ──
///
/// It is used twice: on its own behind Review's "Change", and as the first
/// step of [showPickupSheet], where it shares one surface with the pickup
/// window rather than closing so a second sheet can open over it. Extracting
/// the body is what lets the merged flow reuse this list instead of owning a
/// second copy of it.
class DestinationPicker extends StatefulWidget {
const DestinationPicker({
super.key,
required this.onPicked,
this.showHeader = true,
});
/// Called with the chosen cities. The caller decides what that means — pop a
/// sheet, or move to the next step of one.
final ValueChanged<List<CityOption>> onPicked;
/// False when the host is drawing the title and the close itself.
final bool showHeader;
@override
State<_DestinationSheet> createState() => _DestinationSheetState();
State<DestinationPicker> createState() => _DestinationPickerState();
}
class _DestinationSheetState extends State<_DestinationSheet> {
class _DestinationPickerState extends State<DestinationPicker> {
final _query = TextEditingController();
String _q = '';
/// The state being browsed, or null while the states themselves are.
ServiceArea? _openState;
/// Chosen districts, by district code, in the order they were picked.
final _chosen = <String, CityOption>{};
@@ -73,27 +92,10 @@ class _DestinationSheetState extends State<_DestinationSheet> {
super.dispose();
}
void _open(ServiceArea area) {
HapticFeedback.selectionClick();
setState(() {
_openState = area;
_query.clear();
_q = '';
});
}
void _back() {
setState(() {
_openState = null;
_query.clear();
_q = '';
});
}
void _tap(CityOption city, {required bool multi, required int cap}) {
HapticFeedback.selectionClick();
if (!multi) {
Navigator.of(context).pop([city]);
widget.onPicked([city]);
return;
}
final code = city.district.code;
@@ -126,7 +128,6 @@ class _DestinationSheetState extends State<_DestinationSheet> {
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final browsing = _openState;
final cap = app.limits.maxDestinations;
final multi = app.allowsMultipleDestinations;
@@ -134,66 +135,44 @@ class _DestinationSheetState extends State<_DestinationSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back inside the sheet, not out of it: the customer is one level
// into a question they are still answering, and dismissing the
// whole thing to change their mind about a state would throw away
// both the step they got right and everything they have ticked.
if (browsing != null)
Padding(
padding: const EdgeInsets.only(right: 4),
child: InkResponse(
onTap: _back,
radius: 22,
highlightShape: BoxShape.circle,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft,
child: const SizedBox(
width: 36,
height: 36,
child: Icon(
LucideIcons.arrowLeft,
size: 20,
color: DmColors.ink,
),
),
if (widget.showHeader)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DmSheetHeader(
title: 'Where is it going?',
// ── One level, and the subtitle says what the list is ──
//
// This used to open on states and make the customer pick one
// to see any city, so the subtitle had to explain that tapping
// a row opened it rather than chose it — a sentence that only
// existed because the shape needed defending.
//
// The list is now every city Doormile serves, headed by state.
// That is also the answer to "where do you deliver?", which is
// the question a customer opening this actually has, and it
// was previously two taps down.
subtitle: multi
? 'Every city we serve. Choose one or more.'
: 'Every city we serve',
),
),
Expanded(
child: DmSheetHeader(
title: browsing?.name ?? 'Where is it going?',
// ── A line of orientation ──
//
// The sheet asked a question and then showed a list, and a
// customer who has never used it cannot tell whether tapping
// a state chooses it or opens it. One sentence says which,
// and at the district step says how many they may pick.
subtitle: browsing == null
? 'Pick a state to see the cities it serves'
: (app.allowsMultipleDestinations
? 'Choose one or more districts'
: 'Choose a district'),
// A close, because a sheet that can only be dismissed by
// dragging is a sheet somebody will get stuck in.
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
background: DmColors.surface,
border: Colors.transparent,
size: 38,
onPressed: () => Navigator.of(context).pop(),
),
),
// A close, because a sheet that can only be dismissed by dragging
// is a sheet somebody will get stuck in.
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
background: DmColors.surface,
border: Colors.transparent,
size: 38,
onPressed: () => Navigator.of(context).pop(),
),
],
),
],
),
DmSearchField(
controller: _query,
hint: browsing == null
? 'Search for a city'
: 'Search in ${browsing.name}',
hint: 'Search for a city',
onChanged: (v) => setState(() => _q = v.trim().toLowerCase()),
),
const SizedBox(height: 6),
@@ -215,30 +194,45 @@ class _DestinationSheetState extends State<_DestinationSheet> {
// Sized against the screen rather than fixed in points, and floored
// above the skeleton's own height so it can never be the thing that
// decides how tall this is.
SizedBox(
height: (MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0),
child: DmAsyncList<CityOption>(
key: const ValueKey('destinationList'),
// Three, not four: 3 x 68 plus gaps is 224, which fits inside the
// smallest box the clamp above can produce. Four did not.
skeletonRows: 3,
// Skips the skeleton entirely when the cities are already known,
// which after the first open they always are. A shimmer that
// appears and vanishes inside one frame is noise.
initialItems: app.cachedCities,
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
emptyIcon: LucideIcons.mapPinOff,
emptyTitle: 'No cities open yet',
emptyMessage:
"We're not accepting new pickups right now. Please check "
'back shortly.',
builder: (context, cities) => SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._body(cities, multi: multi, cap: cap),
const SizedBox(height: 4),
],
// ── Flexible around the fixed box, not instead of it ──
//
// The box is what stops the sheet resizing as its content loads. The
// `Flexible` is what stops it overflowing when something *else* on the
// sheet grows — the multi-select confirm button appearing, a large
// text scale, a two-line header — on a sheet already at the modal's
// 86% cap. A strip of recent destinations sat here briefly and
// overflowed it by twenty points, which is how the need was found.
//
// Loose fit: the box asks for its height and gets it whenever there is
// room, and gives way rather than overflowing when there is not. Fixed
// in the common case, never the thing that breaks the layout.
Flexible(
child: SizedBox(
height: (MediaQuery.sizeOf(context).height * 0.44)
.clamp(240.0, 420.0),
child: DmAsyncList<CityOption>(
key: const ValueKey('destinationList'),
// Three, not four: 3 x 68 plus gaps is 224, which fits inside the
// smallest box the clamp above can produce. Four did not.
skeletonRows: 3,
// Skips the skeleton entirely when the cities are already known,
// which after the first open they always are. A shimmer that
// appears and vanishes inside one frame is noise.
initialItems: app.cachedCities,
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
emptyIcon: LucideIcons.mapPinOff,
emptyTitle: 'No cities open yet',
emptyMessage:
"We're not accepting new pickups right now. Please check "
'back shortly.',
builder: (context, cities) => SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._body(cities, multi: multi, cap: cap),
const SizedBox(height: 4),
],
),
),
),
),
@@ -250,21 +244,31 @@ class _DestinationSheetState extends State<_DestinationSheet> {
? 'Send to ${_chosen.values.first.district.name}'
: 'Send to ${_chosen.length} places',
icon: LucideIcons.arrowRight,
onPressed: () =>
Navigator.of(context).pop(_chosen.values.toList()),
onPressed: () => widget.onPicked(_chosen.values.toList()),
),
],
],
);
}
/// Every serviceable city, headed by its state.
///
/// ── One level, not two ──
///
/// This used to be a list of states that had to be opened to reach a city.
/// Two levels of navigation, a back button inside the sheet, and — by the
/// file's own admission — a search box that already cut across every state
/// from the first step, which is the path anybody who knew their destination
/// actually took. The browse existed for the customer who did *not* know,
/// and that customer is better served by seeing the whole list.
///
/// The state is a heading now. It still groups, it still carries its mark,
/// and it is no longer somewhere you have to go.
List<Widget> _body(
List<CityOption> cities, {
required bool multi,
required int cap,
}) {
final browsing = _openState;
Widget district(CityOption city) => _DistrictRow(
city: city,
multi: multi,
@@ -272,13 +276,8 @@ class _DestinationSheetState extends State<_DestinationSheet> {
onTap: () => _tap(city, multi: multi, cap: cap),
);
// Searching from the top level goes straight to districts, across every
// state. Searching inside one stays inside it.
if (_q.isNotEmpty) {
final pool = browsing == null
? cities
: cities.where((c) => c.state.code == browsing.code);
final matches = pool
final matches = cities
.where((c) => c.label.toLowerCase().contains(_q))
.toList();
if (matches.isEmpty) {
@@ -293,77 +292,50 @@ class _DestinationSheetState extends State<_DestinationSheet> {
return [for (final city in matches) district(city)];
}
if (browsing != null) {
return [
for (final city in cities.where((c) => c.state.code == browsing.code))
district(city),
];
final out = <Widget>[];
for (final area in _statesOf(cities)) {
final inArea = cities.where((c) => c.state.code == area.code).toList();
out.add(_StateHead(area: area, first: out.isEmpty));
out.addAll(inArea.map(district));
}
return [
for (final area in _statesOf(cities))
_StateRow(
area: area,
// The count is the reason to open it, and it is free: the list it
// came from is the list the next step shows.
count: cities.where((c) => c.state.code == area.code).length,
// How many of this state's districts are already in, so a customer
// who has ticked two in Tamil Nadu can see that from the top.
chosen: cities
.where(
(c) =>
c.state.code == area.code &&
_chosen.containsKey(c.district.code),
)
.length,
onTap: () => _open(area),
),
];
return out;
}
}
/// One state: its mark, its name, and how many cities are open inside it.
class _StateRow extends StatelessWidget {
const _StateRow({
required this.area,
required this.count,
required this.chosen,
required this.onTap,
});
/// A state's name over the cities inside it. A heading, not a row: nothing
/// happens when it is tapped, because there is nowhere left to go.
class _StateHead extends StatelessWidget {
const _StateHead({required this.area, required this.first});
final ServiceArea area;
final int count;
final int chosen;
final VoidCallback onTap;
final bool first;
@override
Widget build(BuildContext context) {
return _Row(
onTap: onTap,
// The mark, with nothing behind it. It sat in a 38pt washed-crimson
// tile, so a list of six states was six pink squares in a column — six
// containers and six spots of brand to hold six glyphs that read
// perfectly well on the sheet's own surface.
leading: Icon(stateMark(area.code), size: 21, color: DmColors.ink3),
title: area.name,
subtitle:
'$count ${count == 1 ? 'city' : 'cities'}'
'${area.transitTag == null ? '' : ' · ${area.transitTag}'}',
// The number of districts already chosen in this state. It was a filled
// crimson pill — a badge, for a figure that is only ever 1, 2 or 3.
trailing: chosen == 0
? null
: Text(
'$chosen',
style: DmText.cardTitle.copyWith(color: DmColors.brand),
return Padding(
padding: EdgeInsets.fromLTRB(2, first ? 6 : 18, 2, 8),
child: Row(
children: [
Icon(stateMark(area.code), size: 15, color: DmColors.ink4),
const SizedBox(width: 8),
Text(area.name.toUpperCase(), style: DmText.eyebrow),
if (area.transitTag != null) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
area.transitTag!,
style: DmText.tiny.copyWith(color: DmColors.ink4),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
);
}
}
/// One district. No mark — the name and the promise are the whole row, and a
/// glyph beside every one of them would be decoration competing with the only
/// two things on it that differ.
class _DistrictRow extends StatelessWidget {
const _DistrictRow({
required this.city,
@@ -419,13 +391,15 @@ class _Check extends StatelessWidget {
}
}
/// The shape both rows share: a target, a title, a quiet line, a trailing mark.
/// A city: a target, its name, its transit promise, and a tick.
///
/// It carried a `leading` slot for the state rows' marks. There are no state
/// rows any more — the state is a heading — so the slot went with them.
class _Row extends StatelessWidget {
const _Row({
required this.onTap,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.selected = false,
});
@@ -433,7 +407,6 @@ class _Row extends StatelessWidget {
final VoidCallback onTap;
final String title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final bool selected;
@@ -468,7 +441,6 @@ class _Row extends StatelessWidget {
),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 14)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
import 'destination_sheet.dart';
import 'window_sheet.dart';
/// What ONE TOUCH asks: where, and when — on one surface.
///
/// ── What this replaced ──
///
/// Two sheets in sequence. The destination sheet rose, the customer answered
/// it, it fell, and the window sheet rose behind it. The file that drove it
/// hoped this would read "as one surface changing its question rather than as
/// a sheet failing and another arriving", and it did not: two present
/// animations and a dismiss between them is three pieces of motion for two
/// questions, and the customer sees a sheet go away before they have finished.
///
/// One sheet now, two steps inside it. The surface never leaves, it does not
/// change height — see the fixed box in [DestinationPicker] — and stepping back
/// is an arrow rather than a dismissal that would throw away the answer.
///
/// ── Why the destination is still asked at all ──
///
/// It is fair to ask why: a Miler rides to the customer's door, and nothing
/// about that trip depends on where the parcel is going afterwards. The
/// destination earns its place for three reasons that are not about the trip:
///
/// * It is the only thing that can price the job. `estimateFare` takes
/// `stateCode`/`districtCode`; without them Review has no figure on it at
/// all, not even a range.
/// * It is the serviceability gate. The expensive thing here is a wasted
/// rider trip, and finding out at the door that the parcel cannot be
/// carried costs the whole visit.
/// * Orders are minted per destination when the Miler completes pickup, so
/// the booking has to know how many there are.
///
/// What was wrong was the ceremony, not the question. Two levels of browsing
/// became one list, the list is now also the answer to "where do you deliver?",
/// places sent to before are one tap, and both questions share a surface.
class PickupRequest {
const PickupRequest({required this.places, required this.slot});
final List<CityOption> places;
final PickupSlot slot;
}
/// Asks both questions and returns both answers, or null if dismissed.
Future<PickupRequest?> showPickupSheet(BuildContext context) {
return showDmSheet<PickupRequest>(
context: context,
builder: (context) => const _PickupSheet(),
);
}
class _PickupSheet extends StatefulWidget {
const _PickupSheet();
@override
State<_PickupSheet> createState() => _PickupSheetState();
}
class _PickupSheetState extends State<_PickupSheet> {
List<CityOption>? _places;
bool get _onWhen => _places != null;
void _back() {
HapticFeedback.selectionClick();
setState(() => _places = null);
}
@override
Widget build(BuildContext context) {
final places = _places;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back to the first question, not out of the sheet. Dismissing to
// change a destination would throw away the step already answered.
if (_onWhen)
Padding(
padding: const EdgeInsets.only(right: 4),
child: InkResponse(
onTap: _back,
radius: 22,
highlightShape: BoxShape.circle,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft,
child: const SizedBox(
width: 36,
height: 36,
child: Icon(
LucideIcons.arrowLeft,
size: 20,
color: DmColors.ink,
),
),
),
),
Expanded(
child: DmSheetHeader(
title: _onWhen ? 'When should we come?' : 'Where is it going?',
// On the second step the subtitle is the answer to the first,
// so the customer can see what they are booking against
// without going back for it.
subtitle: _onWhen
? _summarise(places!)
: 'Every city we serve',
),
),
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
background: DmColors.surface,
border: Colors.transparent,
size: 38,
onPressed: () => Navigator.of(context).pop(),
),
],
),
// ── Flexible, because a cross-fade is briefly both steps ──
//
// `AnimatedSwitcher` stacks the outgoing child under the incoming one
// and takes the height of the taller, so for the length of the fade
// this is as tall as *where* plus nothing to give. Unconstrained that
// is eleven points past the sheet's cap and a rendering assertion on a
// frame no screenshot catches — it only ever exists mid-animation.
//
// Constrained, the overshoot clips for those few frames, which is
// invisible under a cross-fade, and each step still gets the height it
// asks for once it is the only one there.
Flexible(
child: AnimatedSwitcher(
duration: DmMotion.base,
switchInCurve: DmMotion.ease,
switchOutCurve: DmMotion.ease,
child: _onWhen
? Padding(
key: const ValueKey('when'),
padding: const EdgeInsets.only(top: 6),
child: SizedBox(
height: _stepHeight(context),
child: WindowPicker(
confirmLabel: 'Confirm pickup',
onPicked: (slot) => Navigator.of(context).pop(
PickupRequest(places: places!, slot: slot),
),
),
),
)
: DestinationPicker(
key: const ValueKey('where'),
showHeader: false,
onPicked: (chosen) => setState(() => _places = chosen),
),
),
),
],
);
}
/// The same box [DestinationPicker] gives its list, so the two steps are the
/// same height and the sheet does not jump when it changes its question.
static double _stepHeight(BuildContext context) =>
(MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0);
static String _summarise(List<CityOption> places) {
if (places.length == 1) return 'Going to ${places.first.label}';
return 'Going to ${places.length} places';
}
}

View File

@@ -28,21 +28,11 @@ Future<PickupSlot?> showWindowSheet(BuildContext context) {
);
}
class _WindowSheet extends StatefulWidget {
class _WindowSheet extends StatelessWidget {
const _WindowSheet();
@override
State<_WindowSheet> createState() => _WindowSheetState();
}
class _WindowSheetState extends State<_WindowSheet> {
String? _day;
PickupSlot? _picked;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -58,8 +48,52 @@ class _WindowSheetState extends State<_WindowSheet> {
),
],
),
Flexible(
child: DmAsyncList<PickupSlot>(
// The same box the pickup sheet gives its steps, so this sheet is one
// height whether it is loading, listing or empty — and so `Expanded`
// inside the picker has something to divide.
SizedBox(
height: (MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0),
child: WindowPicker(
onPicked: (slot) => Navigator.of(context).pop(slot),
),
),
],
);
}
}
/// The *when* half, without a sheet around it.
///
/// ── Why this is a widget and not a sheet ──
///
/// It is used twice: on its own behind Review's "Change", and as the second
/// step of [showPickupSheet], where it shares one surface with the destination
/// rather than arriving as a second sheet over the first. Extracting the body
/// is what lets the merged flow reuse this instead of owning a second copy of
/// day chips and slot rows that would drift.
class WindowPicker extends StatefulWidget {
const WindowPicker({super.key, required this.onPicked, this.confirmLabel});
/// Called with the chosen slot. The caller decides what that means — pop a
/// sheet, or move to the next step of one.
final ValueChanged<PickupSlot> onPicked;
/// Overrides the button's text. Null uses "Use 2:00 – 4:00 PM".
final String? confirmLabel;
@override
State<WindowPicker> createState() => _WindowPickerState();
}
class _WindowPickerState extends State<WindowPicker> {
String? _day;
PickupSlot? _picked;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
return DmAsyncList<PickupSlot>(
reloadToken: app.slotsEpoch,
load: ({bool refresh = false}) => app.loadSlots(refresh: refresh),
emptyIcon: LucideIcons.clock,
@@ -81,64 +115,75 @@ class _WindowSheetState extends State<_WindowSheet> {
_slotById(slots, app.draftSlotId) ??
shown.where((s) => s.available).firstOrNull;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// One row of days rather than a heading per day: the list
// under it stays short enough to take in at a glance, which
// is the whole reason this is a sheet.
// The segments sit in a groove, which is what makes the
// chosen one read as raised rather than as merely white.
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: DmColors.groove,
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
// ── Days and the button are pinned; only the slots scroll ──
//
// This was one scroll view holding all three. At a large text
// scale the slot rows grow until the button is below the fold,
// and the primary action on a sheet must never be something you
// have to find. The day switcher is a control too, so it stays
// put as well.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// One row of days rather than a heading per day: the list
// under it stays short enough to take in at a glance, which
// is the whole reason this is a sheet.
// The segments sit in a groove, which is what makes the
// chosen one read as raised rather than as merely white.
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: DmColors.groove,
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
for (final d in days) ...[
if (d != days.first) const SizedBox(width: 4),
DmChoiceChip(
label: d,
selected: d == day,
expand: true,
onTap: () => setState(() => _day = d),
),
],
],
),
),
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final d in days) ...[
if (d != days.first) const SizedBox(width: 4),
DmChoiceChip(
label: d,
selected: d == day,
expand: true,
onTap: () => setState(() => _day = d),
for (final slot in shown) ...[
_SlotRow(
slot: slot,
selected: selected?.id == slot.id,
onTap: () => setState(() => _picked = slot),
),
const SizedBox(height: 10),
],
],
),
),
const SizedBox(height: 16),
for (final slot in shown) ...[
_SlotRow(
slot: slot,
selected: selected?.id == slot.id,
onTap: () => setState(() => _picked = slot),
),
const SizedBox(height: 10),
],
const SizedBox(height: 6),
DmButton(
),
const SizedBox(height: 6),
DmButton(
label: selected == null
? 'Choose a window'
: 'Use ${selected.window}',
: (widget.confirmLabel ?? 'Use ${selected.window}'),
onPressed: selected == null
? null
: () {
app.selectSlot(selected);
Navigator.of(context).pop(selected);
widget.onPicked(selected);
},
),
const SizedBox(height: 4),
],
),
const SizedBox(height: 4),
],
);
},
),
),
],
);
}