/// 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 [], 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 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 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 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 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)'}'; }