BOOK NOW, OpenStreetMap, and one segmented control instead of two
── The circle says what pressing it does ── "ONE TOUCH" named the *mechanism* — one gesture, no form — which is something the product team knows and a customer has to be taught. Nobody arrives at that screen wanting a touch; they want a parcel collected. The caption under the sphere still carries what makes it different from the form below. Renamed in the comments too. A codebase explaining "One Touch" against a button that says BOOK NOW is a trap for whoever reads it next. ── OpenStreetMap everywhere ── One line: the default provider was CARTO, it is `osm`. Nothing else moves — `DmMapTiles` already reads the template, subdomains, retina flag and attribution off the provider, so the credit line follows on its own. One thing recorded on the provider itself rather than left to be discovered: these are donated servers and the OSM Foundation's tile policy does not permit a distributed app to lean on them. A block looks like every tile turning into the ground colour at once, with no other symptom. Moving off it is one define — `DM_MAP_PROVIDER=carto|maptiler|stadia`, all serving OpenStreetMap data — and the map_config test now asserts the identifying User-Agent rather than only the URL, because that is what attributable traffic depends on. ── Orders had a second copy of the segmented control ── Its own `_Tab`, a pill radius, 3pt of padding and the count folded into the label's text — beside the pickup window's day switcher, which is DmChoiceChip in a rounded groove with 4pt of padding and the count in a bubble. Two controls doing one job, drifting apart a padding value at a time. It is the same control now, and `_Tab` is gone. DmChoiceChip's horizontal padding drops 12 → 9: three of them split a 390pt phone and "Cancelled" truncated to "Cancell…" at the old value. The day switcher has two chips and acres of room, so it loses nothing.
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 259 KiB |
@@ -124,10 +124,22 @@ class MapTileProvider {
|
||||
keyParam: 'api_key',
|
||||
);
|
||||
|
||||
/// OpenStreetMap's own tile servers.
|
||||
/// OpenStreetMap's own tile servers. **The app's default.**
|
||||
///
|
||||
/// Their usage policy does not permit a distributed app to lean on these —
|
||||
/// present for local development and as a last-resort fallback only.
|
||||
/// ── One thing to know before this ships ──
|
||||
///
|
||||
/// These are donated servers, and the OSM Foundation's tile usage policy
|
||||
/// does not permit a distributed app to lean on them. It asks for a valid
|
||||
/// identifying User-Agent (we send one — see `DmMapTiles.layer`), no bulk
|
||||
/// downloading, and it reserves the right to block traffic that grows past
|
||||
/// what a hobby project would make. A block looks like every tile in the app
|
||||
/// turning into the ground colour at once, with no other symptom.
|
||||
///
|
||||
/// Nothing in the app needs to change when that becomes a problem: set
|
||||
/// `DM_MAP_PROVIDER` to `carto`, `maptiler` or `stadia` — all three serve
|
||||
/// OpenStreetMap data and the map looks near enough the same — and add the
|
||||
/// provider's key as `DM_MAP_KEY`. The attribution line follows the provider
|
||||
/// on its own, so nothing else is touched.
|
||||
static const osm = MapTileProvider(
|
||||
id: 'osm',
|
||||
name: 'OpenStreetMap standard tiles',
|
||||
@@ -237,15 +249,17 @@ class DmMapConfig {
|
||||
);
|
||||
|
||||
factory DmMapConfig.fromEnvironment() {
|
||||
final id = _provider.isEmpty ? 'carto' : _provider;
|
||||
// OpenStreetMap unless a build says otherwise. See [MapTileProvider.osm]
|
||||
// for what that commits us to, and how to move off it in one define.
|
||||
final id = _provider.isEmpty ? 'osm' : _provider;
|
||||
String? warning;
|
||||
|
||||
MapTileProvider provider;
|
||||
if (id == 'custom') {
|
||||
if (_url.isEmpty) {
|
||||
provider = MapTileProvider.carto;
|
||||
provider = MapTileProvider.osm;
|
||||
warning =
|
||||
'DM_MAP_PROVIDER=custom needs DM_MAP_URL; fell back to CARTO.';
|
||||
'DM_MAP_PROVIDER=custom needs DM_MAP_URL; fell back to OpenStreetMap.';
|
||||
} else {
|
||||
provider = MapTileProvider(
|
||||
id: 'custom',
|
||||
@@ -268,9 +282,9 @@ class DmMapConfig {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
provider = MapTileProvider.presets[id] ?? MapTileProvider.carto;
|
||||
provider = MapTileProvider.presets[id] ?? MapTileProvider.osm;
|
||||
if (!MapTileProvider.presets.containsKey(id)) {
|
||||
warning = 'Unknown DM_MAP_PROVIDER "$id"; fell back to CARTO.';
|
||||
warning = 'Unknown DM_MAP_PROVIDER "$id"; fell back to OpenStreetMap.';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1044,7 +1044,7 @@ class DestinationGroup {
|
||||
return {
|
||||
...destination.toJson(),
|
||||
'packageCount': packageCount,
|
||||
// Omitted rather than sent empty: One Touch fills none of this in, and
|
||||
// Omitted rather than sent empty: BOOK NOW fills none of this in, and
|
||||
// `details: {}` is a key that says nothing.
|
||||
if (detail.isNotEmpty) 'details': detail,
|
||||
};
|
||||
|
||||
@@ -463,7 +463,7 @@ class AppState extends ChangeNotifier {
|
||||
///
|
||||
/// ── Two ways to book the same parcel ──
|
||||
///
|
||||
/// **One Touch** asks three things: the state, the district and a pickup
|
||||
/// **BOOK NOW** asks three things: the state, the district and a pickup
|
||||
/// window. Nothing else — no door number, no recipient, no weight. Those are
|
||||
/// filled in by the Miler standing at the address with the parcel in their
|
||||
/// hand, which is the only moment anybody actually knows them.
|
||||
|
||||
@@ -266,7 +266,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
),
|
||||
// ── Only the long way asks for this ──
|
||||
//
|
||||
// One Touch books on a state, a district and a window; the door is
|
||||
// BOOK NOW books on a state, a district and a window; the door is
|
||||
// filled in by the Miler who is standing at it. A customer who chose
|
||||
// the full form is choosing to save them that step, so the row is
|
||||
// here, it is outstanding until it is answered, and the button waits
|
||||
|
||||
@@ -87,7 +87,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// The full form asks for the door before it asks for the window, and it
|
||||
// asks once per destination: a visit that fans out to three places is
|
||||
// three addresses, and "for all parcels" is the whole point of choosing
|
||||
// this path over One Touch.
|
||||
// this path over BOOK NOW.
|
||||
if (detailed) {
|
||||
for (final group in app.draftDestinations) {
|
||||
if (!mounted) return;
|
||||
@@ -730,7 +730,7 @@ class _PickupLine extends StatelessWidget {
|
||||
///
|
||||
/// ── Why the app has two ways to start and only one of them is a sphere ──
|
||||
///
|
||||
/// One Touch is the product: a state, a district, a window, and the Miler
|
||||
/// BOOK NOW is the product: a state, a district, a window, and the Miler
|
||||
/// writes down the door when they get there. It is a sphere because it is one
|
||||
/// gesture, and a gesture cannot be a form.
|
||||
///
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/pieces.dart';
|
||||
import '../widgets/inputs.dart';
|
||||
import '../widgets/states.dart';
|
||||
import 'order_row.dart';
|
||||
@@ -183,20 +183,22 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
||||
///
|
||||
/// Scrollable because a fourth state is one backend release away and a row of
|
||||
/// four fixed chips at 1.8x text has nowhere to go. Each carries its count in
|
||||
/// the same pill, because "Active" and "4" are one fact.
|
||||
/// Active / Past / Cancelled, as a segmented control.
|
||||
///
|
||||
/// ── Chips → text tabs → a segment ──
|
||||
/// ── Chips → text tabs → a segment → the app's own segment ──
|
||||
///
|
||||
/// The chips were three containers, one of them filled solid black, sitting
|
||||
/// above a list of cards — the loudest object on the screen was the filter.
|
||||
/// Plain text tabs with a rule fixed that and left the row looking like a
|
||||
/// heading rather than a control.
|
||||
/// The chips were three containers, one filled solid black, sitting above a
|
||||
/// list of cards — the loudest object on the screen was the filter. Plain text
|
||||
/// tabs with a rule fixed that and left the row looking like a heading rather
|
||||
/// than a control. A groove with a raised chip in it was the right shape.
|
||||
///
|
||||
/// A groove with a raised chip in it is the shape that is unambiguously a
|
||||
/// control and still quiet: white lifted out of a warm bed, ink type, and the
|
||||
/// count beside the label because that is the reason anyone looks at the two
|
||||
/// tabs they are not on.
|
||||
/// This was then a *second* implementation of that shape: its own `_Tab`, a
|
||||
/// pill radius, 3pt of padding and the count folded into the label's text —
|
||||
/// beside the pickup window's day switcher, which is [DmChoiceChip] in a
|
||||
/// rounded groove with 4pt of padding and the count in a bubble. Two controls
|
||||
/// doing one job, drifting apart a padding value at a time.
|
||||
///
|
||||
/// It is the same control now. The count was already a parameter of it.
|
||||
class _Filter extends StatelessWidget {
|
||||
const _Filter({
|
||||
required this.labels,
|
||||
@@ -213,88 +215,28 @@ class _Filter extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.groove,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < labels.length; i++)
|
||||
Flexible(
|
||||
child: _Tab(
|
||||
label: labels[i],
|
||||
count: counts[i],
|
||||
selected: i == index,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
for (var i = 0; i < labels.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 4),
|
||||
DmChoiceChip(
|
||||
label: labels[i],
|
||||
// Zero is not a figure worth a bubble — an empty tab says so by
|
||||
// being empty when you open it.
|
||||
count: (counts[i] ?? 0) > 0 ? counts[i] : null,
|
||||
selected: i == index,
|
||||
expand: true,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatelessWidget {
|
||||
const _Tab({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int? count;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap();
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: 34),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.surface : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: selected ? DmShadow.card : null,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: label),
|
||||
if (count != null && count! > 0)
|
||||
TextSpan(
|
||||
text: ' ($count)',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12,
|
||||
color: selected ? DmColors.ink3 : DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13,
|
||||
color: selected ? DmColors.ink : DmColors.ink3,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import '../../widgets/inputs.dart';
|
||||
///
|
||||
/// Every field on it is optional to the contract and every one of them is
|
||||
/// something the Miler can fill in at the door. That is the whole argument for
|
||||
/// One Touch. A customer who chooses the full form is choosing to save the
|
||||
/// BOOK NOW. A customer who chooses the full form is choosing to save the
|
||||
/// Miler that step, so the form has to be quick enough to be worth choosing:
|
||||
/// one surface, five fields, one button.
|
||||
///
|
||||
|
||||
@@ -9,7 +9,7 @@ import '../../widgets/feedback.dart';
|
||||
import 'destination_sheet.dart';
|
||||
import 'window_sheet.dart';
|
||||
|
||||
/// What ONE TOUCH asks: where, and when — on one surface.
|
||||
/// What BOOK NOW asks: where, and when — on one surface.
|
||||
///
|
||||
/// ── What this replaced ──
|
||||
///
|
||||
|
||||
@@ -60,7 +60,14 @@ class DmBookOrb extends StatefulWidget {
|
||||
const DmBookOrb({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
this.label = 'ONE TOUCH',
|
||||
// ── "BOOK NOW", not "ONE TOUCH" ──
|
||||
//
|
||||
// The old word named the *mechanism* — one gesture, no form — which is a
|
||||
// thing the product team knows and a customer has to be taught. Nobody
|
||||
// arrives at this screen wanting a touch; they want a parcel collected.
|
||||
// "BOOK NOW" says what pressing it does, and the caption underneath still
|
||||
// carries what makes it different from the form below.
|
||||
this.label = 'BOOK NOW',
|
||||
this.caption,
|
||||
this.held = false,
|
||||
this.field = maxField,
|
||||
|
||||
@@ -131,7 +131,11 @@ class DmChoiceChip extends StatelessWidget {
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
// 9, not 12. Three of these split a 390pt phone between them on the
|
||||
// Orders filter, and at 12 the widest label truncated to "Cancell…".
|
||||
// The pickup window's day switcher has two chips and acres of room, so
|
||||
// it loses nothing.
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 9),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.surface : Colors.transparent,
|
||||
@@ -155,7 +159,7 @@ class DmChoiceChip extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
const SizedBox(width: 5),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -92,7 +92,7 @@ Future<void> serverAdvance(
|
||||
/// The sheet is left open: tests that are about the sheet itself go no
|
||||
/// further.
|
||||
Future<void> openSend(WidgetTester tester) async {
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
@@ -218,14 +218,14 @@ void main() {
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await settle(tester);
|
||||
expect(find.text('That number or PIN is incorrect'), findsOneWidget);
|
||||
expect(find.text('ONE TOUCH'), findsNothing);
|
||||
expect(find.text('BOOK NOW'), findsNothing);
|
||||
|
||||
// The right one lands on Home.
|
||||
await tester.enterText(find.byType(TextField).first, '1234');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await settle(tester);
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -256,7 +256,7 @@ void main() {
|
||||
await tester.tap(find.text('Create PIN and continue'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -265,7 +265,7 @@ void main() {
|
||||
|
||||
// Home greets by name, carries the sphere, and ends on the live card.
|
||||
expect(find.text('Hello, Joe'), findsOneWidget);
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
// The seeded active booking surfaces on Home. Its state is the uppercase
|
||||
// micro-label beside the destination, in the chip vocabulary — "Assigned",
|
||||
// not the rail's "Miler assigned".
|
||||
@@ -934,7 +934,7 @@ void main() {
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
await signIn(tester); // login + OTP
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget); // home
|
||||
expect(find.text('BOOK NOW'), findsOneWidget); // home
|
||||
|
||||
await openSend(tester); // the whole booking, one screen
|
||||
await pickCity(tester, 'Chennai');
|
||||
@@ -1014,15 +1014,19 @@ void main() {
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('Orders'));
|
||||
await settle(tester);
|
||||
// Each tab carries its own count — the figure is the rows that tab will
|
||||
// actually show, which for a collected multi-destination pickup is one
|
||||
// per destination. The count shares a paragraph with the label, so it is
|
||||
// a span rather than a Text of its own.
|
||||
// ── Label and count are separate widgets now ──
|
||||
//
|
||||
// The filter used to fold the figure into the label's own paragraph
|
||||
// ("Active (4)"), so this matched one span. It is [DmChoiceChip] now —
|
||||
// the same control as the pickup window's day switcher — which puts the
|
||||
// count in a bubble beside the label. Two Texts, not one string.
|
||||
//
|
||||
// The figure is still the rows that tab will show, which for a collected
|
||||
// multi-destination pickup is one per destination.
|
||||
expect(find.text('Active'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining(
|
||||
'Active (${state.entriesOf(BookingStatus.active).length})',
|
||||
),
|
||||
findsOneWidget,
|
||||
find.text('${state.entriesOf(BookingStatus.active).length}'),
|
||||
findsWidgets,
|
||||
);
|
||||
|
||||
// The tab row scrolls — three labels plus their counts do not fit a 390pt
|
||||
|
||||
@@ -230,7 +230,7 @@ void main() {
|
||||
// districts inside a state, then a separate window sheet. There are no
|
||||
// states to open now — the list is flat and the state is a heading — and
|
||||
// the window is the same sheet's second step.
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07-pickup-where');
|
||||
|
||||
|
||||
@@ -53,13 +53,19 @@ void main() {
|
||||
});
|
||||
|
||||
group('resolved configuration', () {
|
||||
test('defaults to CARTO and identifies the app to the provider', () {
|
||||
test('defaults to OpenStreetMap and identifies the app to it', () {
|
||||
// ── The User-Agent is not decoration here ──
|
||||
//
|
||||
// OSM's tile usage policy asks a distributed app for a valid
|
||||
// identifying agent, and blocks traffic it cannot attribute. A block
|
||||
// looks like every tile turning into the ground colour at once, with no
|
||||
// other symptom — so this asserts the identification, not just the URL.
|
||||
final config = DmMapConfig.fromEnvironment();
|
||||
expect(config.provider.id, 'carto');
|
||||
expect(config.provider.id, 'osm');
|
||||
expect(config.warning, isNull);
|
||||
expect(config.userAgent, contains('Doormile/'));
|
||||
expect(config.userAgent, contains(DmMapConfig.packageName));
|
||||
expect(config.attribution, contains('CARTO'));
|
||||
expect(config.attribution, contains('OpenStreetMap'));
|
||||
});
|
||||
|
||||
test('is swappable without touching a screen', () {
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:doormile_cx/ui/widgets/states.dart';
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// A SHEET DOES NOT CHANGE SIZE WHILE IT IS OPENING
|
||||
///
|
||||
/// Tapping ONE TOUCH opened a tall sheet that snapped shorter a few frames
|
||||
/// Tapping BOOK NOW opened a tall sheet that snapped shorter a few frames
|
||||
/// later. Nothing was wrong with either height — the sheet was simply as tall
|
||||
/// as whatever [DmAsyncList] happened to be rendering, and that changes twice
|
||||
/// before the customer has done anything: four skeleton rows at 302pt, then
|
||||
|
||||
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 259 KiB |