Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
Dart
307 lines
11 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.
|
|
///
|
|
/// Their usage policy does not permit a distributed app to lean on these —
|
|
/// present for local development and as a last-resort fallback only.
|
|
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() {
|
|
final id = _provider.isEmpty ? 'carto' : _provider;
|
|
String? warning;
|
|
|
|
MapTileProvider provider;
|
|
if (id == 'custom') {
|
|
if (_url.isEmpty) {
|
|
provider = MapTileProvider.carto;
|
|
warning =
|
|
'DM_MAP_PROVIDER=custom needs DM_MAP_URL; fell back to CARTO.';
|
|
} 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.carto;
|
|
if (!MapTileProvider.presets.containsKey(id)) {
|
|
warning = 'Unknown DM_MAP_PROVIDER "$id"; fell back to CARTO.';
|
|
}
|
|
}
|
|
|
|
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)'}';
|
|
}
|