Files
doormile_customer_app/lib/data/map_config.dart
Thiru-tenext c7a74c57b8 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.
2026-09-30 11:19:30 +05:30

321 lines
12 KiB
Dart

/// Where map tiles come from, what must be credited, and any key needed to ask
/// for them.
///
/// This is the **only** place a tile URL, an attribution string or a map API
/// key exists. No widget builds a URL; screens ask for a style and get back
/// whatever the configured provider serves. Swapping CARTO for MapTiler, for a
/// keyed CARTO plan, or for self-hosted tiles is a build flag or one entry in
/// [MapTileProvider.presets] — no map screen changes.
///
/// Deliberately free of Flutter imports so the choice of provider is testable
/// as plain data. `lib/ui/widgets/map_tiles.dart` turns it into a `TileLayer`.
library;
/// The two cartographies the app uses.
enum DmMapStyle {
/// Streets, labels and landmarks. The pickup map, where the customer is
/// looking for their own door.
streets,
/// Quieter, near-monochrome. The route map, where the line is the subject
/// and the basemap is context.
muted,
}
/// A raster tile source: its URL templates, its credit line, and its key.
class MapTileProvider {
const MapTileProvider({
required this.id,
required this.name,
required this.templates,
required this.attribution,
this.attributionUrl = osmCopyright,
this.subdomains = const <String>[],
this.keyParam,
this.apiKey = '',
this.requiresKey = false,
this.maxNativeZoom = 20,
this.retina = true,
});
/// Stable id, also what `DM_MAP_PROVIDER` selects.
final String id;
/// Human name, for the "map source" line in diagnostics.
final String name;
/// One URL template per style. `{s}` `{z}` `{x}` `{y}` `{r}` as flutter_map
/// expects; the key is appended by [urlFor], never written into a template.
final Map<DmMapStyle, String> templates;
/// The credit line that must stay visible on every map using this source.
final String attribution;
/// Where the attribution links, for the licence.
final String attributionUrl;
/// Tile host shards, when the provider uses them.
final List<String> subdomains;
/// Query parameter the key travels in — `api_key` for CARTO and Stadia,
/// `key` for MapTiler. Null when the provider takes no key.
final String? keyParam;
/// The key itself. Supplied at build time, never committed.
final String apiKey;
/// True when the provider serves nothing without a key, so a missing key is
/// a misconfiguration rather than a downgrade.
final bool requiresKey;
final int maxNativeZoom;
/// Whether the provider serves `@2x` tiles for the `{r}` placeholder.
final bool retina;
static const osmCopyright = 'https://www.openstreetmap.org/copyright';
bool get hasKey => apiKey.isNotEmpty;
/// False when this provider cannot serve a tile as configured.
bool get usable => !requiresKey || hasKey;
/// The template for [style], with the key appended when there is one.
String urlFor(DmMapStyle style) {
final template = templates[style] ?? templates[DmMapStyle.streets]!;
if (!hasKey || keyParam == null) return template;
final separator = template.contains('?') ? '&' : '?';
return '$template$separator$keyParam=$apiKey';
}
MapTileProvider withKey(String key) => MapTileProvider(
id: id,
name: name,
templates: templates,
attribution: attribution,
attributionUrl: attributionUrl,
subdomains: subdomains,
keyParam: keyParam,
apiKey: key,
requiresKey: requiresKey,
maxNativeZoom: maxNativeZoom,
retina: retina,
);
// ------------------------------------------------------------- presets
/// CARTO's basemaps over OpenStreetMap data. The development default:
/// warm, legible, and usable without an account.
///
/// Anonymous use is **not** a production contract. CARTO issues API keys for
/// application use — set `DM_MAP_KEY` and it travels as `api_key` on every
/// tile request, no other change required.
static const carto = MapTileProvider(
id: 'carto',
name: 'CARTO basemaps (OpenStreetMap data)',
templates: {
DmMapStyle.streets:
'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
DmMapStyle.muted:
'https://{s}.basemaps.cartocdn.com/rastertiles/light_all/{z}/{x}/{y}{r}.png',
},
attribution: '© OpenStreetMap contributors · © CARTO',
subdomains: ['a', 'b', 'c', 'd'],
keyParam: 'api_key',
);
/// OpenStreetMap's own tile servers. **The app's default.**
///
/// ── 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',
templates: {
DmMapStyle.streets: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
DmMapStyle.muted: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
},
attribution: '© OpenStreetMap contributors',
maxNativeZoom: 19,
retina: false,
);
/// MapTiler — a commercial provider with an India-usable free tier.
static const maptiler = MapTileProvider(
id: 'maptiler',
name: 'MapTiler',
templates: {
DmMapStyle.streets:
'https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}{r}.png',
DmMapStyle.muted:
'https://api.maptiler.com/maps/dataviz-light/{z}/{x}/{y}{r}.png',
},
attribution: '© MapTiler · © OpenStreetMap contributors',
keyParam: 'key',
requiresKey: true,
);
/// Stadia Maps, serving OpenStreetMap-derived styles.
static const stadia = MapTileProvider(
id: 'stadia',
name: 'Stadia Maps',
templates: {
DmMapStyle.streets:
'https://tiles.stadiamaps.com/tiles/osm_bright/{z}/{x}/{y}{r}.png',
DmMapStyle.muted:
'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png',
},
attribution: '© Stadia Maps · © OpenStreetMap contributors',
keyParam: 'api_key',
requiresKey: true,
);
/// Everything that ships with the app, by id.
static const Map<String, MapTileProvider> presets = {
'carto': carto,
'osm': osm,
'maptiler': maptiler,
'stadia': stadia,
};
}
/// The resolved map configuration for this build.
///
/// Read once at startup from `--dart-define`s; [instance] is assignable so a
/// test or a staging build can substitute one without touching a screen.
class DmMapConfig {
const DmMapConfig({
required this.provider,
required this.userAgent,
this.warning,
});
final MapTileProvider provider;
/// Sent as the `User-Agent` on every tile request, so the provider can
/// identify and rate-limit the app properly rather than seeing raw traffic.
final String userAgent;
/// Set when the requested configuration could not be honoured — surfaced in
/// debug diagnostics rather than silently swallowed.
final String? warning;
/// Swappable for tests and staging.
static DmMapConfig instance = DmMapConfig.fromEnvironment();
/// The bundle id, which is also what identifies us to a tile provider.
static const packageName = 'in.doormile.customer';
// ---- build-time configuration -------------------------------------------
//
// flutter run \
// --dart-define=DM_MAP_PROVIDER=maptiler \
// --dart-define=DM_MAP_KEY=xxxxxxxx
//
// flutter build appbundle \
// --dart-define=DM_MAP_PROVIDER=carto \
// --dart-define=DM_MAP_KEY=$CARTO_API_KEY
//
// # a fully custom or self-hosted style
// flutter run \
// --dart-define=DM_MAP_PROVIDER=custom \
// --dart-define=DM_MAP_URL=https://tiles.doormile.in/streets/{z}/{x}/{y}.png \
// --dart-define=DM_MAP_URL_MUTED=https://tiles.doormile.in/light/{z}/{x}/{y}.png \
// --dart-define=DM_MAP_ATTRIBUTION='© Doormile · © OpenStreetMap contributors'
static const _provider = String.fromEnvironment('DM_MAP_PROVIDER');
static const _key = String.fromEnvironment('DM_MAP_KEY');
static const _url = String.fromEnvironment('DM_MAP_URL');
static const _urlMuted = String.fromEnvironment('DM_MAP_URL_MUTED');
static const _subdomains = String.fromEnvironment('DM_MAP_SUBDOMAINS');
static const _attribution = String.fromEnvironment('DM_MAP_ATTRIBUTION');
static const _attributionUrl = String.fromEnvironment('DM_MAP_ATTRIBUTION_URL');
static const _maxZoom = int.fromEnvironment('DM_MAP_MAX_ZOOM', defaultValue: 0);
static const _appVersion = String.fromEnvironment(
'DM_APP_VERSION',
defaultValue: '1.0.0',
);
factory DmMapConfig.fromEnvironment() {
// 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.osm;
warning =
'DM_MAP_PROVIDER=custom needs DM_MAP_URL; fell back to OpenStreetMap.';
} else {
provider = MapTileProvider(
id: 'custom',
name: 'Custom tile source',
templates: {
DmMapStyle.streets: _url,
DmMapStyle.muted: _urlMuted.isEmpty ? _url : _urlMuted,
},
attribution: _attribution.isEmpty
? '© OpenStreetMap contributors'
: _attribution,
attributionUrl: _attributionUrl.isEmpty
? MapTileProvider.osmCopyright
: _attributionUrl,
subdomains: _subdomains.isEmpty
? const []
: _subdomains.split(',').map((s) => s.trim()).toList(),
maxNativeZoom: _maxZoom == 0 ? 20 : _maxZoom,
retina: _url.contains('{r}'),
);
}
} else {
provider = MapTileProvider.presets[id] ?? MapTileProvider.osm;
if (!MapTileProvider.presets.containsKey(id)) {
warning = 'Unknown DM_MAP_PROVIDER "$id"; fell back to OpenStreetMap.';
}
}
if (_key.isNotEmpty) provider = provider.withKey(_key);
// A keyed provider with no key would render nothing at all. Better a
// downgraded map than a grey rectangle in a customer's hands.
if (!provider.usable) {
warning =
'${provider.name} needs DM_MAP_KEY; fell back to CARTO for this build.';
provider = MapTileProvider.carto;
if (_key.isNotEmpty) provider = provider.withKey(_key);
}
return DmMapConfig(
provider: provider,
userAgent: 'Doormile/$_appVersion ($packageName)',
warning: warning,
);
}
String urlFor(DmMapStyle style) => provider.urlFor(style);
String get attribution => provider.attribution;
String get attributionUrl => provider.attributionUrl;
List<String> get subdomains => provider.subdomains;
int get maxNativeZoom => provider.maxNativeZoom;
bool get retina => provider.retina;
/// One line for a diagnostics screen or a log at startup.
String get describe =>
'${provider.name}${provider.hasKey ? ' (keyed)' : ' (anonymous)'}';
}