diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 59861b6..c75d332 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -13,6 +13,7 @@ services: environment: HOST: "0.0.0.0" PORT: "8080" + PUBLIC_URL: "http://192.168.2.59:8080" MARTIN_URL: "http://martin:3001" PHOTON_URL: "http://photon:2322" OSRM_DRIVING_URL: "http://osrm-driving:5000" diff --git a/backend/src/config.rs b/backend/src/config.rs index 2e68e57..2f35737 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -4,6 +4,9 @@ pub struct AppConfig { pub host: String, pub port: u16, + /// Public-facing URL of this backend, used to rewrite tile URLs in style.json. + /// Set via PUBLIC_URL env var, defaults to http://localhost:8080. + pub public_url: String, pub martin_url: String, pub photon_url: String, pub osrm_driving_url: String, @@ -24,6 +27,8 @@ impl AppConfig { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(8080), + public_url: std::env::var("PUBLIC_URL") + .unwrap_or_else(|_| "http://localhost:8080".into()), martin_url: std::env::var("MARTIN_URL") .unwrap_or_else(|_| "http://martin:3000".into()), photon_url: std::env::var("PHOTON_URL") diff --git a/backend/src/routes/tiles.rs b/backend/src/routes/tiles.rs index eeeb0eb..5937c2c 100644 --- a/backend/src/routes/tiles.rs +++ b/backend/src/routes/tiles.rs @@ -1,6 +1,7 @@ use actix_web::{web, HttpResponse}; use bytes::Bytes; +use crate::config::AppConfig; use crate::errors::AppError; use crate::services::cache::CacheService; use crate::services::martin::MartinService; @@ -14,7 +15,13 @@ pub struct TilePath { y: u32, } -const VALID_LAYERS: &[&str] = &["openmaptiles", "terrain", "hillshade"]; +const VALID_LAYERS: &[&str] = &[ + "planet_osm_polygon", + "planet_osm_line", + "planet_osm_point", + "planet_osm_roads", + "pois", +]; /// GET /tiles/{layer}/{z}/{x}/{y}.pbf /// @@ -80,11 +87,64 @@ pub async fn get_tile( /// GET /tiles/style.json /// -/// Proxies the Mapbox GL style JSON from Martin. +/// Returns a MapLibre GL style that uses this backend as the tile proxy, +/// so Flutter clients never need to reach Martin directly. pub async fn get_style( - martin: web::Data, + config: web::Data, ) -> Result { - let style = martin.get_style().await?; + let base = &config.public_url; + + let style = serde_json::json!({ + "version": 8, + "name": "Privacy Maps", + "sources": { + "planet_osm_polygon": { + "type": "vector", + "tiles": [format!("{base}/tiles/planet_osm_polygon/{{z}}/{{x}}/{{y}}.pbf")], + "minzoom": 0, + "maxzoom": 14 + }, + "planet_osm_line": { + "type": "vector", + "tiles": [format!("{base}/tiles/planet_osm_line/{{z}}/{{x}}/{{y}}.pbf")], + "minzoom": 0, + "maxzoom": 14 + }, + "planet_osm_point": { + "type": "vector", + "tiles": [format!("{base}/tiles/planet_osm_point/{{z}}/{{x}}/{{y}}.pbf")], + "minzoom": 0, + "maxzoom": 14 + }, + "planet_osm_roads": { + "type": "vector", + "tiles": [format!("{base}/tiles/planet_osm_roads/{{z}}/{{x}}/{{y}}.pbf")], + "minzoom": 0, + "maxzoom": 14 + } + }, + "layers": [ + { "id": "background", "type": "background", + "paint": { "background-color": "#f0ebe3" } }, + { "id": "landuse", "type": "fill", "source": "planet_osm_polygon", + "source-layer": "planet_osm_polygon", + "paint": { "fill-color": "#d4e5c9", "fill-opacity": 0.6 } }, + { "id": "water", "type": "fill", "source": "planet_osm_polygon", + "source-layer": "planet_osm_polygon", + "filter": ["==", "natural", "water"], + "paint": { "fill-color": "#a0c8f0" } }, + { "id": "roads-minor", "type": "line", "source": "planet_osm_line", + "source-layer": "planet_osm_line", + "paint": { "line-color": "#ccc", "line-width": 1 } }, + { "id": "roads-main", "type": "line", "source": "planet_osm_roads", + "source-layer": "planet_osm_roads", + "paint": { "line-color": "#f5a623", "line-width": 2 } }, + { "id": "buildings", "type": "fill", "source": "planet_osm_polygon", + "source-layer": "planet_osm_polygon", + "filter": ["has", "building"], + "paint": { "fill-color": "#d9d0c7", "fill-outline-color": "#bbb" } } + ] + }); Ok(HttpResponse::Ok() .content_type("application/json; charset=utf-8") diff --git a/mobile/devtools_options.yaml b/mobile/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/mobile/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/mobile/lib/core/api/api_client.dart b/mobile/lib/core/api/api_client.dart index 7261c40..2aa2429 100644 --- a/mobile/lib/core/api/api_client.dart +++ b/mobile/lib/core/api/api_client.dart @@ -110,6 +110,13 @@ class ApiClient { } /// Riverpod provider for the API client. +/// Initialized with the saved backend URL from the database, falling back to +/// the default if nothing has been saved yet. final apiClientProvider = Provider((ref) { return ApiClient(); }); + +/// Override this in main() after loading the saved URL. +final initialBackendUrlProvider = Provider((ref) { + return AppConstants.defaultBackendUrl; +}); diff --git a/mobile/lib/features/map/presentation/screens/map_screen.dart b/mobile/lib/features/map/presentation/screens/map_screen.dart index efbfddd..fe36ccb 100644 --- a/mobile/lib/features/map/presentation/screens/map_screen.dart +++ b/mobile/lib/features/map/presentation/screens/map_screen.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:vector_map_tiles/vector_map_tiles.dart'; import '../../../../core/api/api_client.dart'; import '../../providers/map_provider.dart'; +import '../../providers/map_style_provider.dart'; import '../widgets/map_controls.dart'; import '../widgets/place_card.dart'; @@ -34,7 +35,7 @@ class _MapScreenState extends ConsumerState { Widget build(BuildContext context) { final mapState = ref.watch(mapProvider); final apiClient = ref.watch(apiClientProvider); - final tileUrl = '${apiClient.baseUrl}/tiles/openmaptiles/{z}/{x}/{y}.pbf'; + final styleAsync = ref.watch(mapStyleProvider(apiClient.baseUrl)); // Listen for zoom/center changes from the provider and move the map. ref.listen(mapProvider, (previous, next) { @@ -66,10 +67,13 @@ class _MapScreenState extends ConsumerState { }, ), children: [ - TileLayer( - urlTemplate: tileUrl, - tileProvider: CancellableNetworkTileProvider(), - userAgentPackageName: 'com.privacymaps.app', + styleAsync.when( + data: (style) => VectorTileLayer( + tileProviders: style.providers, + theme: style.theme, + ), + loading: () => const SizedBox.shrink(), + error: (e, _) => const SizedBox.shrink(), ), if (mapState.currentLocation != null) MarkerLayer( diff --git a/mobile/lib/features/map/providers/map_style_provider.dart b/mobile/lib/features/map/providers/map_style_provider.dart new file mode 100644 index 0000000..3ffe458 --- /dev/null +++ b/mobile/lib/features/map/providers/map_style_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:vector_map_tiles/vector_map_tiles.dart'; + +/// Loads and caches the MapLibre style from the backend. +/// Keyed by base URL so a URL change triggers a reload. +final mapStyleProvider = + FutureProvider.family((ref, baseUrl) async { + final styleUrl = '$baseUrl/tiles/style.json'; + return StyleReader(uri: styleUrl).read(); +}); diff --git a/mobile/lib/features/routing/presentation/screens/route_screen.dart b/mobile/lib/features/routing/presentation/screens/route_screen.dart index 39cfbd4..536918c 100644 --- a/mobile/lib/features/routing/presentation/screens/route_screen.dart +++ b/mobile/lib/features/routing/presentation/screens/route_screen.dart @@ -37,14 +37,10 @@ class _RouteScreenState extends ConsumerState { final notifier = ref.read(routingProvider.notifier); final mapState = ref.read(mapProvider); - // Set origin from current location if available. - if (mapState.currentLocation != null) { - notifier.setOrigin( - mapState.currentLocation!.latitude, - mapState.currentLocation!.longitude, - 'My Location', - ); - } + // Set origin from GPS if available, otherwise use the map center. + final origin = mapState.currentLocation ?? mapState.center; + final originName = mapState.currentLocation != null ? 'My Location' : 'Map center'; + notifier.setOrigin(origin.latitude, origin.longitude, originName); // Set destination from route params. if (widget.destinationLat != null && widget.destinationLon != null) { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 628a81d..536de63 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,12 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app/app.dart'; +import 'core/api/api_client.dart'; +import 'core/constants.dart'; +import 'core/database/app_database.dart'; -void main() { +void main() async { WidgetsFlutterBinding.ensureInitialized(); + + // Load the saved backend URL before building the widget tree so the API + // client is initialized with the correct URL from the first request. + final db = AppDatabase(); + final savedUrl = await db.getSetting(AppConstants.settingBackendUrl); + final backendUrl = savedUrl ?? AppConstants.defaultBackendUrl; + runApp( - const ProviderScope( - child: PrivacyMapsApp(), + ProviderScope( + overrides: [ + apiClientProvider.overrideWithValue(ApiClient(baseUrl: backendUrl)), + ], + child: const PrivacyMapsApp(), ), ); }