draft
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
target/
|
||||
.idea/
|
||||
.dart_tool
|
||||
*.iml
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
0
README.md
Normal file
3713
backend/Cargo.lock
generated
Normal file
24
backend/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "maps-backend"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
description = "Privacy-first maps API gateway"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4"
|
||||
actix-cors = "0.7"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "json", "chrono"] }
|
||||
bytes = "1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
redis = { version = "0.27", features = ["tokio-comp"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenvy = "0.15"
|
||||
geojson = "0.24"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
21
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Stage 1: Build
|
||||
FROM rust:1.94-bookworm AS builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
COPY src/ src/
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /usr/src/app/target/release/maps-backend /usr/local/bin/maps-backend
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["maps-backend"]
|
||||
73
backend/docker-compose.yml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
services:
|
||||
backend:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "8080"
|
||||
MARTIN_URL: "http://martin:3000"
|
||||
PHOTON_URL: "http://photon:2322"
|
||||
OSRM_DRIVING_URL: "http://osrm-driving:5000"
|
||||
OSRM_WALKING_URL: "http://osrm-walking:5000"
|
||||
OSRM_CYCLING_URL: "http://osrm-cycling:5000"
|
||||
DATABASE_URL: "postgres://maps:maps@postgres:5432/maps"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
OFFLINE_DATA_DIR: "/data/offline"
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
- martin
|
||||
- photon
|
||||
- osrm-driving
|
||||
- osrm-walking
|
||||
- osrm-cycling
|
||||
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: maps
|
||||
POSTGRES_PASSWORD: maps
|
||||
POSTGRES_DB: maps
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: redis-server --maxmemory 2gb --maxmemory-policy allkeys-lru
|
||||
|
||||
martin:
|
||||
image: ghcr.io/maplibre/martin
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgres://maps:maps@postgres:5432/maps"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
photon:
|
||||
image: komoot/photon:latest
|
||||
ports:
|
||||
- "2322:2322"
|
||||
|
||||
osrm-driving:
|
||||
image: osrm/osrm-backend:latest
|
||||
ports:
|
||||
- "5000:5000"
|
||||
|
||||
osrm-walking:
|
||||
image: osrm/osrm-backend:latest
|
||||
ports:
|
||||
- "5001:5000"
|
||||
|
||||
osrm-cycling:
|
||||
image: osrm/osrm-backend:latest
|
||||
ports:
|
||||
- "5002:5000"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
55
backend/src/config.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/// Environment-based configuration for all upstream services and the gateway itself.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub martin_url: String,
|
||||
pub photon_url: String,
|
||||
pub osrm_driving_url: String,
|
||||
pub osrm_walking_url: String,
|
||||
pub osrm_cycling_url: String,
|
||||
pub database_url: String,
|
||||
pub redis_url: String,
|
||||
pub offline_data_dir: String,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Load configuration from environment variables with sensible defaults
|
||||
/// for docker-compose deployment.
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()),
|
||||
port: std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(8080),
|
||||
martin_url: std::env::var("MARTIN_URL")
|
||||
.unwrap_or_else(|_| "http://martin:3000".into()),
|
||||
photon_url: std::env::var("PHOTON_URL")
|
||||
.unwrap_or_else(|_| "http://photon:2322".into()),
|
||||
osrm_driving_url: std::env::var("OSRM_DRIVING_URL")
|
||||
.unwrap_or_else(|_| "http://osrm-driving:5000".into()),
|
||||
osrm_walking_url: std::env::var("OSRM_WALKING_URL")
|
||||
.unwrap_or_else(|_| "http://osrm-walking:5000".into()),
|
||||
osrm_cycling_url: std::env::var("OSRM_CYCLING_URL")
|
||||
.unwrap_or_else(|_| "http://osrm-cycling:5000".into()),
|
||||
database_url: std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://maps:maps@postgis:5432/maps".into()),
|
||||
redis_url: std::env::var("REDIS_URL")
|
||||
.unwrap_or_else(|_| "redis://redis:6379".into()),
|
||||
offline_data_dir: std::env::var("OFFLINE_DATA_DIR")
|
||||
.unwrap_or_else(|_| "/data/offline".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the OSRM base URL for a given routing profile.
|
||||
pub fn osrm_url_for_profile(&self, profile: &str) -> Option<&str> {
|
||||
match profile {
|
||||
"driving" => Some(&self.osrm_driving_url),
|
||||
"walking" => Some(&self.osrm_walking_url),
|
||||
"cycling" => Some(&self.osrm_cycling_url),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
113
backend/src/errors.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
use actix_web::{HttpResponse, ResponseError};
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
|
||||
/// Standard JSON error body matching the API contract.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorBody {
|
||||
pub error: ErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorDetail {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Application-level error type that maps to HTTP status codes and
|
||||
/// the standard error JSON format defined in API.md.
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
MissingParameter(String),
|
||||
InvalidParameter(String),
|
||||
InvalidBbox(String),
|
||||
InvalidCoordinates(String),
|
||||
NotFound(String),
|
||||
RateLimited,
|
||||
UpstreamError(String),
|
||||
ServiceUnavailable(String),
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
AppError::MissingParameter(msg) => write!(f, "Missing parameter: {msg}"),
|
||||
AppError::InvalidParameter(msg) => write!(f, "Invalid parameter: {msg}"),
|
||||
AppError::InvalidBbox(msg) => write!(f, "Invalid bbox: {msg}"),
|
||||
AppError::InvalidCoordinates(msg) => write!(f, "Invalid coordinates: {msg}"),
|
||||
AppError::NotFound(msg) => write!(f, "Not found: {msg}"),
|
||||
AppError::RateLimited => write!(f, "Rate limited"),
|
||||
AppError::UpstreamError(msg) => write!(f, "Upstream error: {msg}"),
|
||||
AppError::ServiceUnavailable(msg) => write!(f, "Service unavailable: {msg}"),
|
||||
AppError::InternalError(msg) => write!(f, "Internal error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for AppError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let (status, code) = match self {
|
||||
AppError::MissingParameter(_) => {
|
||||
(actix_web::http::StatusCode::BAD_REQUEST, "MISSING_PARAMETER")
|
||||
}
|
||||
AppError::InvalidParameter(_) => {
|
||||
(actix_web::http::StatusCode::BAD_REQUEST, "INVALID_PARAMETER")
|
||||
}
|
||||
AppError::InvalidBbox(_) => {
|
||||
(actix_web::http::StatusCode::BAD_REQUEST, "INVALID_BBOX")
|
||||
}
|
||||
AppError::InvalidCoordinates(_) => {
|
||||
(actix_web::http::StatusCode::BAD_REQUEST, "INVALID_COORDINATES")
|
||||
}
|
||||
AppError::NotFound(_) => (actix_web::http::StatusCode::NOT_FOUND, "NOT_FOUND"),
|
||||
AppError::RateLimited => {
|
||||
(actix_web::http::StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED")
|
||||
}
|
||||
AppError::UpstreamError(_) => {
|
||||
(actix_web::http::StatusCode::BAD_GATEWAY, "UPSTREAM_ERROR")
|
||||
}
|
||||
AppError::ServiceUnavailable(_) => (
|
||||
actix_web::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"SERVICE_UNAVAILABLE",
|
||||
),
|
||||
AppError::InternalError(_) => (
|
||||
actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
),
|
||||
};
|
||||
|
||||
HttpResponse::build(status).json(ErrorBody {
|
||||
error: ErrorDetail {
|
||||
code: code.to_string(),
|
||||
message: self.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
tracing::error!(error = %err, "Database error");
|
||||
AppError::ServiceUnavailable("Database is unreachable".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for AppError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
tracing::error!(error = %err, "HTTP client error");
|
||||
if err.is_connect() || err.is_timeout() {
|
||||
AppError::ServiceUnavailable("Upstream service is unreachable".into())
|
||||
} else {
|
||||
AppError::UpstreamError("Upstream service returned an error".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<redis::RedisError> for AppError {
|
||||
fn from(err: redis::RedisError) -> Self {
|
||||
tracing::warn!(error = %err, "Redis error (non-fatal)");
|
||||
// Redis errors are non-fatal — we skip cache and proceed
|
||||
AppError::InternalError("Cache error".into())
|
||||
}
|
||||
}
|
||||
83
backend/src/main.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
mod config;
|
||||
mod errors;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod services;
|
||||
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{web, App, HttpServer, middleware};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::services::cache::CacheService;
|
||||
use crate::services::martin::MartinService;
|
||||
use crate::services::osrm::OsrmService;
|
||||
use crate::services::photon::PhotonService;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// Load .env file if present (ignore errors when missing).
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
// Set up tracing subscriber with env-filter (defaults to INFO).
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
tracing::info!("Starting maps-backend on {}:{}", config.host, config.port);
|
||||
|
||||
// Database connection pool.
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(20)
|
||||
.connect(&config.database_url)
|
||||
.await
|
||||
.expect("Failed to connect to PostgreSQL");
|
||||
|
||||
tracing::info!("Connected to PostgreSQL");
|
||||
|
||||
// Redis cache service.
|
||||
let cache = CacheService::new(&config.redis_url).expect("Failed to connect to Redis");
|
||||
tracing::info!("Redis client initialized");
|
||||
|
||||
// Upstream service clients.
|
||||
let martin = MartinService::new(&config);
|
||||
let photon = PhotonService::new(&config);
|
||||
let osrm = OsrmService::new(&config);
|
||||
|
||||
// Server start time for the health endpoint uptime counter.
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let bind_addr = format!("{}:{}", config.host, config.port);
|
||||
|
||||
// Wrap shared state in web::Data (Arc) so cloning inside the closure is cheap.
|
||||
let pool = web::Data::new(pool);
|
||||
let cache = web::Data::new(cache);
|
||||
let martin = web::Data::new(martin);
|
||||
let photon = web::Data::new(photon);
|
||||
let osrm = web::Data::new(osrm);
|
||||
let config = web::Data::new(config);
|
||||
let start_time = web::Data::new(start_time);
|
||||
|
||||
HttpServer::new(move || {
|
||||
let cors = Cors::permissive();
|
||||
|
||||
App::new()
|
||||
.wrap(cors)
|
||||
.wrap(middleware::Logger::default())
|
||||
.app_data(pool.clone())
|
||||
.app_data(cache.clone())
|
||||
.app_data(martin.clone())
|
||||
.app_data(photon.clone())
|
||||
.app_data(osrm.clone())
|
||||
.app_data(config.clone())
|
||||
.app_data(start_time.clone())
|
||||
.configure(routes::configure)
|
||||
})
|
||||
.bind(&bind_addr)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
46
backend/src/models/health.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Per-service health status.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ServiceHealth {
|
||||
pub status: String,
|
||||
pub latency_ms: u64,
|
||||
}
|
||||
|
||||
/// Full health check response matching API.md section 6.1.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub uptime_seconds: u64,
|
||||
pub services: HashMap<String, ServiceHealth>,
|
||||
}
|
||||
|
||||
impl HealthResponse {
|
||||
/// Derive the top-level status from per-service statuses.
|
||||
/// - "ok" if all services are ok.
|
||||
/// - "degraded" if some are down but postgres and martin are ok.
|
||||
/// - "down" if postgres or martin are down.
|
||||
pub fn compute_status(services: &HashMap<String, ServiceHealth>) -> String {
|
||||
let all_ok = services.values().all(|s| s.status == "ok");
|
||||
if all_ok {
|
||||
return "ok".into();
|
||||
}
|
||||
|
||||
let critical_down = ["postgres", "martin"]
|
||||
.iter()
|
||||
.any(|name| {
|
||||
services
|
||||
.get(*name)
|
||||
.map(|s| s.status == "down")
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
if critical_down {
|
||||
"down".into()
|
||||
} else {
|
||||
"degraded".into()
|
||||
}
|
||||
}
|
||||
}
|
||||
5
backend/src/models/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod health;
|
||||
pub mod offline;
|
||||
pub mod poi;
|
||||
pub mod route;
|
||||
pub mod search;
|
||||
82
backend/src/models/offline.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
use serde::Serialize;
|
||||
|
||||
/// Row from the `offline_regions` PostGIS table.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct OfflineRegionRow {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// bbox as [minLon, minLat, maxLon, maxLat] extracted via ST_Extent / ST_Envelope
|
||||
pub min_lon: f64,
|
||||
pub min_lat: f64,
|
||||
pub max_lon: f64,
|
||||
pub max_lat: f64,
|
||||
pub tiles_size_bytes: i64,
|
||||
pub routing_size_bytes: i64,
|
||||
pub pois_size_bytes: i64,
|
||||
pub last_updated: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Component breakdown in the JSON response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RegionComponents {
|
||||
pub tiles_mb: i64,
|
||||
pub routing_driving_mb: i64,
|
||||
pub routing_walking_mb: i64,
|
||||
pub routing_cycling_mb: i64,
|
||||
pub pois_mb: i64,
|
||||
}
|
||||
|
||||
/// Single region in the JSON response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OfflineRegion {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub bbox: [f64; 4],
|
||||
pub size_mb: i64,
|
||||
pub last_updated: String,
|
||||
pub components: RegionComponents,
|
||||
}
|
||||
|
||||
/// Top-level response for GET /api/offline/regions.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OfflineRegionsResponse {
|
||||
pub regions: Vec<OfflineRegion>,
|
||||
}
|
||||
|
||||
/// Valid component names for download.
|
||||
pub const VALID_COMPONENTS: &[&str] = &[
|
||||
"tiles",
|
||||
"routing-driving",
|
||||
"routing-walking",
|
||||
"routing-cycling",
|
||||
"pois",
|
||||
];
|
||||
|
||||
impl OfflineRegionRow {
|
||||
pub fn into_response(self) -> OfflineRegion {
|
||||
let total_bytes = self.tiles_size_bytes + self.routing_size_bytes + self.pois_size_bytes;
|
||||
let total_mb = total_bytes / (1024 * 1024);
|
||||
let tiles_mb = self.tiles_size_bytes / (1024 * 1024);
|
||||
let pois_mb = self.pois_size_bytes / (1024 * 1024);
|
||||
// Split routing evenly across three profiles as an approximation.
|
||||
let routing_per_profile_mb = self.routing_size_bytes / (3 * 1024 * 1024);
|
||||
|
||||
OfflineRegion {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
bbox: [self.min_lon, self.min_lat, self.max_lon, self.max_lat],
|
||||
size_mb: total_mb,
|
||||
last_updated: self.last_updated.to_rfc3339(),
|
||||
components: RegionComponents {
|
||||
tiles_mb,
|
||||
routing_driving_mb: routing_per_profile_mb,
|
||||
routing_walking_mb: routing_per_profile_mb,
|
||||
routing_cycling_mb: routing_per_profile_mb,
|
||||
pois_mb,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
140
backend/src/models/poi.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Row returned from PostGIS `pois` table.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct PoiRow {
|
||||
pub osm_id: i64,
|
||||
pub osm_type: String,
|
||||
pub name: String,
|
||||
pub category: String,
|
||||
pub geometry: serde_json::Value, // ST_AsGeoJSON result
|
||||
pub address: Option<serde_json::Value>,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub opening_hours: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub website: Option<String>,
|
||||
pub wheelchair: Option<String>,
|
||||
}
|
||||
|
||||
/// Count row for total matching POIs.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct CountRow {
|
||||
pub count: Option<i64>,
|
||||
}
|
||||
|
||||
/// Address sub-object in the API response.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PoiAddress {
|
||||
pub street: Option<String>,
|
||||
pub housenumber: Option<String>,
|
||||
pub postcode: Option<String>,
|
||||
pub city: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed opening hours for the API response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OpeningHoursParsed {
|
||||
pub is_open: bool,
|
||||
pub today: Option<String>,
|
||||
pub next_change: Option<String>,
|
||||
}
|
||||
|
||||
/// Properties block of a POI GeoJSON Feature.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PoiProperties {
|
||||
pub osm_id: i64,
|
||||
pub osm_type: String,
|
||||
pub name: String,
|
||||
pub category: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub address: Option<PoiAddress>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub opening_hours: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub opening_hours_parsed: Option<OpeningHoursParsed>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub website: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wheelchair: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// A single GeoJSON Feature for a POI.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PoiFeature {
|
||||
pub r#type: String,
|
||||
pub geometry: serde_json::Value,
|
||||
pub properties: PoiProperties,
|
||||
}
|
||||
|
||||
/// GeoJSON FeatureCollection response with pagination metadata.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PoiFeatureCollection {
|
||||
pub r#type: String,
|
||||
pub features: Vec<PoiFeature>,
|
||||
pub metadata: PaginationMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PaginationMetadata {
|
||||
pub total: i64,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
/// Converts a database row into a GeoJSON Feature.
|
||||
impl PoiRow {
|
||||
pub fn into_feature(self) -> PoiFeature {
|
||||
let address: Option<PoiAddress> = self
|
||||
.address
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
|
||||
// Basic opening hours parsing — in production this would use a
|
||||
// proper OSM opening_hours parser. For the MVP we return the raw
|
||||
// string and a simple placeholder parsed object.
|
||||
let opening_hours_parsed = self.opening_hours.as_ref().map(|_oh| OpeningHoursParsed {
|
||||
is_open: true, // Placeholder — real parser needed
|
||||
today: None,
|
||||
next_change: None,
|
||||
});
|
||||
|
||||
PoiFeature {
|
||||
r#type: "Feature".into(),
|
||||
geometry: self.geometry,
|
||||
properties: PoiProperties {
|
||||
osm_id: self.osm_id,
|
||||
osm_type: self.osm_type,
|
||||
name: self.name,
|
||||
category: self.category,
|
||||
address,
|
||||
opening_hours: self.opening_hours,
|
||||
opening_hours_parsed,
|
||||
phone: self.phone,
|
||||
website: self.website,
|
||||
wheelchair: self.wheelchair,
|
||||
tags: self.tags,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Valid POI categories as defined in the data model.
|
||||
pub const VALID_CATEGORIES: &[&str] = &[
|
||||
"restaurant",
|
||||
"cafe",
|
||||
"shop",
|
||||
"supermarket",
|
||||
"pharmacy",
|
||||
"hospital",
|
||||
"fuel",
|
||||
"parking",
|
||||
"atm",
|
||||
"public_transport",
|
||||
"hotel",
|
||||
"tourist_attraction",
|
||||
"park",
|
||||
];
|
||||
33
backend/src/models/route.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/// Route models are pass-through from OSRM. The gateway proxies the JSON
|
||||
/// response as-is (after validation), so we only need minimal types here
|
||||
/// for any transformation logic.
|
||||
///
|
||||
/// The full OSRM response schema is defined in API.md section 3.1.
|
||||
|
||||
/// Valid routing profiles.
|
||||
pub const VALID_PROFILES: &[&str] = &["driving", "walking", "cycling"];
|
||||
|
||||
/// Valid geometry formats.
|
||||
pub const VALID_GEOMETRIES: &[&str] = &["polyline", "polyline6", "geojson"];
|
||||
|
||||
/// Valid overview options.
|
||||
pub const VALID_OVERVIEWS: &[&str] = &["full", "simplified", "false"];
|
||||
|
||||
/// OSRM response code to our error mapping.
|
||||
pub fn map_osrm_code(code: &str) -> OsrmCodeMapping {
|
||||
match code {
|
||||
"Ok" => OsrmCodeMapping::Ok,
|
||||
"NoRoute" => OsrmCodeMapping::NoRoute,
|
||||
"NoSegment" => OsrmCodeMapping::NoSegment,
|
||||
"TooBig" => OsrmCodeMapping::TooBig,
|
||||
_ => OsrmCodeMapping::Other(code.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub enum OsrmCodeMapping {
|
||||
Ok,
|
||||
NoRoute,
|
||||
NoSegment,
|
||||
TooBig,
|
||||
Other(String),
|
||||
}
|
||||
16
backend/src/models/search.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/// Search models. The gateway proxies Photon's GeoJSON response as-is,
|
||||
/// so we do not need to deserialize the full response. The types here
|
||||
/// are used only for cache key construction and parameter validation.
|
||||
|
||||
/// Maximum allowed query length.
|
||||
pub const MAX_QUERY_LENGTH: usize = 500;
|
||||
|
||||
/// Allowed limit range for forward search.
|
||||
pub const SEARCH_LIMIT_MIN: u32 = 1;
|
||||
pub const SEARCH_LIMIT_MAX: u32 = 20;
|
||||
pub const SEARCH_LIMIT_DEFAULT: u32 = 10;
|
||||
|
||||
/// Allowed limit range for reverse geocoding.
|
||||
pub const REVERSE_LIMIT_MIN: u32 = 1;
|
||||
pub const REVERSE_LIMIT_MAX: u32 = 5;
|
||||
pub const REVERSE_LIMIT_DEFAULT: u32 = 1;
|
||||
111
backend/src/routes/health.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use actix_web::{web, HttpResponse};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::errors::AppError;
|
||||
use crate::models::health::{HealthResponse, ServiceHealth};
|
||||
use crate::services::cache::CacheService;
|
||||
use crate::services::martin::MartinService;
|
||||
use crate::services::osrm::OsrmService;
|
||||
use crate::services::photon::PhotonService;
|
||||
|
||||
/// GET /api/health
|
||||
///
|
||||
/// Checks connectivity to all upstream services and returns aggregated status.
|
||||
/// Always returns HTTP 200; the client should inspect the `status` field.
|
||||
pub async fn health_check(
|
||||
martin: web::Data<MartinService>,
|
||||
photon: web::Data<PhotonService>,
|
||||
osrm: web::Data<OsrmService>,
|
||||
pool: web::Data<PgPool>,
|
||||
cache: web::Data<CacheService>,
|
||||
start_time: web::Data<std::time::Instant>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let mut services = HashMap::new();
|
||||
|
||||
// Check Martin
|
||||
let martin_health = check_service("martin", martin.health_check()).await;
|
||||
services.insert("martin".to_string(), martin_health);
|
||||
|
||||
// Check Photon
|
||||
let photon_health = check_service("photon", photon.health_check()).await;
|
||||
services.insert("photon".to_string(), photon_health);
|
||||
|
||||
// Check OSRM instances
|
||||
let osrm_driving_health = check_service("osrm_driving", osrm.health_check("driving")).await;
|
||||
services.insert("osrm_driving".to_string(), osrm_driving_health);
|
||||
|
||||
let osrm_walking_health = check_service("osrm_walking", osrm.health_check("walking")).await;
|
||||
services.insert("osrm_walking".to_string(), osrm_walking_health);
|
||||
|
||||
let osrm_cycling_health = check_service("osrm_cycling", osrm.health_check("cycling")).await;
|
||||
services.insert("osrm_cycling".to_string(), osrm_cycling_health);
|
||||
|
||||
// Check PostgreSQL
|
||||
let postgres_health = check_postgres(&pool).await;
|
||||
services.insert("postgres".to_string(), postgres_health);
|
||||
|
||||
// Check Redis
|
||||
let redis_health = check_service("redis", cache.ping()).await;
|
||||
services.insert("redis".to_string(), redis_health);
|
||||
|
||||
let status = HealthResponse::compute_status(&services);
|
||||
let uptime = start_time.elapsed().as_secs();
|
||||
|
||||
let response = HealthResponse {
|
||||
status,
|
||||
version: "1.0.0".to_string(),
|
||||
uptime_seconds: uptime,
|
||||
services,
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "no-store"))
|
||||
.json(response))
|
||||
}
|
||||
|
||||
/// Check a service that returns Result<latency_ms, ()>.
|
||||
async fn check_service(
|
||||
_name: &str,
|
||||
future: impl std::future::Future<Output = Result<u64, ()>>,
|
||||
) -> ServiceHealth {
|
||||
match future.await {
|
||||
Ok(latency_ms) => ServiceHealth {
|
||||
status: if latency_ms > 2000 {
|
||||
"degraded".to_string()
|
||||
} else {
|
||||
"ok".to_string()
|
||||
},
|
||||
latency_ms,
|
||||
},
|
||||
Err(()) => ServiceHealth {
|
||||
status: "down".to_string(),
|
||||
latency_ms: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Check PostgreSQL connectivity.
|
||||
async fn check_postgres(pool: &PgPool) -> ServiceHealth {
|
||||
let start = std::time::Instant::now();
|
||||
let result = sqlx::query_scalar::<_, i32>("SELECT 1")
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
let latency_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
match result {
|
||||
Ok(_) => ServiceHealth {
|
||||
status: if latency_ms > 2000 {
|
||||
"degraded".to_string()
|
||||
} else {
|
||||
"ok".to_string()
|
||||
},
|
||||
latency_ms,
|
||||
},
|
||||
Err(_) => ServiceHealth {
|
||||
status: "down".to_string(),
|
||||
latency_ms: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
46
backend/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
pub mod health;
|
||||
pub mod offline;
|
||||
pub mod pois;
|
||||
pub mod routing;
|
||||
pub mod search;
|
||||
pub mod tiles;
|
||||
|
||||
use actix_web::web;
|
||||
|
||||
/// Mount all routes onto the Actix-web application.
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("")
|
||||
// Tiles
|
||||
.route(
|
||||
"/tiles/{layer}/{z}/{x}/{y}.pbf",
|
||||
web::get().to(tiles::get_tile),
|
||||
)
|
||||
.route("/tiles/style.json", web::get().to(tiles::get_style))
|
||||
// Search
|
||||
.route("/api/search", web::get().to(search::search))
|
||||
.route("/api/reverse", web::get().to(search::reverse))
|
||||
// Routing
|
||||
.route(
|
||||
"/api/route/{profile}/{coordinates}",
|
||||
web::get().to(routing::route),
|
||||
)
|
||||
// POIs
|
||||
.route("/api/pois", web::get().to(pois::list_pois))
|
||||
.route(
|
||||
"/api/pois/{osm_type}/{osm_id}",
|
||||
web::get().to(pois::get_poi),
|
||||
)
|
||||
// Offline
|
||||
.route(
|
||||
"/api/offline/regions",
|
||||
web::get().to(offline::list_regions),
|
||||
)
|
||||
.route(
|
||||
"/api/offline/regions/{region_id}/{component}",
|
||||
web::get().to(offline::download_component),
|
||||
)
|
||||
// Health
|
||||
.route("/api/health", web::get().to(health::health_check)),
|
||||
);
|
||||
}
|
||||
194
backend/src/routes/offline.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
use actix_web::{web, HttpResponse};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::errors::AppError;
|
||||
use crate::models::offline::*;
|
||||
|
||||
/// GET /api/offline/regions
|
||||
///
|
||||
/// List all available offline regions from the PostGIS `offline_regions` table.
|
||||
pub async fn list_regions(
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let rows = sqlx::query_as::<_, OfflineRegionRow>(
|
||||
"SELECT id, name, description, \
|
||||
ST_XMin(bbox) as min_lon, ST_YMin(bbox) as min_lat, \
|
||||
ST_XMax(bbox) as max_lon, ST_YMax(bbox) as max_lat, \
|
||||
tiles_size_bytes, routing_size_bytes, pois_size_bytes, \
|
||||
last_updated \
|
||||
FROM offline_regions \
|
||||
ORDER BY name",
|
||||
)
|
||||
.fetch_all(pool.get_ref())
|
||||
.await?;
|
||||
|
||||
let regions: Vec<OfflineRegion> = rows.into_iter().map(|r| r.into_response()).collect();
|
||||
|
||||
let response = OfflineRegionsResponse { regions };
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "public, max-age=3600"))
|
||||
.json(response))
|
||||
}
|
||||
|
||||
/// Path parameters for downloading a region component.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct DownloadPath {
|
||||
region_id: String,
|
||||
component: String,
|
||||
}
|
||||
|
||||
/// GET /api/offline/regions/{region_id}/{component}
|
||||
///
|
||||
/// Serve an offline data package file from disk. Supports Range requests
|
||||
/// for pause/resume downloads.
|
||||
pub async fn download_component(
|
||||
path: web::Path<DownloadPath>,
|
||||
config: web::Data<AppConfig>,
|
||||
pool: web::Data<PgPool>,
|
||||
req: actix_web::HttpRequest,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let DownloadPath {
|
||||
region_id,
|
||||
component,
|
||||
} = path.into_inner();
|
||||
|
||||
// Validate component
|
||||
if !VALID_COMPONENTS.contains(&component.as_str()) {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"Invalid component: {component}. Must be one of: {}",
|
||||
VALID_COMPONENTS.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
// Verify region exists
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM offline_regions WHERE id = $1)",
|
||||
)
|
||||
.bind(®ion_id)
|
||||
.fetch_one(pool.get_ref())
|
||||
.await?;
|
||||
|
||||
if !exists {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Region not found: {region_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Determine file path and name
|
||||
let (filename, extension) = match component.as_str() {
|
||||
"tiles" => (format!("{region_id}-tiles"), "mbtiles"),
|
||||
"routing-driving" => (format!("{region_id}-routing-driving"), "tar"),
|
||||
"routing-walking" => (format!("{region_id}-routing-walking"), "tar"),
|
||||
"routing-cycling" => (format!("{region_id}-routing-cycling"), "tar"),
|
||||
"pois" => (format!("{region_id}-pois"), "db"),
|
||||
_ => unreachable!(), // Already validated above
|
||||
};
|
||||
|
||||
let file_path = format!(
|
||||
"{}/{}/{}.{}",
|
||||
config.offline_data_dir, region_id, filename, extension
|
||||
);
|
||||
|
||||
// Read file metadata
|
||||
let metadata = tokio::fs::metadata(&file_path).await.map_err(|e| {
|
||||
tracing::error!(error = %e, path = %file_path, "Failed to read offline data file");
|
||||
AppError::NotFound(format!(
|
||||
"Component {component} not available for region {region_id}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let file_size = metadata.len();
|
||||
let download_name = format!("{filename}.{extension}");
|
||||
|
||||
// Check for Range header
|
||||
if let Some(range_header) = req.headers().get("Range") {
|
||||
let range_str = range_header.to_str().unwrap_or("");
|
||||
if let Some(range) = parse_range(range_str, file_size) {
|
||||
let (start, end) = range;
|
||||
let content_length = end - start + 1;
|
||||
|
||||
let data = read_file_range(&file_path, start, content_length).await?;
|
||||
|
||||
return Ok(HttpResponse::PartialContent()
|
||||
.content_type("application/octet-stream")
|
||||
.insert_header(("Content-Range", format!("bytes {start}-{end}/{file_size}")))
|
||||
.insert_header(("Content-Length", content_length.to_string()))
|
||||
.insert_header(("Accept-Ranges", "bytes"))
|
||||
.body(data));
|
||||
} else {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"Range not satisfiable".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Full download
|
||||
let data = tokio::fs::read(&file_path).await.map_err(|e| {
|
||||
tracing::error!(error = %e, path = %file_path, "Failed to read offline data file");
|
||||
AppError::InternalError("Failed to read package file".into())
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/octet-stream")
|
||||
.insert_header(("Content-Length", file_size.to_string()))
|
||||
.insert_header(("Accept-Ranges", "bytes"))
|
||||
.insert_header((
|
||||
"Content-Disposition",
|
||||
format!("attachment; filename=\"{download_name}\""),
|
||||
))
|
||||
.insert_header(("Cache-Control", "public, max-age=86400"))
|
||||
.insert_header((
|
||||
"ETag",
|
||||
format!("\"region-{region_id}-{component}\""),
|
||||
))
|
||||
.body(data))
|
||||
}
|
||||
|
||||
/// Parse a byte range header like "bytes=1000-2000" or "bytes=1000-".
|
||||
/// Returns (start, end) inclusive.
|
||||
fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
|
||||
let header = header.strip_prefix("bytes=")?;
|
||||
let parts: Vec<&str> = header.splitn(2, '-').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start: u64 = parts[0].parse().ok()?;
|
||||
let end: u64 = if parts[1].is_empty() {
|
||||
file_size - 1
|
||||
} else {
|
||||
parts[1].parse().ok()?
|
||||
};
|
||||
|
||||
if start > end || end >= file_size {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Read a byte range from a file.
|
||||
async fn read_file_range(path: &str, offset: u64, length: u64) -> Result<Vec<u8>, AppError> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
let mut file = tokio::fs::File::open(path).await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to open file for range read");
|
||||
AppError::InternalError("Failed to read package file".into())
|
||||
})?;
|
||||
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to seek file");
|
||||
AppError::InternalError("Failed to read package file".into())
|
||||
})?;
|
||||
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
file.read_exact(&mut buf).await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to read file range");
|
||||
AppError::InternalError("Failed to read package file".into())
|
||||
})?;
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
249
backend/src/routes/pois.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
use actix_web::{web, HttpResponse};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::errors::AppError;
|
||||
use crate::models::poi::*;
|
||||
|
||||
/// Query parameters for listing POIs.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PoisQuery {
|
||||
bbox: Option<String>,
|
||||
category: Option<String>,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// Path parameters for a single POI.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PoiPath {
|
||||
osm_type: String,
|
||||
osm_id: i64,
|
||||
}
|
||||
|
||||
/// GET /api/pois
|
||||
///
|
||||
/// Query PostGIS for POIs within a bounding box, optionally filtered by category.
|
||||
pub async fn list_pois(
|
||||
query: web::Query<PoisQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let bbox_str = query
|
||||
.bbox
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::MissingParameter("bbox parameter is required".into()))?;
|
||||
|
||||
let (min_lon, min_lat, max_lon, max_lat) = parse_bbox(bbox_str)?;
|
||||
|
||||
// Validate area: max 0.25 square degrees
|
||||
let area = (max_lon - min_lon) * (max_lat - min_lat);
|
||||
if area > 0.25 {
|
||||
return Err(AppError::InvalidBbox(
|
||||
"Bounding box area exceeds maximum of 0.25 square degrees".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
if !(1..=500).contains(&limit) {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"limit must be between 1 and 500".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
if offset < 0 {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"offset must be >= 0".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate and parse categories
|
||||
let categories: Option<Vec<String>> = if let Some(ref cat_str) = query.category {
|
||||
let cats: Vec<String> = cat_str.split(',').map(|s| s.trim().to_string()).collect();
|
||||
for cat in &cats {
|
||||
if !VALID_CATEGORIES.contains(&cat.as_str()) {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"Unknown category: {cat}. Valid categories: {}",
|
||||
VALID_CATEGORIES.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
Some(cats)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Build and execute the count query
|
||||
let total = if let Some(ref cats) = categories {
|
||||
sqlx::query_as::<_, CountRow>(
|
||||
"SELECT COUNT(*) as count FROM pois \
|
||||
WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326) \
|
||||
AND category = ANY($5)",
|
||||
)
|
||||
.bind(min_lon)
|
||||
.bind(min_lat)
|
||||
.bind(max_lon)
|
||||
.bind(max_lat)
|
||||
.bind(cats)
|
||||
.fetch_one(pool.get_ref())
|
||||
.await?
|
||||
.count
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
sqlx::query_as::<_, CountRow>(
|
||||
"SELECT COUNT(*) as count FROM pois \
|
||||
WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326)",
|
||||
)
|
||||
.bind(min_lon)
|
||||
.bind(min_lat)
|
||||
.bind(max_lon)
|
||||
.bind(max_lat)
|
||||
.fetch_one(pool.get_ref())
|
||||
.await?
|
||||
.count
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
// Build and execute the data query
|
||||
let rows: Vec<PoiRow> = if let Some(ref cats) = categories {
|
||||
sqlx::query_as::<_, PoiRow>(
|
||||
"SELECT osm_id, osm_type, name, category, \
|
||||
ST_AsGeoJSON(geometry)::json AS geometry, \
|
||||
address, tags, opening_hours, phone, website, wheelchair \
|
||||
FROM pois \
|
||||
WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326) \
|
||||
AND category = ANY($5) \
|
||||
ORDER BY name \
|
||||
LIMIT $6 OFFSET $7",
|
||||
)
|
||||
.bind(min_lon)
|
||||
.bind(min_lat)
|
||||
.bind(max_lon)
|
||||
.bind(max_lat)
|
||||
.bind(cats)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool.get_ref())
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, PoiRow>(
|
||||
"SELECT osm_id, osm_type, name, category, \
|
||||
ST_AsGeoJSON(geometry)::json AS geometry, \
|
||||
address, tags, opening_hours, phone, website, wheelchair \
|
||||
FROM pois \
|
||||
WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326) \
|
||||
ORDER BY name \
|
||||
LIMIT $5 OFFSET $6",
|
||||
)
|
||||
.bind(min_lon)
|
||||
.bind(min_lat)
|
||||
.bind(max_lon)
|
||||
.bind(max_lat)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool.get_ref())
|
||||
.await?
|
||||
};
|
||||
|
||||
let features: Vec<PoiFeature> = rows.into_iter().map(|r| r.into_feature()).collect();
|
||||
|
||||
let response = PoiFeatureCollection {
|
||||
r#type: "FeatureCollection".into(),
|
||||
features,
|
||||
metadata: PaginationMetadata {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "public, max-age=300"))
|
||||
.json(response))
|
||||
}
|
||||
|
||||
/// GET /api/pois/{osm_type}/{osm_id}
|
||||
///
|
||||
/// Fetch a single POI from PostGIS by OSM type and ID.
|
||||
pub async fn get_poi(
|
||||
path: web::Path<PoiPath>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let PoiPath { osm_type, osm_id } = path.into_inner();
|
||||
|
||||
// Validate osm_type
|
||||
if !["N", "W", "R"].contains(&osm_type.as_str()) {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"osm_type must be one of: N, W, R".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if osm_id <= 0 {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"osm_id must be a positive integer".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, PoiRow>(
|
||||
"SELECT osm_id, osm_type, name, category, \
|
||||
ST_AsGeoJSON(geometry)::json AS geometry, \
|
||||
address, tags, opening_hours, phone, website, wheelchair \
|
||||
FROM pois \
|
||||
WHERE osm_type = $1 AND osm_id = $2",
|
||||
)
|
||||
.bind(&osm_type)
|
||||
.bind(osm_id)
|
||||
.fetch_optional(pool.get_ref())
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let feature = r.into_feature();
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "public, max-age=3600"))
|
||||
.json(feature))
|
||||
}
|
||||
None => Err(AppError::NotFound(format!(
|
||||
"No POI found with type {osm_type} and id {osm_id}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a bounding box string "minLon,minLat,maxLon,maxLat".
|
||||
fn parse_bbox(bbox: &str) -> Result<(f64, f64, f64, f64), AppError> {
|
||||
let parts: Vec<f64> = bbox
|
||||
.split(',')
|
||||
.map(|s| {
|
||||
s.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(|_| AppError::InvalidBbox("bbox contains non-numeric values".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
if parts.len() != 4 {
|
||||
return Err(AppError::InvalidBbox(
|
||||
"bbox must have exactly 4 comma-separated values: minLon,minLat,maxLon,maxLat".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let (min_lon, min_lat, max_lon, max_lat) = (parts[0], parts[1], parts[2], parts[3]);
|
||||
|
||||
if !(-180.0..=180.0).contains(&min_lon)
|
||||
|| !(-180.0..=180.0).contains(&max_lon)
|
||||
|| !(-90.0..=90.0).contains(&min_lat)
|
||||
|| !(-90.0..=90.0).contains(&max_lat)
|
||||
{
|
||||
return Err(AppError::InvalidBbox(
|
||||
"bbox coordinates out of valid range".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if min_lon >= max_lon || min_lat >= max_lat {
|
||||
return Err(AppError::InvalidBbox(
|
||||
"bbox min values must be less than max values".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((min_lon, min_lat, max_lon, max_lat))
|
||||
}
|
||||
182
backend/src/routes/routing.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
use actix_web::{web, HttpResponse};
|
||||
|
||||
use crate::errors::AppError;
|
||||
use crate::models::route::*;
|
||||
use crate::services::cache::CacheService;
|
||||
use crate::services::osrm::OsrmService;
|
||||
|
||||
/// Path parameters for route requests.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct RoutePath {
|
||||
profile: String,
|
||||
coordinates: String,
|
||||
}
|
||||
|
||||
/// Query parameters for route requests.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct RouteQuery {
|
||||
alternatives: Option<u32>,
|
||||
steps: Option<bool>,
|
||||
geometries: Option<String>,
|
||||
overview: Option<String>,
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/route/{profile}/{coordinates}
|
||||
///
|
||||
/// Proxies to the appropriate OSRM instance with Redis caching (TTL 30m).
|
||||
pub async fn route(
|
||||
path: web::Path<RoutePath>,
|
||||
query: web::Query<RouteQuery>,
|
||||
osrm: web::Data<OsrmService>,
|
||||
cache: web::Data<CacheService>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let RoutePath {
|
||||
profile,
|
||||
coordinates,
|
||||
} = path.into_inner();
|
||||
|
||||
// Validate profile
|
||||
if !VALID_PROFILES.contains(&profile.as_str()) {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"Invalid profile: {profile}. Must be one of: {}",
|
||||
VALID_PROFILES.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate coordinates
|
||||
validate_coordinates(&coordinates)?;
|
||||
|
||||
let alternatives = query.alternatives.unwrap_or(0);
|
||||
if alternatives > 3 {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"alternatives must be between 0 and 3".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let steps = query.steps.unwrap_or(true);
|
||||
|
||||
let geometries = query.geometries.as_deref().unwrap_or("geojson");
|
||||
if !VALID_GEOMETRIES.contains(&geometries) {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"Invalid geometries format: {geometries}. Must be one of: {}",
|
||||
VALID_GEOMETRIES.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
let overview = query.overview.as_deref().unwrap_or("full");
|
||||
if !VALID_OVERVIEWS.contains(&overview) {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"Invalid overview: {overview}. Must be one of: {}",
|
||||
VALID_OVERVIEWS.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
let language = query.language.as_deref().unwrap_or("en");
|
||||
|
||||
// Check cache
|
||||
let cache_key =
|
||||
CacheService::route_key(&profile, &coordinates, alternatives, steps, geometries, overview);
|
||||
if let Some(cached) = cache.get_json(&cache_key).await {
|
||||
tracing::debug!("Route cache hit");
|
||||
return Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "no-store"))
|
||||
.body(cached));
|
||||
}
|
||||
|
||||
// Call OSRM
|
||||
let json = osrm
|
||||
.route(
|
||||
&profile,
|
||||
&coordinates,
|
||||
alternatives,
|
||||
steps,
|
||||
geometries,
|
||||
overview,
|
||||
language,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Check OSRM response code
|
||||
if let Some(code) = json.get("code").and_then(|c| c.as_str()) {
|
||||
match map_osrm_code(code) {
|
||||
OsrmCodeMapping::Ok => {} // Success, continue
|
||||
OsrmCodeMapping::NoRoute => {
|
||||
return Err(AppError::NotFound("No route could be found".into()));
|
||||
}
|
||||
OsrmCodeMapping::NoSegment => {
|
||||
return Err(AppError::InvalidCoordinates(
|
||||
"Coordinates could not be snapped to the road network".into(),
|
||||
));
|
||||
}
|
||||
OsrmCodeMapping::TooBig => {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"Route request is too large".into(),
|
||||
));
|
||||
}
|
||||
OsrmCodeMapping::Other(c) => {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"OSRM returned unexpected code: {c}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let body = serde_json::to_string(&json)
|
||||
.map_err(|e| AppError::InternalError(format!("JSON serialization failed: {e}")))?;
|
||||
|
||||
// Cache for 30 minutes
|
||||
cache.set_json(&cache_key, &body, 1800).await;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "no-store"))
|
||||
.body(body))
|
||||
}
|
||||
|
||||
/// Validate coordinate string: "lon,lat;lon,lat[;...]"
|
||||
/// Minimum 2 pairs, maximum 7 pairs.
|
||||
fn validate_coordinates(coords: &str) -> Result<(), AppError> {
|
||||
let pairs: Vec<&str> = coords.split(';').collect();
|
||||
|
||||
if pairs.len() < 2 {
|
||||
return Err(AppError::InvalidCoordinates(
|
||||
"At least 2 coordinate pairs are required".into(),
|
||||
));
|
||||
}
|
||||
if pairs.len() > 7 {
|
||||
return Err(AppError::InvalidCoordinates(
|
||||
"At most 7 coordinate pairs are allowed (origin + 5 waypoints + destination)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
for pair in &pairs {
|
||||
let parts: Vec<&str> = pair.split(',').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(AppError::InvalidCoordinates(format!(
|
||||
"Malformed coordinate pair: {pair}"
|
||||
)));
|
||||
}
|
||||
|
||||
let lon: f64 = parts[0].parse().map_err(|_| {
|
||||
AppError::InvalidCoordinates(format!("Invalid longitude in pair: {pair}"))
|
||||
})?;
|
||||
let lat: f64 = parts[1].parse().map_err(|_| {
|
||||
AppError::InvalidCoordinates(format!("Invalid latitude in pair: {pair}"))
|
||||
})?;
|
||||
|
||||
if !(-180.0..=180.0).contains(&lon) {
|
||||
return Err(AppError::InvalidCoordinates(format!(
|
||||
"Longitude {lon} out of range [-180, 180]"
|
||||
)));
|
||||
}
|
||||
if !(-90.0..=90.0).contains(&lat) {
|
||||
return Err(AppError::InvalidCoordinates(format!(
|
||||
"Latitude {lat} out of range [-90, 90]"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
216
backend/src/routes/search.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
use actix_web::{web, HttpResponse};
|
||||
|
||||
use crate::errors::AppError;
|
||||
use crate::models::search::*;
|
||||
use crate::services::cache::CacheService;
|
||||
use crate::services::photon::PhotonService;
|
||||
|
||||
/// Query parameters for forward search.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
q: Option<String>,
|
||||
lat: Option<f64>,
|
||||
lon: Option<f64>,
|
||||
limit: Option<u32>,
|
||||
lang: Option<String>,
|
||||
bbox: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/search
|
||||
///
|
||||
/// Proxies to Photon with Redis caching (TTL 1h).
|
||||
pub async fn search(
|
||||
query: web::Query<SearchQuery>,
|
||||
photon: web::Data<PhotonService>,
|
||||
cache: web::Data<CacheService>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let q = query
|
||||
.q
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::MissingParameter("q parameter is required".into()))?;
|
||||
|
||||
if q.is_empty() {
|
||||
return Err(AppError::MissingParameter(
|
||||
"q parameter must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Strip control characters
|
||||
let q_clean: String = q.chars().filter(|c| !c.is_control()).collect();
|
||||
if q_clean.len() > MAX_QUERY_LENGTH {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"Query exceeds maximum length of {MAX_QUERY_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
|
||||
// lat and lon must both be provided or both omitted
|
||||
match (query.lat, query.lon) {
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"lat and lon must both be provided or both omitted".into(),
|
||||
));
|
||||
}
|
||||
(Some(lat), Some(_)) if !(-90.0..=90.0).contains(&lat) => {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"lat must be between -90.0 and 90.0".into(),
|
||||
));
|
||||
}
|
||||
(Some(_), Some(lon)) if !(-180.0..=180.0).contains(&lon) => {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"lon must be between -180.0 and 180.0".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(SEARCH_LIMIT_DEFAULT);
|
||||
if !(SEARCH_LIMIT_MIN..=SEARCH_LIMIT_MAX).contains(&limit) {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"limit must be between {SEARCH_LIMIT_MIN} and {SEARCH_LIMIT_MAX}"
|
||||
)));
|
||||
}
|
||||
|
||||
let lang = query.lang.as_deref().unwrap_or("en");
|
||||
|
||||
// Validate bbox if provided
|
||||
if let Some(ref bbox) = query.bbox {
|
||||
validate_bbox(bbox)?;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
let cache_key = CacheService::search_key(&q_clean, query.lat, query.lon, limit, lang);
|
||||
if let Some(cached) = cache.get_json(&cache_key).await {
|
||||
tracing::debug!("Search cache hit");
|
||||
return Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "public, max-age=300"))
|
||||
.body(cached));
|
||||
}
|
||||
|
||||
// Call Photon
|
||||
let json = photon
|
||||
.search(&q_clean, query.lat, query.lon, limit, lang, query.bbox.as_deref())
|
||||
.await?;
|
||||
|
||||
let body = serde_json::to_string(&json)
|
||||
.map_err(|e| AppError::InternalError(format!("JSON serialization failed: {e}")))?;
|
||||
|
||||
// Cache the result
|
||||
cache.set_json(&cache_key, &body, 3600).await;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "public, max-age=300"))
|
||||
.body(body))
|
||||
}
|
||||
|
||||
/// Query parameters for reverse geocoding.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ReverseQuery {
|
||||
lat: Option<f64>,
|
||||
lon: Option<f64>,
|
||||
limit: Option<u32>,
|
||||
lang: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/reverse
|
||||
///
|
||||
/// Proxies to Photon reverse geocoding with caching (TTL 1h).
|
||||
pub async fn reverse(
|
||||
query: web::Query<ReverseQuery>,
|
||||
photon: web::Data<PhotonService>,
|
||||
cache: web::Data<CacheService>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let lat = query
|
||||
.lat
|
||||
.ok_or_else(|| AppError::MissingParameter("lat parameter is required".into()))?;
|
||||
let lon = query
|
||||
.lon
|
||||
.ok_or_else(|| AppError::MissingParameter("lon parameter is required".into()))?;
|
||||
|
||||
if !(-90.0..=90.0).contains(&lat) {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"lat must be between -90.0 and 90.0".into(),
|
||||
));
|
||||
}
|
||||
if !(-180.0..=180.0).contains(&lon) {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"lon must be between -180.0 and 180.0".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(REVERSE_LIMIT_DEFAULT);
|
||||
if !(REVERSE_LIMIT_MIN..=REVERSE_LIMIT_MAX).contains(&limit) {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"limit must be between {REVERSE_LIMIT_MIN} and {REVERSE_LIMIT_MAX}"
|
||||
)));
|
||||
}
|
||||
|
||||
let lang = query.lang.as_deref().unwrap_or("en");
|
||||
|
||||
// Build cache key using the same search key scheme
|
||||
let cache_key = CacheService::search_key(
|
||||
&format!("reverse:{lat:.6},{lon:.6}"),
|
||||
Some(lat),
|
||||
Some(lon),
|
||||
limit,
|
||||
lang,
|
||||
);
|
||||
|
||||
if let Some(cached) = cache.get_json(&cache_key).await {
|
||||
tracing::debug!("Reverse geocode cache hit");
|
||||
return Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "public, max-age=3600"))
|
||||
.body(cached));
|
||||
}
|
||||
|
||||
let json = photon.reverse(lat, lon, limit, lang).await?;
|
||||
let body = serde_json::to_string(&json)
|
||||
.map_err(|e| AppError::InternalError(format!("JSON serialization failed: {e}")))?;
|
||||
|
||||
cache.set_json(&cache_key, &body, 3600).await;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "public, max-age=3600"))
|
||||
.body(body))
|
||||
}
|
||||
|
||||
/// Validate a bounding box string: "minLon,minLat,maxLon,maxLat".
|
||||
fn validate_bbox(bbox: &str) -> Result<(), AppError> {
|
||||
let parts: Vec<f64> = bbox
|
||||
.split(',')
|
||||
.map(|s| {
|
||||
s.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(|_| AppError::InvalidParameter("bbox contains non-numeric values".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
if parts.len() != 4 {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"bbox must have exactly 4 comma-separated values".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let (min_lon, min_lat, max_lon, max_lat) = (parts[0], parts[1], parts[2], parts[3]);
|
||||
|
||||
if !(-180.0..=180.0).contains(&min_lon)
|
||||
|| !(-180.0..=180.0).contains(&max_lon)
|
||||
|| !(-90.0..=90.0).contains(&min_lat)
|
||||
|| !(-90.0..=90.0).contains(&max_lat)
|
||||
{
|
||||
return Err(AppError::InvalidParameter(
|
||||
"bbox coordinates out of range".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if min_lon >= max_lon || min_lat >= max_lat {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"bbox min values must be less than max values".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
93
backend/src/routes/tiles.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
use actix_web::{web, HttpResponse};
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::errors::AppError;
|
||||
use crate::services::cache::CacheService;
|
||||
use crate::services::martin::MartinService;
|
||||
|
||||
/// Path parameters for tile requests.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct TilePath {
|
||||
layer: String,
|
||||
z: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
}
|
||||
|
||||
const VALID_LAYERS: &[&str] = &["openmaptiles", "terrain", "hillshade"];
|
||||
|
||||
/// GET /tiles/{layer}/{z}/{x}/{y}.pbf
|
||||
///
|
||||
/// Proxies to Martin with Redis tile caching (TTL 24h).
|
||||
pub async fn get_tile(
|
||||
path: web::Path<TilePath>,
|
||||
martin: web::Data<MartinService>,
|
||||
cache: web::Data<CacheService>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let TilePath { layer, z, x, y } = path.into_inner();
|
||||
|
||||
// Validate layer
|
||||
if !VALID_LAYERS.contains(&layer.as_str()) {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"Invalid layer: {layer}. Must be one of: {}",
|
||||
VALID_LAYERS.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate zoom
|
||||
if z > 18 {
|
||||
return Err(AppError::InvalidParameter(
|
||||
"Zoom level must be between 0 and 18".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate x/y range for zoom level
|
||||
let max_coord = (1u64 << z) - 1;
|
||||
if (x as u64) > max_coord || (y as u64) > max_coord {
|
||||
return Err(AppError::InvalidParameter(format!(
|
||||
"x and y must be between 0 and {max_coord} for zoom level {z}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Check Redis cache
|
||||
let cache_key = CacheService::tile_key(&layer, z, x, y);
|
||||
if let Some(cached) = cache.get_tile(&cache_key).await {
|
||||
tracing::debug!("Tile cache hit");
|
||||
return Ok(HttpResponse::Ok()
|
||||
.content_type("application/x-protobuf")
|
||||
.insert_header(("Content-Encoding", "gzip"))
|
||||
.insert_header(("Cache-Control", "public, max-age=86400"))
|
||||
.body(cached));
|
||||
}
|
||||
|
||||
// Fetch from Martin
|
||||
let (data, etag): (Bytes, Option<String>) = martin.get_tile(&layer, z, x, y).await?;
|
||||
|
||||
// Store in cache (fire-and-forget)
|
||||
cache.set_tile(&cache_key, &data).await;
|
||||
|
||||
let mut resp = HttpResponse::Ok();
|
||||
resp.content_type("application/x-protobuf")
|
||||
.insert_header(("Content-Encoding", "gzip"))
|
||||
.insert_header(("Cache-Control", "public, max-age=86400"));
|
||||
|
||||
if let Some(etag) = etag {
|
||||
resp.insert_header(("ETag", etag));
|
||||
}
|
||||
|
||||
Ok(resp.body(data))
|
||||
}
|
||||
|
||||
/// GET /tiles/style.json
|
||||
///
|
||||
/// Proxies the Mapbox GL style JSON from Martin.
|
||||
pub async fn get_style(
|
||||
martin: web::Data<MartinService>,
|
||||
) -> Result<HttpResponse, AppError> {
|
||||
let style = martin.get_style().await?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json; charset=utf-8")
|
||||
.insert_header(("Cache-Control", "public, max-age=3600"))
|
||||
.json(style))
|
||||
}
|
||||
113
backend/src/services/cache.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
use redis::AsyncCommands;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Redis caching layer implementing the key patterns and TTLs
|
||||
/// defined in DATA_MODEL.md section 3.
|
||||
pub struct CacheService {
|
||||
client: redis::Client,
|
||||
}
|
||||
|
||||
impl CacheService {
|
||||
pub fn new(redis_url: &str) -> Result<Self, redis::RedisError> {
|
||||
let client = redis::Client::open(redis_url)?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
async fn conn(&self) -> Result<redis::aio::MultiplexedConnection, redis::RedisError> {
|
||||
self.client.get_multiplexed_async_connection().await
|
||||
}
|
||||
|
||||
// ── Tile cache ──────────────────────────────────────────────
|
||||
|
||||
/// Key: `tile:{layer}:{z}:{x}:{y}`, TTL: 24 hours.
|
||||
pub fn tile_key(layer: &str, z: u32, x: u32, y: u32) -> String {
|
||||
format!("tile:{layer}:{z}:{x}:{y}")
|
||||
}
|
||||
|
||||
pub async fn get_tile(&self, key: &str) -> Option<Vec<u8>> {
|
||||
let mut conn = self.conn().await.ok()?;
|
||||
conn.get::<_, Option<Vec<u8>>>(key).await.ok().flatten()
|
||||
}
|
||||
|
||||
pub async fn set_tile(&self, key: &str, data: &[u8]) {
|
||||
if let Ok(mut conn) = self.conn().await {
|
||||
let _: Result<(), _> = conn.set_ex(key, data, 86400).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search cache ────────────────────────────────────────────
|
||||
|
||||
/// Key: `search:{sha256(params)}`, TTL: 1 hour.
|
||||
pub fn search_key(
|
||||
q: &str,
|
||||
lat: Option<f64>,
|
||||
lon: Option<f64>,
|
||||
limit: u32,
|
||||
lang: &str,
|
||||
) -> String {
|
||||
let input = format!(
|
||||
"q={}&lat={}&lon={}&limit={}&lang={}",
|
||||
q,
|
||||
lat.map(|v| format!("{v:.6}")).unwrap_or_default(),
|
||||
lon.map(|v| format!("{v:.6}")).unwrap_or_default(),
|
||||
limit,
|
||||
lang,
|
||||
);
|
||||
let hash = hex::encode(Sha256::digest(input.as_bytes()));
|
||||
format!("search:{hash}")
|
||||
}
|
||||
|
||||
pub async fn get_json(&self, key: &str) -> Option<String> {
|
||||
let mut conn = self.conn().await.ok()?;
|
||||
conn.get::<_, Option<String>>(key).await.ok().flatten()
|
||||
}
|
||||
|
||||
pub async fn set_json(&self, key: &str, json: &str, ttl_secs: u64) {
|
||||
if let Ok(mut conn) = self.conn().await {
|
||||
let _: Result<(), _> = conn.set_ex(key, json, ttl_secs).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route cache ─────────────────────────────────────────────
|
||||
|
||||
/// Key: `route:{sha256(params)}`, TTL: 30 minutes.
|
||||
pub fn route_key(
|
||||
profile: &str,
|
||||
coordinates: &str,
|
||||
alternatives: u32,
|
||||
steps: bool,
|
||||
geometries: &str,
|
||||
overview: &str,
|
||||
) -> String {
|
||||
let input = format!(
|
||||
"profile={}&coords={}&alt={}&steps={}&geom={}&overview={}",
|
||||
profile, coordinates, alternatives, steps, geometries, overview,
|
||||
);
|
||||
let hash = hex::encode(Sha256::digest(input.as_bytes()));
|
||||
format!("route:{hash}")
|
||||
}
|
||||
|
||||
// ── Health cache ────────────────────────────────────────────
|
||||
|
||||
pub async fn get_health(&self, service: &str) -> Option<String> {
|
||||
let key = format!("health:{service}");
|
||||
self.get_json(&key).await
|
||||
}
|
||||
|
||||
pub async fn set_health(&self, service: &str, json: &str) {
|
||||
let key = format!("health:{service}");
|
||||
self.set_json(&key, json, 30).await;
|
||||
}
|
||||
|
||||
// ── Generic health probe for Redis itself ───────────────────
|
||||
|
||||
pub async fn ping(&self) -> Result<u64, ()> {
|
||||
let start = std::time::Instant::now();
|
||||
let mut conn = self.conn().await.map_err(|_| ())?;
|
||||
let _: String = redis::cmd("PING")
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
Ok(start.elapsed().as_millis() as u64)
|
||||
}
|
||||
}
|
||||
102
backend/src/services/martin.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use crate::config::AppConfig;
|
||||
use crate::errors::AppError;
|
||||
use bytes::Bytes;
|
||||
|
||||
/// HTTP client for the Martin tile server.
|
||||
pub struct MartinService {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl MartinService {
|
||||
pub fn new(config: &AppConfig) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client"),
|
||||
base_url: config.martin_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a vector tile from Martin.
|
||||
/// Returns the raw bytes (gzip-compressed protobuf).
|
||||
pub async fn get_tile(
|
||||
&self,
|
||||
layer: &str,
|
||||
z: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
) -> Result<(Bytes, Option<String>), AppError> {
|
||||
let url = format!("{}/{layer}/{z}/{x}/{y}", self.base_url);
|
||||
let resp = self.client.get(&url).send().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Martin connection error");
|
||||
AppError::ServiceUnavailable("Martin tile server is unreachable".into())
|
||||
})?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(AppError::NotFound(
|
||||
"No tile data exists for these coordinates".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Martin returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let etag = resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
let body = resp.bytes().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to read Martin response body");
|
||||
AppError::UpstreamError("Failed to read tile data".into())
|
||||
})?;
|
||||
|
||||
Ok((body, etag))
|
||||
}
|
||||
|
||||
/// Fetch the style.json from Martin.
|
||||
pub async fn get_style(&self) -> Result<serde_json::Value, AppError> {
|
||||
let url = format!("{}/style.json", self.base_url);
|
||||
let resp = self.client.get(&url).send().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Martin connection error");
|
||||
AppError::ServiceUnavailable("Martin tile server is unreachable".into())
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Martin returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to parse Martin style.json");
|
||||
AppError::UpstreamError("Failed to parse style.json".into())
|
||||
})?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Health probe — simple connectivity check.
|
||||
pub async fn health_check(&self) -> Result<u64, ()> {
|
||||
let start = std::time::Instant::now();
|
||||
let resp = self
|
||||
.client
|
||||
.get(&format!("{}/health", self.base_url))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await;
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() => Ok(latency),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
4
backend/src/services/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod cache;
|
||||
pub mod martin;
|
||||
pub mod osrm;
|
||||
pub mod photon;
|
||||
98
backend/src/services/osrm.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use crate::config::AppConfig;
|
||||
use crate::errors::AppError;
|
||||
|
||||
/// HTTP client for OSRM routing engine instances.
|
||||
pub struct OsrmService {
|
||||
client: reqwest::Client,
|
||||
driving_url: String,
|
||||
walking_url: String,
|
||||
cycling_url: String,
|
||||
}
|
||||
|
||||
impl OsrmService {
|
||||
pub fn new(config: &AppConfig) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client"),
|
||||
driving_url: config.osrm_driving_url.clone(),
|
||||
walking_url: config.osrm_walking_url.clone(),
|
||||
cycling_url: config.osrm_cycling_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url_for_profile(&self, profile: &str) -> Option<&str> {
|
||||
match profile {
|
||||
"driving" => Some(&self.driving_url),
|
||||
"walking" => Some(&self.walking_url),
|
||||
"cycling" => Some(&self.cycling_url),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Request a route from the appropriate OSRM instance.
|
||||
/// Returns raw JSON from OSRM.
|
||||
pub async fn route(
|
||||
&self,
|
||||
profile: &str,
|
||||
coordinates: &str,
|
||||
alternatives: u32,
|
||||
steps: bool,
|
||||
geometries: &str,
|
||||
overview: &str,
|
||||
language: &str,
|
||||
) -> Result<serde_json::Value, AppError> {
|
||||
let base = self.base_url_for_profile(profile).ok_or_else(|| {
|
||||
AppError::InvalidParameter(format!("Invalid profile: {profile}"))
|
||||
})?;
|
||||
|
||||
let url = format!(
|
||||
"{base}/route/v1/{profile}/{coordinates}?alternatives={alternatives}&steps={steps}&geometries={geometries}&overview={overview}&language={language}"
|
||||
);
|
||||
|
||||
let resp = self.client.get(&url).send().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "OSRM connection error");
|
||||
AppError::ServiceUnavailable(format!(
|
||||
"OSRM instance for {profile} is unreachable"
|
||||
))
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"OSRM returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to parse OSRM response");
|
||||
AppError::UpstreamError("Failed to parse routing response".into())
|
||||
})?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Health probe for a specific OSRM profile.
|
||||
pub async fn health_check(&self, profile: &str) -> Result<u64, ()> {
|
||||
let base = match self.base_url_for_profile(profile) {
|
||||
Some(u) => u,
|
||||
None => return Err(()),
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
// OSRM has no dedicated health endpoint; a minimal route request works.
|
||||
let url = format!("{base}/route/v1/{profile}/0,0;0.001,0.001?overview=false&steps=false");
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await;
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() || r.status().as_u16() == 400 => Ok(latency),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
118
backend/src/services/photon.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use crate::config::AppConfig;
|
||||
use crate::errors::AppError;
|
||||
|
||||
/// HTTP client for the Photon geocoder.
|
||||
pub struct PhotonService {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl PhotonService {
|
||||
pub fn new(config: &AppConfig) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client"),
|
||||
base_url: config.photon_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward geocoding search. Returns raw JSON from Photon.
|
||||
pub async fn search(
|
||||
&self,
|
||||
q: &str,
|
||||
lat: Option<f64>,
|
||||
lon: Option<f64>,
|
||||
limit: u32,
|
||||
lang: &str,
|
||||
bbox: Option<&str>,
|
||||
) -> Result<serde_json::Value, AppError> {
|
||||
let mut url = format!("{}/api?q={}&limit={}&lang={}", self.base_url, urlencod(q), limit, lang);
|
||||
|
||||
if let (Some(lat), Some(lon)) = (lat, lon) {
|
||||
url.push_str(&format!("&lat={lat}&lon={lon}"));
|
||||
}
|
||||
|
||||
if let Some(bbox) = bbox {
|
||||
url.push_str(&format!("&bbox={bbox}"));
|
||||
}
|
||||
|
||||
let resp = self.client.get(&url).send().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Photon connection error");
|
||||
AppError::ServiceUnavailable("Photon geocoder is unreachable".into())
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Photon returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to parse Photon response");
|
||||
AppError::UpstreamError("Failed to parse search response".into())
|
||||
})?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Reverse geocoding. Returns raw JSON from Photon.
|
||||
pub async fn reverse(
|
||||
&self,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
limit: u32,
|
||||
lang: &str,
|
||||
) -> Result<serde_json::Value, AppError> {
|
||||
let url = format!(
|
||||
"{}/reverse?lat={lat}&lon={lon}&limit={limit}&lang={lang}",
|
||||
self.base_url
|
||||
);
|
||||
|
||||
let resp = self.client.get(&url).send().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Photon connection error");
|
||||
AppError::ServiceUnavailable("Photon geocoder is unreachable".into())
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Photon returned status {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to parse Photon reverse response");
|
||||
AppError::UpstreamError("Failed to parse reverse geocoding response".into())
|
||||
})?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Health probe.
|
||||
pub async fn health_check(&self) -> Result<u64, ()> {
|
||||
let start = std::time::Instant::now();
|
||||
let resp = self
|
||||
.client
|
||||
.get(&format!("{}/api?q=healthcheck&limit=1", self.base_url))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await;
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
match resp {
|
||||
Ok(r) if r.status().is_success() => Ok(latency),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal URL encoding for query strings.
|
||||
fn urlencod(s: &str) -> String {
|
||||
s.replace(' ', "+")
|
||||
.replace('&', "%26")
|
||||
.replace('=', "%3D")
|
||||
.replace('#', "%23")
|
||||
.replace('?', "%3F")
|
||||
}
|
||||
917
docs/API.md
Normal file
|
|
@ -0,0 +1,917 @@
|
|||
# REST API Contract: Privacy-First Maps Application
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Date:** 2026-03-29
|
||||
**Base URL:** Configured by user at first launch (e.g., `https://maps.example.com`)
|
||||
|
||||
---
|
||||
|
||||
## General Conventions
|
||||
|
||||
### Authentication
|
||||
None. All endpoints are unauthenticated. No API keys, tokens, cookies, or sessions.
|
||||
|
||||
### Content Types
|
||||
- JSON responses: `Content-Type: application/json; charset=utf-8`
|
||||
- Protobuf tile responses: `Content-Type: application/x-protobuf`
|
||||
- Binary download responses: `Content-Type: application/octet-stream`
|
||||
- Style JSON: `Content-Type: application/json; charset=utf-8`
|
||||
|
||||
### Standard Error Response
|
||||
|
||||
All error responses use the following JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable description of the error."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes Reference
|
||||
|
||||
| HTTP Status | Error Code | Description |
|
||||
|---|---|---|
|
||||
| 400 | `MISSING_PARAMETER` | A required query parameter is missing |
|
||||
| 400 | `INVALID_PARAMETER` | A parameter value is malformed or out of range |
|
||||
| 400 | `INVALID_BBOX` | Bounding box coordinates are invalid |
|
||||
| 400 | `INVALID_COORDINATES` | Route coordinates are invalid or out of range |
|
||||
| 404 | `NOT_FOUND` | The requested resource does not exist |
|
||||
| 429 | `RATE_LIMITED` | Request rate limit exceeded |
|
||||
| 502 | `UPSTREAM_ERROR` | An upstream service (Martin/Photon/OSRM) returned an error |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | An upstream service is unreachable |
|
||||
| 500 | `INTERNAL_ERROR` | An unexpected server error occurred |
|
||||
|
||||
### Common Response Headers
|
||||
|
||||
| Header | Value | Applies To |
|
||||
|---|---|---|
|
||||
| `Content-Type` | varies (see above) | All responses |
|
||||
| `Cache-Control` | varies per endpoint | Cacheable responses |
|
||||
| `X-Request-Id` | UUID v4 | All responses (for correlation, no PII) |
|
||||
| `Access-Control-Allow-Origin` | `*` | All responses |
|
||||
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains` | All responses |
|
||||
|
||||
---
|
||||
|
||||
## 1. Tile Serving
|
||||
|
||||
### 1.1 Get Vector Tile
|
||||
|
||||
Proxied to Martin tile server.
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /tiles/{layer}/{z}/{x}/{y}.pbf
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Constraints | Description |
|
||||
|---|---|---|---|---|
|
||||
| `layer` | string | Yes | One of: `openmaptiles`, `terrain`, `hillshade` | Tile layer name |
|
||||
| `z` | integer | Yes | [0, 18] | Zoom level |
|
||||
| `x` | integer | Yes | [0, 2^z - 1] | Tile column |
|
||||
| `y` | integer | Yes | [0, 2^z - 1] | Tile row |
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/x-protobuf
|
||||
Content-Encoding: gzip
|
||||
Cache-Control: public, max-age=86400
|
||||
ETag: "abc123"
|
||||
```
|
||||
|
||||
Body: Gzip-compressed Mapbox Vector Tile (protobuf binary).
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 400 | `INVALID_PARAMETER` | `z` out of [0,18], or `x`/`y` out of range for zoom level |
|
||||
| 404 | `NOT_FOUND` | No tile data exists for these coordinates |
|
||||
| 502 | `UPSTREAM_ERROR` | Martin returned an error |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | Martin is unreachable |
|
||||
|
||||
**Caching behavior:** Tiles are immutable between OSM data imports. The `ETag` changes when tile data is regenerated. Clients should use `If-None-Match` for conditional requests; the server returns `304 Not Modified` when the tile has not changed.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Get Map Style
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /tiles/style.json
|
||||
```
|
||||
|
||||
No parameters.
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Privacy Maps Default",
|
||||
"sources": {
|
||||
"openmaptiles": {
|
||||
"type": "vector",
|
||||
"tiles": ["/tiles/openmaptiles/{z}/{x}/{y}.pbf"],
|
||||
"minzoom": 0,
|
||||
"maxzoom": 14
|
||||
},
|
||||
"terrain": {
|
||||
"type": "vector",
|
||||
"tiles": ["/tiles/terrain/{z}/{x}/{y}.pbf"],
|
||||
"minzoom": 0,
|
||||
"maxzoom": 14
|
||||
}
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": {
|
||||
"background-color": "#f8f4f0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The full style document includes all layer definitions for roads, buildings, water, land use, labels, etc. The above is an abbreviated example. The mobile app uses this to configure MapLibre GL Native.
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 502 | `UPSTREAM_ERROR` | Martin returned an error |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | Martin is unreachable |
|
||||
|
||||
---
|
||||
|
||||
## 2. Search / Geocoding
|
||||
|
||||
### 2.1 Forward Search
|
||||
|
||||
Proxied to Photon.
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /api/search?q={query}&lat={lat}&lon={lon}&limit={limit}&lang={lang}&bbox={bbox}
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Constraints | Description |
|
||||
|---|---|---|---|---|---|
|
||||
| `q` | string | Yes | — | Max 500 characters, control chars stripped | Search query text |
|
||||
| `lat` | float | No | — | [-90.0, 90.0] | Latitude for proximity bias |
|
||||
| `lon` | float | No | — | [-180.0, 180.0] | Longitude for proximity bias |
|
||||
| `limit` | integer | No | 10 | [1, 20] | Maximum number of results |
|
||||
| `lang` | string | No | `en` | ISO 639-1 two-letter code | Preferred result language |
|
||||
| `bbox` | string | No | — | `minLon,minLat,maxLon,maxLat` (valid coordinates, min < max) | Bounding box filter |
|
||||
|
||||
**Note:** `lat` and `lon` must both be provided or both omitted. Providing only one returns `400 INVALID_PARAMETER`.
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Cache-Control: public, max-age=300
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [4.9041, 52.3676]
|
||||
},
|
||||
"properties": {
|
||||
"osm_id": 12345678,
|
||||
"osm_type": "N",
|
||||
"name": "Vondelpark",
|
||||
"street": "Vondelpark",
|
||||
"housenumber": null,
|
||||
"postcode": "1071 AA",
|
||||
"city": "Amsterdam",
|
||||
"state": "North Holland",
|
||||
"country": "Netherlands",
|
||||
"country_code": "nl",
|
||||
"type": "park",
|
||||
"extent": [4.8580, 52.3585, 4.8820, 52.3620]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Feature properties schema:**
|
||||
|
||||
| Field | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| `osm_id` | integer | No | OpenStreetMap element ID |
|
||||
| `osm_type` | string | No | `"N"` (node), `"W"` (way), or `"R"` (relation) |
|
||||
| `name` | string | No | Place name |
|
||||
| `street` | string | Yes | Street name |
|
||||
| `housenumber` | string | Yes | House number |
|
||||
| `postcode` | string | Yes | Postal code |
|
||||
| `city` | string | Yes | City name |
|
||||
| `state` | string | Yes | State or province |
|
||||
| `country` | string | Yes | Country name |
|
||||
| `country_code` | string | Yes | ISO 3166-1 alpha-2 country code |
|
||||
| `type` | string | No | Place type (e.g., `"park"`, `"house"`, `"street"`, `"city"`) |
|
||||
| `extent` | array | Yes | Bounding box `[minLon, minLat, maxLon, maxLat]` for area features |
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 400 | `MISSING_PARAMETER` | `q` parameter is missing |
|
||||
| 400 | `INVALID_PARAMETER` | `lat`/`lon` out of range, `limit` out of range, `lang` not a valid ISO 639-1 code, `bbox` malformed, only one of `lat`/`lon` provided |
|
||||
| 502 | `UPSTREAM_ERROR` | Photon returned an error |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | Photon is unreachable |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Reverse Geocoding
|
||||
|
||||
Proxied to Photon.
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /api/reverse?lat={lat}&lon={lon}&limit={limit}&lang={lang}
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Constraints | Description |
|
||||
|---|---|---|---|---|---|
|
||||
| `lat` | float | Yes | — | [-90.0, 90.0] | Latitude |
|
||||
| `lon` | float | Yes | — | [-180.0, 180.0] | Longitude |
|
||||
| `limit` | integer | No | 1 | [1, 5] | Maximum number of results |
|
||||
| `lang` | string | No | `en` | ISO 639-1 two-letter code | Preferred result language |
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
|
||||
Response body: same GeoJSON `FeatureCollection` format as forward search (Section 2.1).
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 400 | `MISSING_PARAMETER` | `lat` or `lon` missing |
|
||||
| 400 | `INVALID_PARAMETER` | `lat`/`lon` out of range, `limit` out of range |
|
||||
| 502 | `UPSTREAM_ERROR` | Photon returned an error |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | Photon is unreachable |
|
||||
|
||||
---
|
||||
|
||||
## 3. Routing
|
||||
|
||||
### 3.1 Calculate Route
|
||||
|
||||
Proxied to OSRM.
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /api/route/{profile}/{coordinates}?alternatives={n}&steps={bool}&geometries={fmt}&overview={detail}&language={lang}
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Constraints | Description |
|
||||
|---|---|---|---|---|
|
||||
| `profile` | string | Yes | One of: `driving`, `walking`, `cycling` | Routing profile |
|
||||
| `coordinates` | string | Yes | 2-7 coordinate pairs, semicolon-separated: `{lon},{lat};{lon},{lat}[;...]` | Route waypoints (first = origin, last = destination, middle = intermediate stops) |
|
||||
|
||||
**Coordinate constraints:** Each `lon` in [-180, 180], each `lat` in [-90, 90]. Minimum 2 pairs, maximum 7 (origin + 5 waypoints + destination).
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Constraints | Description |
|
||||
|---|---|---|---|---|---|
|
||||
| `alternatives` | integer | No | 0 | [0, 3] | Number of alternative routes to return |
|
||||
| `steps` | boolean | No | `true` | `true` or `false` | Include turn-by-turn steps |
|
||||
| `geometries` | string | No | `geojson` | One of: `polyline`, `polyline6`, `geojson` | Geometry encoding format |
|
||||
| `overview` | string | No | `full` | One of: `full`, `simplified`, `false` | Route geometry detail level |
|
||||
| `language` | string | No | `en` | ISO 639-1 two-letter code | Language for turn instructions |
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Cache-Control: no-store
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "Ok",
|
||||
"routes": [
|
||||
{
|
||||
"distance": 12456.7,
|
||||
"duration": 1823.4,
|
||||
"weight": 1823.4,
|
||||
"weight_name": "routability",
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [
|
||||
[4.9041, 52.3676],
|
||||
[4.9060, 52.3680],
|
||||
[4.9100, 52.3700]
|
||||
]
|
||||
},
|
||||
"legs": [
|
||||
{
|
||||
"distance": 12456.7,
|
||||
"duration": 1823.4,
|
||||
"summary": "Keizersgracht, Damrak",
|
||||
"steps": [
|
||||
{
|
||||
"distance": 234.5,
|
||||
"duration": 32.1,
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [
|
||||
[4.9041, 52.3676],
|
||||
[4.9060, 52.3680]
|
||||
]
|
||||
},
|
||||
"maneuver": {
|
||||
"type": "turn",
|
||||
"modifier": "left",
|
||||
"location": [4.9041, 52.3676],
|
||||
"bearing_before": 90,
|
||||
"bearing_after": 0,
|
||||
"instruction": "Turn left onto Keizersgracht"
|
||||
},
|
||||
"name": "Keizersgracht",
|
||||
"mode": "driving",
|
||||
"driving_side": "right",
|
||||
"ref": "",
|
||||
"intersections": [
|
||||
{
|
||||
"location": [4.9041, 52.3676],
|
||||
"bearings": [0, 90, 180, 270],
|
||||
"entry": [true, false, true, true],
|
||||
"out": 0,
|
||||
"in": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"distance": 0,
|
||||
"duration": 0,
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[4.9100, 52.3700]]
|
||||
},
|
||||
"maneuver": {
|
||||
"type": "arrive",
|
||||
"modifier": null,
|
||||
"location": [4.9100, 52.3700],
|
||||
"bearing_before": 45,
|
||||
"bearing_after": 0,
|
||||
"instruction": "You have arrived at your destination"
|
||||
},
|
||||
"name": "Dam Square",
|
||||
"mode": "driving"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"waypoints": [
|
||||
{
|
||||
"name": "Vondelstraat",
|
||||
"location": [4.9041, 52.3676],
|
||||
"hint": "..."
|
||||
},
|
||||
{
|
||||
"name": "Dam Square",
|
||||
"location": [4.8952, 52.3732],
|
||||
"hint": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Route object schema:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `distance` | float | Total route distance in meters |
|
||||
| `duration` | float | Estimated travel time in seconds |
|
||||
| `weight` | float | OSRM routing weight |
|
||||
| `weight_name` | string | Weight metric name |
|
||||
| `geometry` | GeoJSON LineString | Full route geometry |
|
||||
| `legs` | array of Leg | One leg per pair of consecutive waypoints |
|
||||
|
||||
**Leg object schema:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `distance` | float | Leg distance in meters |
|
||||
| `duration` | float | Leg duration in seconds |
|
||||
| `summary` | string | Human-readable summary of major roads |
|
||||
| `steps` | array of Step | Turn-by-turn steps (if `steps=true`) |
|
||||
|
||||
**Step object schema:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `distance` | float | Step distance in meters |
|
||||
| `duration` | float | Step duration in seconds |
|
||||
| `geometry` | GeoJSON LineString | Step geometry |
|
||||
| `maneuver` | Maneuver | Maneuver details |
|
||||
| `name` | string | Road name |
|
||||
| `mode` | string | Travel mode for this step |
|
||||
| `ref` | string | Road reference number (if applicable) |
|
||||
| `driving_side` | string | `"left"` or `"right"` |
|
||||
| `intersections` | array | Intersection details |
|
||||
|
||||
**Maneuver object schema:**
|
||||
|
||||
| Field | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| `type` | string | No | One of: `depart`, `arrive`, `turn`, `new name`, `merge`, `on ramp`, `off ramp`, `fork`, `end of road`, `continue`, `roundabout`, `rotary`, `roundabout turn`, `notification` |
|
||||
| `modifier` | string | Yes | One of: `uturn`, `sharp right`, `right`, `slight right`, `straight`, `slight left`, `left`, `sharp left` |
|
||||
| `location` | [lon, lat] | No | Coordinate of the maneuver |
|
||||
| `bearing_before` | integer | No | Bearing before the maneuver (0-359) |
|
||||
| `bearing_after` | integer | No | Bearing after the maneuver (0-359) |
|
||||
| `instruction` | string | No | Human-readable instruction text |
|
||||
|
||||
**Waypoint object schema:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `name` | string | Nearest road name to the snapped waypoint |
|
||||
| `location` | [lon, lat] | Snapped coordinate |
|
||||
| `hint` | string | OSRM hint for faster subsequent queries |
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 400 | `INVALID_PARAMETER` | Invalid profile, `alternatives` out of range, invalid `geometries`/`overview` value |
|
||||
| 400 | `INVALID_COORDINATES` | Coordinates out of range, fewer than 2 pairs, more than 7 pairs, malformed format |
|
||||
| 404 | `NOT_FOUND` | OSRM could not find a route (e.g., coordinates on unreachable islands). OSRM code: `"NoRoute"` |
|
||||
| 502 | `UPSTREAM_ERROR` | OSRM returned an unexpected error |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | OSRM instance for the requested profile is unreachable |
|
||||
|
||||
**OSRM error code mapping:**
|
||||
|
||||
| OSRM `code` | HTTP Status | API Error Code |
|
||||
|---|---|---|
|
||||
| `"Ok"` | 200 | — |
|
||||
| `"NoRoute"` | 404 | `NOT_FOUND` |
|
||||
| `"NoSegment"` | 400 | `INVALID_COORDINATES` |
|
||||
| `"TooBig"` | 400 | `INVALID_PARAMETER` |
|
||||
| Other | 502 | `UPSTREAM_ERROR` |
|
||||
|
||||
**Caching:** Route responses are NOT cached (`Cache-Control: no-store`). Routes depend on real-time road graph state, and caching by coordinates would have an extremely low hit rate.
|
||||
|
||||
---
|
||||
|
||||
## 4. Points of Interest
|
||||
|
||||
### 4.1 List POIs in Bounding Box
|
||||
|
||||
Served directly by the Rust gateway from PostGIS.
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /api/pois?bbox={bbox}&category={categories}&limit={limit}&offset={offset}
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Constraints | Description |
|
||||
|---|---|---|---|---|---|
|
||||
| `bbox` | string | Yes | — | `minLon,minLat,maxLon,maxLat`. Valid coordinate ranges. `maxLon > minLon`, `maxLat > minLat`. Max area: 0.25 square degrees (~25km x 25km at equator) | Bounding box to query |
|
||||
| `category` | string | No | — (all categories) | Comma-separated. Valid values: `restaurant`, `cafe`, `shop`, `supermarket`, `pharmacy`, `hospital`, `fuel`, `parking`, `atm`, `public_transport`, `hotel`, `tourist_attraction`, `park` | Category filter |
|
||||
| `limit` | integer | No | 100 | [1, 500] | Maximum results |
|
||||
| `offset` | integer | No | 0 | >= 0 | Pagination offset |
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Cache-Control: public, max-age=300
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [4.9041, 52.3676]
|
||||
},
|
||||
"properties": {
|
||||
"osm_id": 987654321,
|
||||
"osm_type": "N",
|
||||
"name": "Cafe de Jaren",
|
||||
"category": "cafe",
|
||||
"address": {
|
||||
"street": "Nieuwe Doelenstraat",
|
||||
"housenumber": "20",
|
||||
"postcode": "1012 CP",
|
||||
"city": "Amsterdam"
|
||||
},
|
||||
"opening_hours": "Mo-Th 09:30-01:00; Fr-Sa 09:30-02:00; Su 10:00-01:00",
|
||||
"opening_hours_parsed": {
|
||||
"is_open": true,
|
||||
"today": "09:30 - 01:00",
|
||||
"next_change": "Closes at 01:00"
|
||||
},
|
||||
"phone": "+31 20 625 5771",
|
||||
"website": "https://www.cafedejaren.nl",
|
||||
"wheelchair": "yes",
|
||||
"tags": {
|
||||
"cuisine": "dutch",
|
||||
"outdoor_seating": "yes",
|
||||
"internet_access": "wlan"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"total": 247,
|
||||
"limit": 100,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Feature properties schema:**
|
||||
|
||||
| Field | Type | Nullable | Description |
|
||||
|---|---|---|---|
|
||||
| `osm_id` | integer | No | OpenStreetMap element ID |
|
||||
| `osm_type` | string | No | `"N"`, `"W"`, or `"R"` |
|
||||
| `name` | string | No | POI name |
|
||||
| `category` | string | No | Normalized category (one of the valid category values) |
|
||||
| `address` | object | Yes | Address details |
|
||||
| `address.street` | string | Yes | Street name |
|
||||
| `address.housenumber` | string | Yes | House number |
|
||||
| `address.postcode` | string | Yes | Postal code |
|
||||
| `address.city` | string | Yes | City name |
|
||||
| `opening_hours` | string | Yes | Raw OSM `opening_hours` value |
|
||||
| `opening_hours_parsed` | object | Yes | Parsed opening hours (null if `opening_hours` is null or unparseable) |
|
||||
| `opening_hours_parsed.is_open` | boolean | No | Whether the POI is currently open |
|
||||
| `opening_hours_parsed.today` | string | Yes | Today's hours in human-readable format |
|
||||
| `opening_hours_parsed.next_change` | string | Yes | When the open/closed status next changes |
|
||||
| `phone` | string | Yes | Phone number |
|
||||
| `website` | string | Yes | Website URL |
|
||||
| `wheelchair` | string | Yes | `"yes"`, `"no"`, `"limited"`, or null |
|
||||
| `tags` | object | Yes | Additional OSM tags as key-value pairs |
|
||||
|
||||
**Pagination metadata:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `total` | integer | Total number of POIs matching the query in this bbox |
|
||||
| `limit` | integer | Limit used for this request |
|
||||
| `offset` | integer | Offset used for this request |
|
||||
|
||||
To paginate, increment `offset` by `limit` until `offset >= total`.
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 400 | `MISSING_PARAMETER` | `bbox` not provided |
|
||||
| 400 | `INVALID_BBOX` | Malformed bbox, coordinates out of range, min >= max, area exceeds maximum |
|
||||
| 400 | `INVALID_PARAMETER` | Unknown category, `limit` out of range |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | PostGIS is unreachable |
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Get Single POI
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /api/pois/{osm_type}/{osm_id}
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Constraints | Description |
|
||||
|---|---|---|---|---|
|
||||
| `osm_type` | string | Yes | One of: `N`, `W`, `R` | OSM element type |
|
||||
| `osm_id` | integer | Yes | Positive integer | OSM element ID |
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [4.9041, 52.3676]
|
||||
},
|
||||
"properties": {
|
||||
"osm_id": 987654321,
|
||||
"osm_type": "N",
|
||||
"name": "Cafe de Jaren",
|
||||
"category": "cafe",
|
||||
"address": {
|
||||
"street": "Nieuwe Doelenstraat",
|
||||
"housenumber": "20",
|
||||
"postcode": "1012 CP",
|
||||
"city": "Amsterdam"
|
||||
},
|
||||
"opening_hours": "Mo-Th 09:30-01:00; Fr-Sa 09:30-02:00; Su 10:00-01:00",
|
||||
"opening_hours_parsed": {
|
||||
"is_open": true,
|
||||
"today": "09:30 - 01:00",
|
||||
"next_change": "Closes at 01:00"
|
||||
},
|
||||
"phone": "+31 20 625 5771",
|
||||
"website": "https://www.cafedejaren.nl",
|
||||
"wheelchair": "yes",
|
||||
"tags": {
|
||||
"cuisine": "dutch",
|
||||
"outdoor_seating": "yes",
|
||||
"internet_access": "wlan"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Same property schema as Section 4.1 features.
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 400 | `INVALID_PARAMETER` | `osm_type` not one of `N`/`W`/`R`, `osm_id` not a positive integer |
|
||||
| 404 | `NOT_FOUND` | No POI found with the given type and ID |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | PostGIS is unreachable |
|
||||
|
||||
---
|
||||
|
||||
## 5. Offline Data Packages
|
||||
|
||||
### 5.1 List Available Regions
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /api/offline/regions
|
||||
```
|
||||
|
||||
No parameters.
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"regions": [
|
||||
{
|
||||
"id": "amsterdam",
|
||||
"name": "Amsterdam",
|
||||
"description": "Amsterdam metropolitan area",
|
||||
"bbox": [4.7288, 52.2783, 5.0796, 52.4311],
|
||||
"size_mb": 95,
|
||||
"last_updated": "2026-03-25T00:00:00Z",
|
||||
"components": {
|
||||
"tiles_mb": 55,
|
||||
"routing_driving_mb": 12,
|
||||
"routing_walking_mb": 10,
|
||||
"routing_cycling_mb": 8,
|
||||
"pois_mb": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "netherlands",
|
||||
"name": "Netherlands",
|
||||
"description": "Full country coverage",
|
||||
"bbox": [3.3316, 50.7504, 7.2275, 53.4720],
|
||||
"size_mb": 1250,
|
||||
"last_updated": "2026-03-25T00:00:00Z",
|
||||
"components": {
|
||||
"tiles_mb": 650,
|
||||
"routing_driving_mb": 200,
|
||||
"routing_walking_mb": 150,
|
||||
"routing_cycling_mb": 150,
|
||||
"pois_mb": 100
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Region object schema:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | string | Unique region identifier (URL-safe slug) |
|
||||
| `name` | string | Human-readable region name |
|
||||
| `description` | string | Region description |
|
||||
| `bbox` | array | `[minLon, minLat, maxLon, maxLat]` |
|
||||
| `size_mb` | integer | Total download size in megabytes |
|
||||
| `last_updated` | string | ISO 8601 timestamp of last data update |
|
||||
| `components` | object | Size breakdown per component |
|
||||
| `components.tiles_mb` | integer | Tile package size (MB) |
|
||||
| `components.routing_driving_mb` | integer | Driving OSRM data size (MB) |
|
||||
| `components.routing_walking_mb` | integer | Walking OSRM data size (MB) |
|
||||
| `components.routing_cycling_mb` | integer | Cycling OSRM data size (MB) |
|
||||
| `components.pois_mb` | integer | POI database size (MB) |
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 500 | `INTERNAL_ERROR` | Failed to read region metadata |
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Download Region Component
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /api/offline/regions/{region_id}/{component}
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Constraints | Description |
|
||||
|---|---|---|---|---|
|
||||
| `region_id` | string | Yes | Must match an `id` from the regions list | Region identifier |
|
||||
| `component` | string | Yes | One of: `tiles`, `routing-driving`, `routing-walking`, `routing-cycling`, `pois` | Component to download |
|
||||
|
||||
**Request Headers (optional, for resume):**
|
||||
|
||||
| Header | Value | Description |
|
||||
|---|---|---|
|
||||
| `Range` | `bytes={start}-{end}` | Request a byte range for pause/resume |
|
||||
|
||||
**Success Response (full download):**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/octet-stream
|
||||
Content-Length: 57671680
|
||||
Accept-Ranges: bytes
|
||||
Content-Disposition: attachment; filename="amsterdam-tiles.mbtiles"
|
||||
Cache-Control: public, max-age=86400
|
||||
ETag: "region-amsterdam-tiles-20260325"
|
||||
```
|
||||
|
||||
Body: binary file data.
|
||||
|
||||
**Success Response (partial / resumed download):**
|
||||
|
||||
```
|
||||
HTTP/1.1 206 Partial Content
|
||||
Content-Type: application/octet-stream
|
||||
Content-Range: bytes 10485760-57671679/57671680
|
||||
Content-Length: 47185920
|
||||
Accept-Ranges: bytes
|
||||
```
|
||||
|
||||
Body: requested byte range.
|
||||
|
||||
**Downloaded file formats:**
|
||||
|
||||
| Component | File Format | Description |
|
||||
|---|---|---|
|
||||
| `tiles` | MBTiles (SQLite) | Vector tiles for zoom levels 0-16 |
|
||||
| `routing-driving` | Tar archive | OSRM driving profile data files (`.osrm.*`) |
|
||||
| `routing-walking` | Tar archive | OSRM walking profile data files |
|
||||
| `routing-cycling` | Tar archive | OSRM cycling profile data files |
|
||||
| `pois` | SQLite database | POI data with FTS5 search index |
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 400 | `INVALID_PARAMETER` | Invalid component name |
|
||||
| 404 | `NOT_FOUND` | Region ID not found or component not available |
|
||||
| 416 | `INVALID_PARAMETER` | Range not satisfiable |
|
||||
| 500 | `INTERNAL_ERROR` | Failed to read package file |
|
||||
|
||||
---
|
||||
|
||||
## 6. Health Check
|
||||
|
||||
### 6.1 System Health
|
||||
|
||||
**Request:**
|
||||
|
||||
```
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
No parameters.
|
||||
|
||||
**Success Response:**
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Cache-Control: no-store
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"uptime_seconds": 86421,
|
||||
"services": {
|
||||
"martin": {
|
||||
"status": "ok",
|
||||
"latency_ms": 12
|
||||
},
|
||||
"photon": {
|
||||
"status": "ok",
|
||||
"latency_ms": 45
|
||||
},
|
||||
"osrm_driving": {
|
||||
"status": "ok",
|
||||
"latency_ms": 8
|
||||
},
|
||||
"osrm_walking": {
|
||||
"status": "ok",
|
||||
"latency_ms": 7
|
||||
},
|
||||
"osrm_cycling": {
|
||||
"status": "ok",
|
||||
"latency_ms": 9
|
||||
},
|
||||
"postgres": {
|
||||
"status": "ok",
|
||||
"latency_ms": 3
|
||||
},
|
||||
"redis": {
|
||||
"status": "ok",
|
||||
"latency_ms": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Top-level `status` values:**
|
||||
- `"ok"` — all services are healthy.
|
||||
- `"degraded"` — one or more services are unhealthy but the system is partially functional.
|
||||
- `"down"` — critical services (postgres, martin) are unreachable.
|
||||
|
||||
**Per-service `status` values:**
|
||||
- `"ok"` — service responded within timeout.
|
||||
- `"degraded"` — service responded but slowly (> 2x typical latency).
|
||||
- `"down"` — service did not respond within timeout.
|
||||
|
||||
**Note:** The health endpoint always returns `200 OK` regardless of service status. The client should inspect the `status` field to determine overall health. This design allows load balancers to distinguish between "the gateway is running" (HTTP 200) and "upstream services are down" (status: degraded/down).
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Condition |
|
||||
|---|---|---|
|
||||
| 500 | `INTERNAL_ERROR` | The health check itself failed (should not happen in normal operation) |
|
||||
791
docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,791 @@
|
|||
# Architecture Document: Privacy-First Maps Application
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Date:** 2026-03-29
|
||||
**Status:** Ready for development review
|
||||
|
||||
---
|
||||
|
||||
## 1. High-Level Architecture
|
||||
|
||||
```
|
||||
+-----------------------+
|
||||
| Mobile App |
|
||||
| (Flutter / Dart) |
|
||||
| |
|
||||
| +-------+ +--------+ |
|
||||
| |MapLibre| |Drift DB| |
|
||||
| |GL Native| (SQLite)| |
|
||||
| +-------+ +--------+ |
|
||||
+-----------+-----------+
|
||||
|
|
||||
TLS 1.2+ only
|
||||
|
|
||||
+-----------v-----------+
|
||||
| Rust API Gateway |
|
||||
| (Actix-web) |
|
||||
| |
|
||||
| /tiles/* /api/search |
|
||||
| /api/route /api/pois |
|
||||
| /api/offline /api/health
|
||||
+-+------+------+------+-+
|
||||
| | | |
|
||||
+------------+ +---+--+ +-+----+ +----------+
|
||||
| | | | | |
|
||||
+------v------+ +-----v----+ +v------v-+ +--------v--------+
|
||||
| Martin | | Photon | | OSRM | | PostgreSQL |
|
||||
| Tile Server | | Geocoder | | x3 | | + PostGIS |
|
||||
| (Rust) | | (Java) | | profiles | | |
|
||||
+------+------+ +----------+ +----------+ +-----------------+
|
||||
| ^
|
||||
+-------------------------------------------+
|
||||
(reads tile data from PostGIS)
|
||||
|
||||
+-------------------+
|
||||
| Redis |
|
||||
| (response cache) |
|
||||
+-------------------+
|
||||
|
||||
+-----------------------------+
|
||||
| Caddy / nginx |
|
||||
| (TLS termination, |
|
||||
| reverse proxy) |
|
||||
+-----------------------------+
|
||||
```
|
||||
|
||||
**Data flow summary:**
|
||||
1. The mobile app connects exclusively to the Rust API Gateway over TLS.
|
||||
2. The gateway routes requests to the appropriate upstream service.
|
||||
3. Martin reads vector tile data from PostGIS.
|
||||
4. Photon provides geocoding from its own Elasticsearch-backed index (built from OSM/Nominatim).
|
||||
5. OSRM provides routing from preprocessed OSM graph data (3 separate instances).
|
||||
6. The gateway directly queries PostGIS for POI data.
|
||||
7. Redis caches frequently accessed responses (tiles, POI queries, region metadata).
|
||||
|
||||
---
|
||||
|
||||
## 2. Component Responsibilities
|
||||
|
||||
### 2.1 Mobile App (Flutter)
|
||||
- Renders vector map tiles via MapLibre GL Native.
|
||||
- Manages on-device state: search history, favorites, offline regions.
|
||||
- Handles all user interactions: gestures, search, routing, POI browsing.
|
||||
- Implements offline mode using locally stored MBTiles, OSRM data, and POI SQLite databases.
|
||||
- Enforces TLS-only communication; no third-party network calls.
|
||||
|
||||
### 2.2 Rust API Gateway (Actix-web)
|
||||
- Single entry point for all mobile app requests.
|
||||
- Proxies tile requests to Martin.
|
||||
- Proxies search/reverse-geocoding requests to Photon.
|
||||
- Proxies routing requests to the correct OSRM instance based on profile.
|
||||
- Serves POI data directly by querying PostGIS.
|
||||
- Serves offline data packages (tiles, routing data, POI extracts).
|
||||
- Performs input validation, rate limiting, CORS, and health checks.
|
||||
- Implements Redis caching layer.
|
||||
- Logs requests without PII (no IPs, no query strings, no coordinates).
|
||||
|
||||
### 2.3 Martin (Tile Server)
|
||||
- Serves Mapbox Vector Tiles (MVT) from PostGIS.
|
||||
- Serves the `style.json` used by MapLibre GL to configure rendering.
|
||||
- Connected directly to PostGIS; reads from `openmaptiles`-schema tables.
|
||||
|
||||
### 2.4 Photon (Geocoder)
|
||||
- Forward geocoding: text query to coordinates + metadata.
|
||||
- Reverse geocoding: coordinates to address/place.
|
||||
- Stateless: does not log queries.
|
||||
- Backed by Elasticsearch index built from Nominatim/OSM data.
|
||||
|
||||
### 2.5 OSRM (Routing Engine)
|
||||
- Three separate instances, one per profile: `driving`, `walking`, `cycling`.
|
||||
- Each uses a profile-specific graph preprocessed from OSM PBF data.
|
||||
- Returns routes with geometry, distance, duration, and turn-by-turn steps.
|
||||
- Supports alternative routes (up to 3).
|
||||
|
||||
### 2.6 PostgreSQL + PostGIS
|
||||
- Stores OSM-imported geographic data used by Martin for tile generation.
|
||||
- Stores POI data queried directly by the Rust gateway.
|
||||
- Spatial indexes enable efficient bounding-box and proximity queries.
|
||||
|
||||
### 2.7 Redis
|
||||
- Caches tile responses, POI bounding-box query results, and region metadata.
|
||||
- Reduces load on PostGIS and upstream services.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tech Stack Rationale
|
||||
|
||||
| Choice | Rationale |
|
||||
|---|---|
|
||||
| **Flutter** | Single codebase for Android and iOS. Strong ecosystem for maps (`maplibre_gl`). Dart compiles to native ARM, achieving 60fps rendering. Large community reduces hiring risk. |
|
||||
| **MapLibre GL Native** | Open-source fork of Mapbox GL Native. No telemetry, no API key required. Supports MVT vector tiles, client-side styling, and offline MBTiles. The privacy constraint eliminates Mapbox SDK. |
|
||||
| **Riverpod** | Compile-safe, testable state management. Better than `bloc` for this use case because map state is inherently reactive (viewport changes, location updates, search results). Riverpod's provider model fits naturally with dependency injection. |
|
||||
| **Drift** | Type-safe SQLite wrapper for Dart. Supports migrations, DAOs, and complex queries. Better than raw `sqflite` for maintainability. Compiles queries at build time. |
|
||||
| **Dio** | Full-featured HTTP client with interceptor support (for TLS enforcement, logging, caching headers). No third-party interceptors are used. |
|
||||
| **Rust + Actix-web** | Memory-safe, high-performance API gateway. No garbage collection pauses. Actix-web is the fastest Rust web framework by most benchmarks. Async runtime (Tokio) handles thousands of concurrent connections efficiently. Binary deployment (no JVM, no runtime). |
|
||||
| **Martin** | Written in Rust (same ecosystem as the gateway). Serves tiles directly from PostGIS with minimal configuration. Supports MBTiles and PMTiles. Actively maintained. |
|
||||
| **Photon** | Purpose-built for OSM geocoding. Better OSM coverage than Nominatim's API layer. Supports proximity-biased results. Self-hostable with no external dependencies beyond its Elasticsearch index. |
|
||||
| **OSRM** | Industry-standard open-source routing. Sub-second query times for continental distances. Well-documented API. Multiple profile support. Used by many OSM-based apps (known quantity). |
|
||||
| **PostGIS** | The standard for geospatial databases. Mature, well-indexed spatial queries. Native support in Martin. Ecosystem of import tools (`osm2pgsql`, `imposm3`). |
|
||||
| **Redis** | In-memory cache with TTL support. Simple, fast, well-understood. Reduces repeated expensive PostGIS queries. |
|
||||
| **Docker Compose** | Appropriate for self-hosted single-server deployment. All services defined declaratively. Easy for the target audience (self-hosters) to deploy and manage. Kubernetes would be overkill for the typical deployment scenario. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Mobile App Architecture
|
||||
|
||||
### 4.1 Folder Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── main.dart # App entry point, ProviderScope
|
||||
├── app.dart # MaterialApp, router config, theme
|
||||
├── core/
|
||||
│ ├── config/
|
||||
│ │ ├── app_config.dart # Backend URL, defaults
|
||||
│ │ └── env.dart # Environment-specific config
|
||||
│ ├── constants/
|
||||
│ │ ├── map_constants.dart # Zoom levels, default location
|
||||
│ │ └── api_constants.dart # Endpoint paths, timeouts
|
||||
│ ├── error/
|
||||
│ │ ├── app_exception.dart # Base exception hierarchy
|
||||
│ │ ├── error_handler.dart # Global error handling
|
||||
│ │ └── failure.dart # Failure types for Either pattern
|
||||
│ ├── network/
|
||||
│ │ ├── dio_client.dart # Dio instance, TLS config
|
||||
│ │ ├── api_interceptor.dart # Request/response logging (no PII)
|
||||
│ │ └── connectivity.dart # Online/offline detection
|
||||
│ ├── theme/
|
||||
│ │ ├── app_theme.dart # Day/night Material themes
|
||||
│ │ ├── map_styles.dart # MapLibre style JSON references
|
||||
│ │ └── colors.dart
|
||||
│ └── utils/
|
||||
│ ├── debouncer.dart
|
||||
│ ├── coordinate_utils.dart
|
||||
│ └── opening_hours_parser.dart
|
||||
├── features/
|
||||
│ ├── map/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── repositories/
|
||||
│ │ │ │ └── tile_repository.dart
|
||||
│ │ │ └── datasources/
|
||||
│ │ │ ├── tile_remote_source.dart
|
||||
│ │ │ └── tile_cache_source.dart # MBTiles SQLite
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── map_position.dart
|
||||
│ │ │ │ └── map_marker.dart
|
||||
│ │ │ └── repositories/
|
||||
│ │ │ └── tile_repository.dart # Abstract interface
|
||||
│ │ ├── presentation/
|
||||
│ │ │ ├── providers/
|
||||
│ │ │ │ ├── map_controller_provider.dart
|
||||
│ │ │ │ ├── location_provider.dart
|
||||
│ │ │ │ └── theme_provider.dart
|
||||
│ │ │ ├── widgets/
|
||||
│ │ │ │ ├── map_view.dart
|
||||
│ │ │ │ ├── compass_button.dart
|
||||
│ │ │ │ ├── zoom_controls.dart
|
||||
│ │ │ │ ├── attribution_widget.dart
|
||||
│ │ │ │ └── location_button.dart
|
||||
│ │ │ └── screens/
|
||||
│ │ │ └── map_screen.dart
|
||||
│ │ └── map_providers.dart # Feature-level provider definitions
|
||||
│ ├── search/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── repositories/
|
||||
│ │ │ │ └── search_repository.dart
|
||||
│ │ │ ├── datasources/
|
||||
│ │ │ │ ├── photon_remote_source.dart
|
||||
│ │ │ │ ├── search_history_local_source.dart
|
||||
│ │ │ │ └── offline_search_source.dart
|
||||
│ │ │ └── models/
|
||||
│ │ │ └── search_result_dto.dart
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── search_result.dart
|
||||
│ │ │ │ └── search_history_item.dart
|
||||
│ │ │ └── repositories/
|
||||
│ │ │ └── search_repository.dart
|
||||
│ │ ├── presentation/
|
||||
│ │ │ ├── providers/
|
||||
│ │ │ │ ├── search_provider.dart
|
||||
│ │ │ │ └── search_history_provider.dart
|
||||
│ │ │ ├── widgets/
|
||||
│ │ │ │ ├── search_bar.dart
|
||||
│ │ │ │ ├── search_result_tile.dart
|
||||
│ │ │ │ └── recent_searches_list.dart
|
||||
│ │ │ └── screens/
|
||||
│ │ │ └── search_screen.dart
|
||||
│ │ └── search_providers.dart
|
||||
│ ├── routing/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── repositories/
|
||||
│ │ │ │ └── routing_repository.dart
|
||||
│ │ │ ├── datasources/
|
||||
│ │ │ │ ├── osrm_remote_source.dart
|
||||
│ │ │ │ └── offline_routing_source.dart
|
||||
│ │ │ └── models/
|
||||
│ │ │ └── route_dto.dart
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── route.dart
|
||||
│ │ │ │ ├── route_step.dart
|
||||
│ │ │ │ ├── maneuver.dart
|
||||
│ │ │ │ └── route_profile.dart
|
||||
│ │ │ └── repositories/
|
||||
│ │ │ └── routing_repository.dart
|
||||
│ │ ├── presentation/
|
||||
│ │ │ ├── providers/
|
||||
│ │ │ │ ├── routing_provider.dart
|
||||
│ │ │ │ ├── navigation_provider.dart
|
||||
│ │ │ │ └── reroute_provider.dart
|
||||
│ │ │ ├── widgets/
|
||||
│ │ │ │ ├── route_summary_card.dart
|
||||
│ │ │ │ ├── turn_instruction.dart
|
||||
│ │ │ │ ├── profile_selector.dart
|
||||
│ │ │ │ └── route_line_layer.dart
|
||||
│ │ │ └── screens/
|
||||
│ │ │ ├── directions_screen.dart
|
||||
│ │ │ └── navigation_screen.dart
|
||||
│ │ └── routing_providers.dart
|
||||
│ ├── pois/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── repositories/
|
||||
│ │ │ │ └── poi_repository.dart
|
||||
│ │ │ ├── datasources/
|
||||
│ │ │ │ ├── poi_remote_source.dart
|
||||
│ │ │ │ └── poi_local_source.dart
|
||||
│ │ │ └── models/
|
||||
│ │ │ └── poi_dto.dart
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── poi.dart
|
||||
│ │ │ │ └── poi_category.dart
|
||||
│ │ │ └── repositories/
|
||||
│ │ │ └── poi_repository.dart
|
||||
│ │ ├── presentation/
|
||||
│ │ │ ├── providers/
|
||||
│ │ │ │ └── poi_provider.dart
|
||||
│ │ │ ├── widgets/
|
||||
│ │ │ │ ├── poi_marker_layer.dart
|
||||
│ │ │ │ ├── place_card.dart
|
||||
│ │ │ │ └── opening_hours_display.dart
|
||||
│ │ │ └── screens/
|
||||
│ │ │ └── poi_detail_screen.dart
|
||||
│ │ └── poi_providers.dart
|
||||
│ ├── favorites/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── repositories/
|
||||
│ │ │ │ └── favorites_repository.dart
|
||||
│ │ │ └── datasources/
|
||||
│ │ │ └── favorites_local_source.dart
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── favorite.dart
|
||||
│ │ │ │ └── favorite_group.dart
|
||||
│ │ │ └── repositories/
|
||||
│ │ │ └── favorites_repository.dart
|
||||
│ │ ├── presentation/
|
||||
│ │ │ ├── providers/
|
||||
│ │ │ │ └── favorites_provider.dart
|
||||
│ │ │ ├── widgets/
|
||||
│ │ │ │ ├── favorite_list_tile.dart
|
||||
│ │ │ │ └── save_favorite_dialog.dart
|
||||
│ │ │ └── screens/
|
||||
│ │ │ └── favorites_screen.dart
|
||||
│ │ └── favorites_providers.dart
|
||||
│ ├── offline/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── repositories/
|
||||
│ │ │ │ └── offline_repository.dart
|
||||
│ │ │ └── datasources/
|
||||
│ │ │ ├── offline_remote_source.dart
|
||||
│ │ │ └── offline_local_source.dart
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── offline_region.dart
|
||||
│ │ │ │ └── download_progress.dart
|
||||
│ │ │ └── repositories/
|
||||
│ │ │ └── offline_repository.dart
|
||||
│ │ ├── presentation/
|
||||
│ │ │ ├── providers/
|
||||
│ │ │ │ ├── offline_regions_provider.dart
|
||||
│ │ │ │ └── download_manager_provider.dart
|
||||
│ │ │ ├── widgets/
|
||||
│ │ │ │ ├── region_list_tile.dart
|
||||
│ │ │ │ ├── download_progress_bar.dart
|
||||
│ │ │ │ └── region_selector_map.dart
|
||||
│ │ │ └── screens/
|
||||
│ │ │ └── offline_maps_screen.dart
|
||||
│ │ └── offline_providers.dart
|
||||
│ ├── sharing/
|
||||
│ │ └── presentation/
|
||||
│ │ ├── providers/
|
||||
│ │ │ └── share_provider.dart
|
||||
│ │ └── widgets/
|
||||
│ │ └── share_sheet.dart
|
||||
│ └── settings/
|
||||
│ ├── presentation/
|
||||
│ │ ├── providers/
|
||||
│ │ │ └── settings_provider.dart
|
||||
│ │ └── screens/
|
||||
│ │ ├── settings_screen.dart
|
||||
│ │ └── about_screen.dart
|
||||
│ └── settings_providers.dart
|
||||
├── database/
|
||||
│ ├── app_database.dart # Drift database definition
|
||||
│ ├── tables/
|
||||
│ │ ├── search_history_table.dart
|
||||
│ │ ├── favorites_table.dart
|
||||
│ │ ├── favorite_groups_table.dart
|
||||
│ │ └── offline_regions_table.dart
|
||||
│ └── daos/
|
||||
│ ├── search_history_dao.dart
|
||||
│ ├── favorites_dao.dart
|
||||
│ └── offline_regions_dao.dart
|
||||
└── router/
|
||||
└── app_router.dart # GoRouter or declarative routing
|
||||
```
|
||||
|
||||
### 4.2 State Management (Riverpod)
|
||||
|
||||
The app uses Riverpod with the following provider hierarchy:
|
||||
|
||||
```
|
||||
Core Providers (app-wide):
|
||||
├── dioClientProvider → Dio instance (singleton)
|
||||
├── databaseProvider → Drift AppDatabase (singleton)
|
||||
├── connectivityProvider → Stream<bool> online/offline
|
||||
├── locationProvider → Stream<Position> from platform
|
||||
└── settingsProvider → User preferences (theme, units, cache size)
|
||||
|
||||
Feature Providers (scoped per feature):
|
||||
├── Map:
|
||||
│ ├── mapControllerProvider → MapLibre controller
|
||||
│ ├── mapPositionProvider → Current viewport (center, zoom, bearing)
|
||||
│ └── themeProvider → Active map style (day/night/terrain)
|
||||
├── Search:
|
||||
│ ├── searchQueryProvider → StateProvider<String>
|
||||
│ ├── searchResultsProvider → FutureProvider (debounced, calls repository)
|
||||
│ └── searchHistoryProvider → StreamProvider (from Drift DAO)
|
||||
├── Routing:
|
||||
│ ├── routeRequestProvider → StateProvider<RouteRequest?>
|
||||
│ ├── routeResultsProvider → FutureProvider (calls OSRM)
|
||||
│ ├── selectedRouteProvider → StateProvider<int> (selected alternative index)
|
||||
│ └── navigationStateProvider → StateNotifierProvider (active navigation)
|
||||
├── POIs:
|
||||
│ ├── visiblePoisProvider → FutureProvider (bbox-based, auto-refreshes on viewport change)
|
||||
│ └── selectedPoiProvider → StateProvider<Poi?>
|
||||
├── Favorites:
|
||||
│ ├── favoritesListProvider → StreamProvider (from Drift DAO)
|
||||
│ └── favoriteGroupsProvider → StreamProvider
|
||||
└── Offline:
|
||||
├── availableRegionsProvider → FutureProvider (from backend)
|
||||
├── downloadedRegionsProvider → StreamProvider (from Drift DAO)
|
||||
└── activeDownloadsProvider → StateNotifierProvider (download manager)
|
||||
```
|
||||
|
||||
**Key patterns:**
|
||||
- `FutureProvider` for one-shot async data fetches.
|
||||
- `StreamProvider` for reactive local data (Drift streams).
|
||||
- `StateNotifierProvider` for complex mutable state (navigation, downloads).
|
||||
- `Provider` for computed/derived values.
|
||||
- Feature providers use `ref.watch` to react to upstream changes (e.g., `visiblePoisProvider` watches `mapPositionProvider`).
|
||||
|
||||
### 4.3 Navigation
|
||||
|
||||
Use `go_router` for declarative navigation:
|
||||
|
||||
```
|
||||
/ → MapScreen (root)
|
||||
/search → SearchScreen (overlay on map)
|
||||
/directions → DirectionsScreen (origin/destination input)
|
||||
/directions/navigate → NavigationScreen (active turn-by-turn)
|
||||
/poi/:osmType/:osmId → POI detail (bottom sheet on map)
|
||||
/favorites → FavoritesScreen
|
||||
/settings → SettingsScreen
|
||||
/settings/offline → OfflineMapsScreen
|
||||
/settings/about → AboutScreen
|
||||
```
|
||||
|
||||
The map remains persistent underneath all routes using a `ShellRoute` with the map as the shell. Search, POI details, and directions overlay the map as bottom sheets or panels.
|
||||
|
||||
### 4.4 Dependency Injection
|
||||
|
||||
Riverpod serves as the DI container. All dependencies are defined as providers:
|
||||
|
||||
```dart
|
||||
// Core
|
||||
final dioClientProvider = Provider<Dio>((ref) => createDioClient(ref));
|
||||
final databaseProvider = Provider<AppDatabase>((ref) => AppDatabase());
|
||||
|
||||
// Repositories (depend on data sources)
|
||||
final searchRepositoryProvider = Provider<SearchRepository>((ref) {
|
||||
return SearchRepositoryImpl(
|
||||
remote: ref.watch(photonRemoteSourceProvider),
|
||||
local: ref.watch(searchHistoryLocalSourceProvider),
|
||||
offline: ref.watch(offlineSearchSourceProvider),
|
||||
connectivity: ref.watch(connectivityProvider),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
This makes testing straightforward: override any provider in tests with a mock.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend Architecture (Rust / Actix-web)
|
||||
|
||||
### 5.1 Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs # Server bootstrap, service wiring
|
||||
│ ├── config.rs # Configuration from env vars
|
||||
│ ├── routes/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── tiles.rs # GET /tiles/{layer}/{z}/{x}/{y}.pbf
|
||||
│ │ ├── search.rs # GET /api/search, GET /api/reverse
|
||||
│ │ ├── routing.rs # GET /api/route/{profile}/{coordinates}
|
||||
│ │ ├── pois.rs # GET /api/pois, GET /api/pois/{type}/{id}
|
||||
│ │ ├── offline.rs # GET /api/offline/regions, downloads
|
||||
│ │ └── health.rs # GET /api/health
|
||||
│ ├── services/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── martin_proxy.rs # HTTP proxy to Martin
|
||||
│ │ ├── photon_proxy.rs # HTTP proxy to Photon
|
||||
│ │ ├── osrm_proxy.rs # HTTP proxy to OSRM instances
|
||||
│ │ ├── poi_service.rs # PostGIS POI queries
|
||||
│ │ ├── offline_service.rs # Region package management
|
||||
│ │ └── health_service.rs # Upstream health checks
|
||||
│ ├── middleware/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── cors.rs # CORS configuration
|
||||
│ │ ├── request_logger.rs # Structured logging (no PII)
|
||||
│ │ ├── rate_limiter.rs # Token bucket per IP (IP not logged)
|
||||
│ │ └── input_validator.rs # Coordinate range checks, string sanitization
|
||||
│ ├── cache/
|
||||
│ │ ├── mod.rs
|
||||
│ │ └── redis_cache.rs # Redis get/set with TTL
|
||||
│ ├── models/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── poi.rs # POI structs, GeoJSON serialization
|
||||
│ │ ├── region.rs # Offline region metadata
|
||||
│ │ └── error.rs # Error types and API error response
|
||||
│ └── db/
|
||||
│ ├── mod.rs
|
||||
│ └── postgres.rs # Connection pool (deadpool-postgres), spatial queries
|
||||
├── migrations/
|
||||
│ └── 001_create_pois.sql
|
||||
└── tests/
|
||||
├── integration/
|
||||
└── common/
|
||||
```
|
||||
|
||||
### 5.2 Route Configuration
|
||||
|
||||
```rust
|
||||
// Simplified Actix-web route configuration
|
||||
App::new()
|
||||
.wrap(middleware::cors())
|
||||
.wrap(middleware::request_logger())
|
||||
.wrap(middleware::rate_limiter())
|
||||
// Tiles (proxied to Martin)
|
||||
.route("/tiles/{layer}/{z}/{x}/{y}.pbf", web::get().to(tiles::get_tile))
|
||||
.route("/tiles/style.json", web::get().to(tiles::get_style))
|
||||
// Search (proxied to Photon)
|
||||
.route("/api/search", web::get().to(search::search))
|
||||
.route("/api/reverse", web::get().to(search::reverse))
|
||||
// Routing (proxied to OSRM)
|
||||
.route("/api/route/{profile}/{coordinates}", web::get().to(routing::route))
|
||||
// POIs (direct PostGIS queries)
|
||||
.route("/api/pois", web::get().to(pois::list_pois))
|
||||
.route("/api/pois/{osm_type}/{osm_id}", web::get().to(pois::get_poi))
|
||||
// Offline
|
||||
.route("/api/offline/regions", web::get().to(offline::list_regions))
|
||||
.route("/api/offline/regions/{id}/{component}", web::get().to(offline::download_component))
|
||||
// Health
|
||||
.route("/api/health", web::get().to(health::check))
|
||||
```
|
||||
|
||||
### 5.3 Middleware
|
||||
|
||||
**CORS:**
|
||||
- Allow all origins (the mobile app's origin varies by deployment).
|
||||
- Allow methods: `GET`, `HEAD`, `OPTIONS`.
|
||||
- Allow headers: `Content-Type`, `Accept`, `Accept-Encoding`, `Range`.
|
||||
- `Access-Control-Max-Age: 86400`.
|
||||
|
||||
**Request Logger:**
|
||||
- Logs: HTTP method, path (without query string), response status, response time in ms.
|
||||
- Does NOT log: query parameters, client IP, request body, `User-Agent`, cookies.
|
||||
- Uses `tracing` crate with structured JSON output.
|
||||
- Log format: `{"method":"GET","path":"/api/pois","status":200,"duration_ms":42}`
|
||||
|
||||
**Rate Limiter:**
|
||||
- Token bucket algorithm, keyed by client IP (IP used only for bucket lookup, never logged or stored).
|
||||
- Default: 100 requests/second per IP, burst of 200.
|
||||
- Tile requests: 500 requests/second (tiles are cheap to serve from cache).
|
||||
- Returns `429 Too Many Requests` with `Retry-After` header when exceeded.
|
||||
|
||||
**Input Validator:**
|
||||
- Validates coordinate ranges: latitude [-90, 90], longitude [-180, 180].
|
||||
- Validates zoom levels: [0, 18].
|
||||
- Validates `limit` parameters: enforces maximum values.
|
||||
- Sanitizes string inputs: max length 500 characters for search queries, strip control characters.
|
||||
- Returns `400 Bad Request` with error details on validation failure.
|
||||
|
||||
### 5.4 Health Checks
|
||||
|
||||
The `/api/health` endpoint actively probes each upstream service:
|
||||
|
||||
| Service | Check | Timeout |
|
||||
|---|---|---|
|
||||
| Martin | `GET /health` on Martin's internal port | 2s |
|
||||
| Photon | `GET /api/search?q=test&limit=1` | 3s |
|
||||
| OSRM (driving) | `GET /route/v1/driving/0,0;0.001,0.001?overview=false` | 3s |
|
||||
| OSRM (walking) | Same pattern | 3s |
|
||||
| OSRM (cycling) | Same pattern | 3s |
|
||||
| PostgreSQL | `SELECT 1` | 2s |
|
||||
|
||||
If any service is down, the endpoint still returns `200 OK` but with that service's status as `"degraded"` or `"down"`. This allows the mobile app to show degraded-mode indicators.
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment Architecture (Docker Compose)
|
||||
|
||||
### 6.1 Topology
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (structural overview)
|
||||
services:
|
||||
gateway: # Rust API gateway port 8080 (internal)
|
||||
martin: # Tile server port 3000 (internal)
|
||||
photon: # Geocoder port 2322 (internal)
|
||||
osrm-driving: # OSRM driving profile port 5001 (internal)
|
||||
osrm-walking: # OSRM walking profile port 5002 (internal)
|
||||
osrm-cycling: # OSRM cycling profile port 5003 (internal)
|
||||
postgres: # PostgreSQL + PostGIS port 5432 (internal)
|
||||
redis: # Cache port 6379 (internal)
|
||||
caddy: # Reverse proxy + TLS ports 80, 443 (external)
|
||||
```
|
||||
|
||||
### 6.2 Networking
|
||||
|
||||
- All services are on a single Docker bridge network (`maps_net`).
|
||||
- Only `caddy` exposes ports to the host (80 for HTTPS redirect, 443 for TLS).
|
||||
- The gateway communicates with upstream services by Docker DNS names (`martin:3000`, `photon:2322`, etc.).
|
||||
- No service except `caddy` is reachable from outside the Docker network.
|
||||
|
||||
### 6.3 Volumes
|
||||
|
||||
| Volume | Service | Purpose |
|
||||
|---|---|---|
|
||||
| `pg_data` | postgres | PostgreSQL data directory (persistent) |
|
||||
| `martin_config` | martin | Martin configuration file |
|
||||
| `photon_data` | photon | Photon/Elasticsearch index (persistent, ~2-20 GB depending on coverage) |
|
||||
| `osrm_data` | osrm-* | Preprocessed OSRM graph files (persistent, per profile) |
|
||||
| `redis_data` | redis | Redis AOF/RDB persistence (optional, cache can be cold-started) |
|
||||
| `offline_packages` | gateway | Pre-built offline region packages served to mobile clients |
|
||||
| `caddy_data` | caddy | TLS certificates (Let's Encrypt auto-provisioned) |
|
||||
| `caddy_config` | caddy | Caddy configuration |
|
||||
|
||||
### 6.4 Resource Allocation (for Netherlands-scale deployment)
|
||||
|
||||
| Service | CPU Limit | Memory Limit |
|
||||
|---|---|---|
|
||||
| gateway | 1 core | 256 MB |
|
||||
| martin | 1 core | 512 MB |
|
||||
| photon | 2 cores | 2 GB |
|
||||
| osrm-driving | 1 core | 1.5 GB |
|
||||
| osrm-walking | 0.5 core | 1 GB |
|
||||
| osrm-cycling | 0.5 core | 1 GB |
|
||||
| postgres | 2 cores | 2 GB |
|
||||
| redis | 0.5 core | 256 MB |
|
||||
| caddy | 0.5 core | 128 MB |
|
||||
| **Total** | **~9 cores** | **~8.5 GB** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Offline Architecture
|
||||
|
||||
### 7.1 Overview
|
||||
|
||||
Offline support has three pillars, each with its own storage format and data pipeline:
|
||||
|
||||
```
|
||||
+-------------------------------------------+
|
||||
| Mobile Device Storage |
|
||||
| |
|
||||
| +-------------+ +-----------+ +-------+ |
|
||||
| | MBTiles | | OSRM | | POI | |
|
||||
| | (tiles.db) | | .osrm.* | | .db | |
|
||||
| | SQLite | | files | | SQLite| |
|
||||
| +-------------+ +-----------+ +-------+ |
|
||||
+-------------------------------------------+
|
||||
```
|
||||
|
||||
### 7.2 Offline Tiles
|
||||
|
||||
- Format: MBTiles (SQLite database with tile data as blobs).
|
||||
- The backend pre-generates MBTiles files per region using Martin's `mbtiles` tool or `tilelive`.
|
||||
- Zoom levels 0-16 are included (level 17-18 excluded to reduce size).
|
||||
- On the device, MapLibre GL Native is configured with a composite tile source:
|
||||
1. First, check the offline MBTiles database for the requested tile.
|
||||
2. If not found, check the LRU tile cache (also MBTiles format).
|
||||
3. If not found and online, fetch from the backend and store in cache.
|
||||
|
||||
### 7.3 Offline Routing
|
||||
|
||||
- OSRM data files (`.osrm`, `.osrm.cell_metrics`, `.osrm.partition`, etc.) are downloaded per profile per region.
|
||||
- On the device, routing is performed using a compiled OSRM library accessed via Dart FFI (Foreign Function Interface) bindings to the OSRM C++ library.
|
||||
- The FFI binding exposes a minimal interface: `route(profile_data_path, coordinates) -> RouteResult`.
|
||||
- Fallback: if FFI integration proves too complex for v1.0, the app can use pre-calculated route graphs in a simplified Dart-native implementation (Dijkstra on a simplified road graph stored in SQLite). This is a degraded experience but ensures offline routing works.
|
||||
|
||||
### 7.4 Offline Search
|
||||
|
||||
- POI data is downloaded as a SQLite database per region, containing the same schema as the PostGIS `pois` table.
|
||||
- Offline search uses SQLite FTS5 (full-text search) on the `name` and `address` columns.
|
||||
- Results are ranked by FTS5 relevance score, with proximity to the viewport center as a tiebreaker (using the Haversine formula in SQL).
|
||||
|
||||
### 7.5 Download Manager
|
||||
|
||||
- Downloads are managed by a background isolate (Dart isolate) to prevent blocking the UI.
|
||||
- Each region download consists of multiple sequential component downloads (tiles, routing x3, POIs).
|
||||
- Supports HTTP `Range` headers for pause/resume.
|
||||
- Progress is tracked per component and aggregated per region.
|
||||
- Downloads persist across app restarts by storing state in Drift (download URL, bytes received, total bytes).
|
||||
|
||||
---
|
||||
|
||||
## 8. Privacy Architecture
|
||||
|
||||
Each privacy commitment from the spec is enforced as follows:
|
||||
|
||||
| Commitment | Technical Enforcement |
|
||||
|---|---|
|
||||
| **No accounts** | No authentication middleware on backend. No session tokens, cookies, or `Authorization` headers. No user table in the database. |
|
||||
| **No telemetry** | CI pipeline runs `dart pub deps --json` and rejects any dependency matching a deny-list (Firebase, Sentry, Amplitude, etc.). Static analysis scans compiled binary for known analytics domain strings. |
|
||||
| **No third-party network calls** | Dio base URL is the single configured backend URL. Dio interceptor rejects any request not matching the base URL prefix. CI step: `strings` on the compiled APK/IPA, grep for known third-party domains — fail if any found. |
|
||||
| **On-device history** | Search history, favorites, and offline region metadata are stored in Drift (SQLite). No provider or repository ever sends this data over the network. Code review enforced. |
|
||||
| **Self-hosted backend** | Docker Compose includes all services. No SaaS API keys in configuration. Backend makes zero outbound network calls (all data is local). |
|
||||
| **Auditable** | Open-source. CI publishes dependency tree. Network monitor-friendly (single backend domain). |
|
||||
| **No PII logging (backend)** | `request_logger` middleware logs only: method, path (without query params), status, duration. The `tracing` subscriber is configured to redact any field named `ip`, `query`, `user_agent`, `coordinates`. |
|
||||
| **On-device encryption** | Android: database files stored in app-internal storage (encrypted by default on Android 10+; on Android 8-9, use `EncryptedSharedPreferences` for the SQLite encryption key with `SQLCipher`). iOS: files stored with `NSFileProtectionComplete` attribute (encrypted until first unlock). |
|
||||
| **TLS only** | Dio configured with `baseUrl` starting with `https://`. A custom `SecurityContext` rejects plaintext. On the backend, Caddy enforces HTTPS and redirects HTTP to HTTPS. HSTS header set. |
|
||||
| **No device fingerprinting** | No code reads IMEI, advertising ID, MAC address, or serial number. CI lint rule: any import of `device_info_plus`, `android_id`, or similar packages fails the build. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Error Handling Strategy
|
||||
|
||||
### 9.1 Mobile App
|
||||
|
||||
**Network errors:**
|
||||
- Connection timeout / no connectivity: switch to offline mode automatically. Show "Offline mode" banner. Serve tiles from cache, search from local DB, routing from local OSRM data (if available).
|
||||
- HTTP 429 (rate limited): retry after `Retry-After` duration with exponential backoff (max 3 retries).
|
||||
- HTTP 5xx: show a non-intrusive snackbar ("Service temporarily unavailable"). Retry with exponential backoff.
|
||||
- HTTP 404 (tile): render blank tile area, do not retry.
|
||||
|
||||
**Service-specific degradation:**
|
||||
| Service Down | User Experience |
|
||||
|---|---|
|
||||
| Martin (tiles) | Map shows cached tiles only. Uncached areas are blank. Banner: "Some map areas unavailable." |
|
||||
| Photon (search) | Search falls back to offline POI database (if downloaded). Otherwise: "Search unavailable. Try offline maps." |
|
||||
| OSRM (routing) | Routing falls back to offline OSRM data (if downloaded). Otherwise: "Routing unavailable." |
|
||||
| PostGIS (POIs) | POI markers not shown. POI detail returns "Details unavailable." |
|
||||
| Entire backend | Full offline mode. All features work if region is downloaded. Otherwise: cached tiles only, no search, no routing. |
|
||||
|
||||
**Local errors:**
|
||||
- SQLite corruption: detect on open, offer to clear cache (favorites are backed up separately).
|
||||
- Disk full: warn user before download starts (check 80% threshold). If cache is full, LRU eviction runs automatically.
|
||||
|
||||
### 9.2 Backend API
|
||||
|
||||
All error responses use a standard format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "INVALID_BBOX",
|
||||
"message": "Bounding box coordinates are out of valid range."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Error code mapping:
|
||||
|
||||
| HTTP Status | Code | When |
|
||||
|---|---|---|
|
||||
| 400 | `INVALID_PARAMETER` | Query parameter fails validation |
|
||||
| 400 | `INVALID_BBOX` | Bounding box coordinates out of range |
|
||||
| 400 | `INVALID_COORDINATES` | Route coordinates out of range |
|
||||
| 400 | `MISSING_PARAMETER` | Required parameter missing |
|
||||
| 404 | `NOT_FOUND` | POI, tile, or region not found |
|
||||
| 429 | `RATE_LIMITED` | Client exceeded rate limit |
|
||||
| 502 | `UPSTREAM_ERROR` | Martin/Photon/OSRM returned an error |
|
||||
| 503 | `SERVICE_UNAVAILABLE` | Upstream service is down |
|
||||
| 500 | `INTERNAL_ERROR` | Unexpected server error |
|
||||
|
||||
The gateway never exposes internal error details (stack traces, database errors) to the client.
|
||||
|
||||
---
|
||||
|
||||
## 10. Security
|
||||
|
||||
### 10.1 TLS
|
||||
|
||||
- Caddy auto-provisions TLS certificates via Let's Encrypt (ACME).
|
||||
- Minimum TLS version: 1.2. Preferred: 1.3.
|
||||
- HSTS header: `Strict-Transport-Security: max-age=63072000; includeSubDomains`.
|
||||
- HTTP requests to port 80 are 301-redirected to HTTPS.
|
||||
- Internal Docker network communication is plaintext (acceptable: all services are on the same host, in the same Docker network, not exposed externally).
|
||||
|
||||
### 10.2 No Authentication (by design)
|
||||
|
||||
- The backend has no authentication mechanism. This is intentional.
|
||||
- The backend is meant to be deployed on a private network or behind a VPN/firewall.
|
||||
- If public exposure is needed, the deployer can add HTTP Basic Auth or client certificates at the Caddy layer. This is documented but not built into the application.
|
||||
|
||||
### 10.3 Input Validation
|
||||
|
||||
All user-supplied input is validated at the gateway before proxying:
|
||||
|
||||
| Input | Validation |
|
||||
|---|---|
|
||||
| Zoom level (`z`) | Integer, [0, 18] |
|
||||
| Tile coordinates (`x`, `y`) | Integer, >= 0, <= 2^z - 1 |
|
||||
| Latitude | Float, [-90.0, 90.0] |
|
||||
| Longitude | Float, [-180.0, 180.0] |
|
||||
| Bounding box | 4 floats, valid lat/lon, min < max |
|
||||
| Search query (`q`) | String, max 500 chars, control chars stripped |
|
||||
| `limit` | Integer, [1, max_for_endpoint] |
|
||||
| Routing profile | Enum: `driving`, `walking`, `cycling` |
|
||||
| Coordinates (routing) | 2-7 coordinate pairs (origin + up to 5 waypoints + destination) |
|
||||
| OSM type | Enum: `N`, `W`, `R` |
|
||||
| OSM ID | Positive integer |
|
||||
| `lang` | 2-char ISO 639-1, validated against allow-list |
|
||||
|
||||
### 10.4 Rate Limiting
|
||||
|
||||
- Applied per source IP using an in-memory token bucket (not stored in Redis to avoid logging IPs).
|
||||
- Default limits (configurable via environment variables):
|
||||
- Tile requests: 500/s per IP, burst 1000.
|
||||
- API requests: 100/s per IP, burst 200.
|
||||
- Returns `429` with `Retry-After` header.
|
||||
- The rate limiter state is ephemeral (lost on restart), which is acceptable.
|
||||
|
||||
### 10.5 Dependency Security
|
||||
|
||||
- Backend: `cargo audit` runs in CI to detect known vulnerabilities in Rust dependencies.
|
||||
- Mobile: `dart pub outdated` and manual review of transitive dependencies.
|
||||
- No native dependencies beyond MapLibre GL Native and (optionally) OSRM FFI.
|
||||
- Docker images use minimal base images (`rust:slim` for build, `debian:bookworm-slim` for runtime).
|
||||
|
||||
### 10.6 Content Security
|
||||
|
||||
- Martin tiles are binary protobuf; no injection risk.
|
||||
- Photon and OSRM responses are validated JSON. The gateway re-serializes POI responses from PostGIS to prevent SQL injection artifacts from reaching the client.
|
||||
- The gateway uses parameterized queries (via `tokio-postgres` / `sqlx`) for all PostGIS access. No string interpolation in SQL.
|
||||
1329
docs/DATA_MODEL.md
Normal file
847
docs/SPECS.md
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
# Product Specification: Privacy-First Maps Application
|
||||
|
||||
**Version:** 1.0.0-draft
|
||||
**Date:** 2026-03-29
|
||||
**Status:** Ready for architecture & development review
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Product Vision & Differentiators](#1-product-vision--differentiators)
|
||||
2. [Core Features](#2-core-features)
|
||||
3. [User Flows & Acceptance Criteria](#3-user-flows--acceptance-criteria)
|
||||
4. [Non-Functional Requirements](#4-non-functional-requirements)
|
||||
5. [API Contract Outlines](#5-api-contract-outlines)
|
||||
6. [Data Sources & Licensing](#6-data-sources--licensing)
|
||||
7. [Tech Stack Summary](#7-tech-stack-summary)
|
||||
|
||||
---
|
||||
|
||||
## 1. Product Vision & Differentiators
|
||||
|
||||
### 1.1 Vision Statement
|
||||
|
||||
A fully-featured maps and navigation application that provides the utility users expect from Google Maps while making an absolute guarantee: **the application never transmits, stores, or processes any user data outside the user's own device and their own self-hosted backend.**
|
||||
|
||||
### 1.2 Privacy-First Philosophy — Concrete Commitments
|
||||
|
||||
The term "privacy-first" is defined by the following non-negotiable constraints:
|
||||
|
||||
| Commitment | Implementation |
|
||||
|---|---|
|
||||
| **No accounts** | The app has no sign-up, login, or authentication flow. There is no user identity. |
|
||||
| **No telemetry** | Zero analytics SDKs, no crash reporting services, no usage metrics sent anywhere. |
|
||||
| **No third-party network calls** | Every network request the app makes goes to the user's own self-hosted backend. The app binary contains no hardcoded URLs to Google, Apple, Facebook, Sentry, Firebase, Mapbox, or any other third-party service. |
|
||||
| **On-device history** | Search history, recent routes, and favorites are stored in a local SQLite database on the device. They are never transmitted over the network. |
|
||||
| **Self-hosted backend** | Tile serving (Martin), geocoding (Photon), and routing (OSRM) all run on infrastructure the deployer controls. No SaaS dependencies. |
|
||||
| **Auditable** | The application is open-source. Any user can verify the above claims by inspecting the codebase and monitoring network traffic. |
|
||||
|
||||
### 1.3 Competitive Comparison
|
||||
|
||||
| Capability | Google Maps | Apple Maps | OsmAnd | **This App** |
|
||||
|---|---|---|---|---|
|
||||
| Account required | Yes (for full features) | Apple ID | No | **No** |
|
||||
| Tracks location history | Yes (opt-out) | Yes (opt-out) | No | **No** |
|
||||
| Analytics/telemetry | Extensive | Moderate | Minimal | **None** |
|
||||
| Third-party API calls | N/A (is the third party) | Apple services | Some (optional) | **None** |
|
||||
| Search quality | Excellent | Good | Fair | **Good** (Photon/OSM) |
|
||||
| Offline maps | Limited | Limited | Full | **Full** |
|
||||
| Self-hostable backend | No | No | Partial | **Fully** |
|
||||
| Open-source | No | No | Yes (client) | **Yes (client + backend)** |
|
||||
| Routing quality | Excellent | Good | Good (OSRM-based) | **Good** (OSRM) |
|
||||
| UI/UX polish | Excellent | Excellent | Fair | **Target: Good** |
|
||||
|
||||
### 1.4 Target Users
|
||||
|
||||
- Privacy-conscious individuals who want a usable maps app without surveillance.
|
||||
- Organizations (NGOs, journalists, activists) operating in environments where location privacy is critical.
|
||||
- Self-hosting enthusiasts who want full control over their infrastructure.
|
||||
- Users in regions where Google services are unavailable or undesirable.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Features
|
||||
|
||||
### 2.1 Map Rendering
|
||||
|
||||
**Description:** The primary map view renders vector tiles served by a self-hosted Martin tile server. Vector tiles are styled client-side, enabling theme switching without re-downloading data.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Vector tile rendering using Martin-served Mapbox Vector Tiles (MVT) format.
|
||||
- Smooth pan and zoom with 60fps target on mid-range devices (2023+).
|
||||
- Pinch-to-zoom, double-tap zoom, rotation, and tilt gestures.
|
||||
- Zoom levels 0 (world) through 18 (building-level).
|
||||
- **Day theme:** Light background, high-contrast roads and labels.
|
||||
- **Night theme:** Dark background, reduced brightness, suitable for driving at night. Activates automatically based on device time or manual toggle.
|
||||
- **Terrain layer:** Optional hillshade/contour overlay for hiking and outdoor use.
|
||||
- Map attribution displayed per ODbL requirements (persistent "© OpenStreetMap contributors" in corner).
|
||||
- Current location indicator (blue dot) with heading indicator when moving.
|
||||
- Compass indicator; tap to re-orient north-up.
|
||||
|
||||
**Tile Caching:**
|
||||
|
||||
- On-device tile cache using SQLite (MBTiles format).
|
||||
- Cache size configurable, default 500 MB, maximum 2 GB.
|
||||
- LRU eviction policy when cache is full.
|
||||
- Tiles served from cache when available, network fetch only on cache miss.
|
||||
|
||||
### 2.2 Search / Geocoding
|
||||
|
||||
**Description:** Users search for addresses, place names, and points of interest. Search queries are sent to a self-hosted Photon instance. Recent searches are stored only on-device.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Single search bar at top of map view.
|
||||
- As-you-type suggestions with debounce (300ms after last keystroke).
|
||||
- Results ranked by relevance and proximity to current map viewport center.
|
||||
- Each result shows: name, address, category icon, distance from current location.
|
||||
- Tapping a result centers the map on that location and shows a place card.
|
||||
- **Recent searches:** Last 50 searches stored in local SQLite. Displayed when search bar is focused and query is empty. User can clear individual items or all history.
|
||||
- **No search logging on the backend.** Photon queries are stateless; the backend does not log query strings.
|
||||
|
||||
### 2.3 Routing / Navigation
|
||||
|
||||
**Description:** Turn-by-turn directions between two or more points, powered by a self-hosted OSRM instance.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- **Profiles:** Driving, walking, cycling. Each profile uses a separate OSRM dataset optimized for that mode.
|
||||
- **Route request flow:**
|
||||
1. User selects origin (current location or search result) and destination.
|
||||
2. App requests route from OSRM.
|
||||
3. Up to 3 alternative routes displayed on map with estimated time and distance.
|
||||
4. User selects a route; turn-by-turn instruction list is shown.
|
||||
- **Turn-by-turn display:**
|
||||
- Next maneuver shown prominently at top of screen (icon + distance + street name).
|
||||
- Subsequent maneuver shown in smaller text below.
|
||||
- Full instruction list accessible by swiping up.
|
||||
- Voice guidance is out of scope for v1.0 (planned for v1.1).
|
||||
- **Waypoints:** User can add up to 5 intermediate stops by long-pressing on the map or searching.
|
||||
- **Re-routing:** If the user deviates more than 50 meters from the active route, the app automatically requests a new route from the current position.
|
||||
- **Route summary:** Total distance, estimated duration, and arrival time.
|
||||
- **Offline routing:** If the user has downloaded the relevant region's OSRM data, routing works without network access (see Section 2.6).
|
||||
|
||||
### 2.4 Points of Interest (POIs)
|
||||
|
||||
**Description:** POI data is sourced from OpenStreetMap and served through the backend. Users can browse POIs on the map and view detail pages.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- POI categories rendered as icons on the map at appropriate zoom levels (zoom 14+ for most, zoom 12+ for major landmarks).
|
||||
- **Categories include:** Restaurants, cafes, shops, supermarkets, pharmacies, hospitals, fuel stations, parking, ATMs, public transport stops, hotels, tourist attractions, parks.
|
||||
- **Detail view** (shown when tapping a POI marker or search result):
|
||||
- Name
|
||||
- Category / type
|
||||
- Address
|
||||
- Opening hours (parsed from OSM `opening_hours` tag, displayed in human-readable format with current open/closed status)
|
||||
- Phone number (if available)
|
||||
- Website (if available, opened in external browser)
|
||||
- Wheelchair accessibility (if tagged)
|
||||
- "Get Directions" button
|
||||
- "Save to Favorites" button
|
||||
- POIs at the current viewport are fetched from the backend's POI endpoint (see Section 5.5).
|
||||
|
||||
### 2.5 Bookmarks / Favorites
|
||||
|
||||
**Description:** Users can save places to a local favorites list for quick access. No cloud sync.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Save any location (POI, search result, or arbitrary map point via long-press) as a favorite.
|
||||
- Each favorite stores: name (editable), coordinates, address, optional note, timestamp saved.
|
||||
- Favorites list accessible from main menu.
|
||||
- Favorites displayed as distinct markers on the map.
|
||||
- User can organize favorites into custom groups (e.g., "Work", "Travel", "Restaurants").
|
||||
- Import/export favorites as GeoJSON file for manual backup or transfer between devices.
|
||||
- All data stored in local SQLite. No network calls involved.
|
||||
|
||||
### 2.6 Offline Maps
|
||||
|
||||
**Description:** Users can download map regions for use without network access. A downloaded region includes vector tiles, OSRM routing data, and POI data.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- **Region selection:** User selects a rectangular area on the map, or chooses from a list of predefined regions (cities, states/provinces, countries).
|
||||
- **Download package contents:**
|
||||
- Vector tiles for the selected area (zoom levels 0-16).
|
||||
- OSRM routing graph for the selected area (driving + walking + cycling).
|
||||
- POI data for the selected area (as SQLite database).
|
||||
- **Storage estimates:**
|
||||
|
||||
| Region Size | Tiles | Routing | POIs | Total |
|
||||
|---|---|---|---|---|
|
||||
| City (~30km radius) | 30-80 MB | 15-50 MB | 5-20 MB | **50-150 MB** |
|
||||
| State/Province | 200-500 MB | 100-300 MB | 20-80 MB | **320-880 MB** |
|
||||
| Country (medium) | 1-3 GB | 0.5-1.5 GB | 50-200 MB | **1.5-4.7 GB** |
|
||||
|
||||
- **Download management:**
|
||||
- Progress indicator with percentage and estimated time remaining.
|
||||
- Pause and resume support.
|
||||
- Background download (continues when app is backgrounded).
|
||||
- List of downloaded regions with size and last-updated date.
|
||||
- Update a downloaded region (delta updates where possible).
|
||||
- Delete a downloaded region.
|
||||
- **Offline behavior:**
|
||||
- When offline, the app uses cached/downloaded tiles, local OSRM data, and local POI database.
|
||||
- Search uses a local Photon index bundled with the download (or falls back to coordinate-based lookup from local POI data).
|
||||
- A banner indicates "Offline mode" at the top of the screen.
|
||||
|
||||
---
|
||||
|
||||
## 3. User Flows & Acceptance Criteria
|
||||
|
||||
### 3.1 Open App and Browse Map
|
||||
|
||||
**Precondition:** App is installed, location permission granted (optional).
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. User launches the app.
|
||||
2. Splash screen displays for no more than the cold start time.
|
||||
3. Map renders centered on the user's current location (if permission granted) or on a default location (configurable, default: last viewed location, or Europe center on first launch).
|
||||
4. User pans, zooms, rotates, and tilts the map.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- [ ] Map is interactive within 2 seconds of launch (cold start).
|
||||
- [ ] Tiles within the initial viewport load from cache in < 200ms or from network in < 500ms.
|
||||
- [ ] Pan and zoom maintain 60fps on a mid-range device.
|
||||
- [ ] No network request is made to any domain other than the configured backend.
|
||||
- [ ] If location permission is denied, the app still functions with no error—map centers on default location.
|
||||
- [ ] Map attribution ("© OpenStreetMap contributors") is visible at all times.
|
||||
|
||||
### 3.2 Search for a Place
|
||||
|
||||
**Precondition:** App is open, map is visible.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. User taps the search bar.
|
||||
2. Recent searches appear (if any).
|
||||
3. User types a query (e.g., "Vondelpark").
|
||||
4. After 300ms of no typing, suggestions appear.
|
||||
5. User taps a result.
|
||||
6. Map animates to the selected location, a marker appears, and a place card slides up from the bottom.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- [ ] Recent searches load from local SQLite in < 50ms.
|
||||
- [ ] Search suggestions appear within 500ms of the debounce firing.
|
||||
- [ ] Results are ordered by relevance, with proximity to viewport as a secondary signal.
|
||||
- [ ] Tapping a result dismisses the keyboard and animates the map smoothly.
|
||||
- [ ] The query is saved to local search history.
|
||||
- [ ] The query is NOT logged or stored on the backend.
|
||||
- [ ] Searching while offline returns results from the local POI database (if region is downloaded).
|
||||
|
||||
### 3.3 Get Directions Between Two Points
|
||||
|
||||
**Precondition:** App is open.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. User taps "Directions" (either from a place card or from the main menu).
|
||||
2. Origin defaults to current location; user can change it via search.
|
||||
3. User enters or selects a destination.
|
||||
4. User selects a travel profile (driving/walking/cycling).
|
||||
5. App displays up to 3 route alternatives on the map with time/distance.
|
||||
6. User taps a route to select it.
|
||||
7. Turn-by-turn instruction list appears.
|
||||
8. User taps "Start" to begin navigation mode.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- [ ] Route response returns within 1 second for distances under 100 km.
|
||||
- [ ] At least 1 route (and up to 3 alternatives) is displayed.
|
||||
- [ ] Each route shows total distance (km/mi based on locale) and estimated duration.
|
||||
- [ ] Route line is drawn on the map with clear visual distinction between alternatives.
|
||||
- [ ] Selected route is visually highlighted; unselected routes are dimmed.
|
||||
- [ ] Turn-by-turn instructions include maneuver type, street name, and distance to maneuver.
|
||||
- [ ] Re-routing triggers automatically when user deviates > 50m from the active route.
|
||||
- [ ] Routing works offline if the region's OSRM data is downloaded.
|
||||
|
||||
### 3.4 Save a Place as Favorite
|
||||
|
||||
**Precondition:** A place card is visible (from search result, POI tap, or long-press on map).
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. User taps the "Save" / bookmark icon on the place card.
|
||||
2. A dialog appears with the place name pre-filled (editable), an optional note field, and a group selector (default: "Favorites").
|
||||
3. User confirms.
|
||||
4. The marker changes to a favorites icon. The place appears in the favorites list.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- [ ] Favorite is persisted to local SQLite immediately.
|
||||
- [ ] No network request is made.
|
||||
- [ ] Favorite appears in the favorites list and on the map.
|
||||
- [ ] User can edit the name and note after saving.
|
||||
- [ ] User can delete a favorite.
|
||||
- [ ] Favorites survive app restart and device reboot.
|
||||
|
||||
### 3.5 Download Area for Offline Use
|
||||
|
||||
**Precondition:** App is open, device is online.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. User navigates to Settings > Offline Maps > Download Region.
|
||||
2. User either selects a predefined region from a list or drags a selection rectangle on the map.
|
||||
3. App shows estimated download size and required storage.
|
||||
4. User confirms download.
|
||||
5. Progress bar shows download progress. User can background the app.
|
||||
6. On completion, a notification appears: "Region X is ready for offline use."
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- [ ] Estimated size is shown before download begins.
|
||||
- [ ] Download can be paused and resumed.
|
||||
- [ ] Download continues when the app is backgrounded.
|
||||
- [ ] Downloaded region is listed under "Downloaded Regions" with name, size, and date.
|
||||
- [ ] After download, map tiles for that region load from local storage without network.
|
||||
- [ ] After download, search within that region works offline.
|
||||
- [ ] After download, routing within that region works offline.
|
||||
- [ ] User can delete a downloaded region and storage is reclaimed.
|
||||
|
||||
### 3.6 View POI Details
|
||||
|
||||
**Precondition:** Map is zoomed in enough to show POI icons (zoom level 14+).
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. User taps a POI icon on the map.
|
||||
2. A place card slides up from the bottom with the POI name and category.
|
||||
3. User swipes the card up to reveal full details: address, opening hours, phone, website, accessibility info.
|
||||
4. User taps "Get Directions" or "Save" or dismisses the card.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- [ ] POI data loads within 300ms (from cache or network).
|
||||
- [ ] Opening hours display the current open/closed status with today's hours.
|
||||
- [ ] Phone number is tappable (opens dialer).
|
||||
- [ ] Website is tappable (opens external browser — the app itself makes no request to the website).
|
||||
- [ ] "Get Directions" pre-fills the POI as the destination in the routing flow.
|
||||
- [ ] POI details work offline if the region is downloaded.
|
||||
|
||||
### 3.7 Share a Location
|
||||
|
||||
**Precondition:** A location is selected (via search, POI tap, or long-press on map).
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. User taps the "Share" icon on the place card.
|
||||
2. A share sheet appears with options:
|
||||
- **Coordinates:** Plain text, e.g., "52.3676, 4.9041"
|
||||
- **geo: URI:** `geo:52.3676,4.9041`
|
||||
- **OpenStreetMap link:** `https://www.openstreetmap.org/#map=17/52.3676/4.9041`
|
||||
3. User selects an option. The system share sheet opens (or content is copied to clipboard).
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- [ ] Sharing does not make any network request.
|
||||
- [ ] Shared content does not include any tracking parameters or unique identifiers.
|
||||
- [ ] Coordinates are in WGS84 (latitude, longitude) with 4 decimal places (11m precision).
|
||||
- [ ] The system share sheet is used (no custom sharing implementation that phones home).
|
||||
|
||||
### 3.8 Switch Map Theme
|
||||
|
||||
**Precondition:** App is open, map is visible.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. User taps the layers/theme button on the map.
|
||||
2. A panel shows available themes: Day, Night, Terrain.
|
||||
3. User selects a theme.
|
||||
4. Map re-renders with the new style immediately (no tile re-download needed since vector tiles are styled client-side).
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- [ ] Theme switch completes in < 500ms (no network request required).
|
||||
- [ ] Night theme has noticeably reduced brightness suitable for dark environments.
|
||||
- [ ] Terrain theme shows hillshade/contour overlay.
|
||||
- [ ] Selected theme persists across app restarts (stored locally).
|
||||
- [ ] Auto night mode option: switches to night theme based on device clock (sunset/sunrise for current location, or a fixed schedule like 20:00-06:00).
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Functional Requirements
|
||||
|
||||
### 4.1 Performance
|
||||
|
||||
| Metric | Target | Measurement Method |
|
||||
|---|---|---|
|
||||
| Cold start to interactive map | < 2 seconds | Time from process start to first rendered frame with interactive tiles, on a mid-range 2023 Android device |
|
||||
| Tile load from cache | < 200ms | Time from tile request to rendered tile, tile present in SQLite cache |
|
||||
| Tile load from network | < 500ms | Time from tile request to rendered tile, tile not cached, backend in same region |
|
||||
| Search suggestions | < 500ms | Time from debounce trigger to suggestions rendered |
|
||||
| Route calculation (< 100km) | < 1 second | Time from request sent to route drawn on map |
|
||||
| Route calculation (< 500km) | < 3 seconds | Time from request sent to route drawn on map |
|
||||
| Pan/zoom frame rate | 60fps | Sustained frame rate during continuous gesture on mid-range device |
|
||||
| POI detail load | < 300ms | Time from tap to full detail card rendered |
|
||||
|
||||
### 4.2 Battery
|
||||
|
||||
- Map browsing (active panning/zooming) must not drain battery faster than Google Maps doing the same activity on the same device. Target: within 10% of Google Maps' battery consumption.
|
||||
- Background navigation (screen off, GPS active) must use less than 5% battery per hour.
|
||||
- When the app is not actively being used and no navigation is in progress, GPS must be fully released (zero location updates).
|
||||
|
||||
### 4.3 Data Usage
|
||||
|
||||
- Vector tiles are approximately 10x smaller than equivalent raster tiles. A typical browsing session (30 minutes, exploring a city) should use < 15 MB of data.
|
||||
- Initial app install size: < 30 MB (no bundled tile data; tiles are fetched or downloaded on demand).
|
||||
- Tile cache uses LRU eviction. Default limit: 500 MB.
|
||||
|
||||
### 4.4 Offline Storage
|
||||
|
||||
| Region Type | Storage Estimate |
|
||||
|---|---|
|
||||
| Single city (~30km radius) | 50-150 MB |
|
||||
| State/Province | 320-880 MB |
|
||||
| Medium-sized country | 1.5-4.7 GB |
|
||||
|
||||
The user must be warned if a download would fill more than 80% of available device storage.
|
||||
|
||||
### 4.5 Privacy — Hard Requirements
|
||||
|
||||
These are non-negotiable and must be verified in CI and during code review.
|
||||
|
||||
1. **Zero third-party network calls.** The app binary must not contain any hardcoded URLs to external services. Every network request goes to the single configured backend URL. CI pipeline must include a static analysis step that scans the compiled binary and dependency tree for third-party URLs.
|
||||
2. **No analytics or crash reporting SDKs.** The dependency tree must not include Firebase, Sentry, Amplitude, Mixpanel, Google Analytics, or any similar library. Enforced via dependency allow-listing in CI.
|
||||
3. **No device fingerprinting.** The app must not read or transmit: IMEI, advertising ID, MAC address, serial number, or any persistent device identifier.
|
||||
4. **No data collection on the backend.** The backend must not log: IP addresses (beyond ephemeral connection-level), query strings, coordinates, or any data that could identify a user or session. Backend access logs must be configured to omit query parameters and client IPs by default.
|
||||
5. **On-device data encrypted at rest.** The local SQLite databases (history, favorites, cache) must be stored in the platform's encrypted storage (Android: EncryptedSharedPreferences / encrypted file system; iOS: Data Protection Complete).
|
||||
6. **TLS only.** All communication between app and backend must use TLS 1.2 or higher. The app must reject plaintext HTTP connections. Certificate pinning is recommended but optional (since the user controls the backend).
|
||||
|
||||
### 4.6 Accessibility
|
||||
|
||||
- All interactive elements must have accessible labels.
|
||||
- Minimum touch target: 48x48dp (Android) / 44x44pt (iOS).
|
||||
- Support for system-level font scaling (up to 200%).
|
||||
- Screen reader support for search results, POI details, and navigation instructions.
|
||||
- Sufficient color contrast (WCAG 2.1 AA) in both day and night themes.
|
||||
|
||||
### 4.7 Supported Platforms
|
||||
|
||||
| Platform | Minimum Version |
|
||||
|---|---|
|
||||
| Android | API 26 (Android 8.0) |
|
||||
| iOS | iOS 15.0 |
|
||||
|
||||
---
|
||||
|
||||
## 5. API Contract Outlines
|
||||
|
||||
The mobile app communicates with a single self-hosted backend. The backend is a Rust/Actix-web service that proxies or wraps Martin, Photon, and OSRM.
|
||||
|
||||
**Base URL:** Configured by the user at first launch (e.g., `https://maps.example.com`).
|
||||
|
||||
All endpoints are unauthenticated (no API keys, no tokens, no cookies).
|
||||
|
||||
### 5.1 Tile Serving
|
||||
|
||||
Proxied from Martin tile server.
|
||||
|
||||
**Endpoint:** `GET /tiles/{layer}/{z}/{x}/{y}.pbf`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `layer` | string | Tile layer name (e.g., `openmaptiles`, `terrain`, `hillshade`) |
|
||||
| `z` | integer | Zoom level (0-18) |
|
||||
| `x` | integer | Tile column |
|
||||
| `y` | integer | Tile row |
|
||||
|
||||
**Response:**
|
||||
- `200 OK` — Body: Protobuf-encoded Mapbox Vector Tile. Headers: `Content-Type: application/x-protobuf`, `Content-Encoding: gzip`, `Cache-Control: public, max-age=86400`.
|
||||
- `404 Not Found` — Tile does not exist for the given coordinates.
|
||||
|
||||
**Style Endpoint:** `GET /tiles/style.json`
|
||||
|
||||
Returns a Mapbox GL style JSON document referencing the tile endpoints. The app uses this to configure the renderer.
|
||||
|
||||
### 5.2 Search / Geocoding
|
||||
|
||||
Proxied from Photon.
|
||||
|
||||
**Endpoint:** `GET /api/search`
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `q` | string | Yes | Search query |
|
||||
| `lat` | float | No | Latitude for proximity bias |
|
||||
| `lon` | float | No | Longitude for proximity bias |
|
||||
| `limit` | integer | No | Max results (default: 10, max: 20) |
|
||||
| `lang` | string | No | Preferred language for results (ISO 639-1) |
|
||||
| `bbox` | string | No | Bounding box filter: `minLon,minLat,maxLon,maxLat` |
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [4.9041, 52.3676]
|
||||
},
|
||||
"properties": {
|
||||
"osm_id": 12345678,
|
||||
"osm_type": "N",
|
||||
"name": "Vondelpark",
|
||||
"street": "Vondelpark",
|
||||
"city": "Amsterdam",
|
||||
"state": "North Holland",
|
||||
"country": "Netherlands",
|
||||
"postcode": "1071 AA",
|
||||
"type": "park",
|
||||
"extent": [4.8580, 52.3585, 4.8820, 52.3620]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Reverse Geocoding
|
||||
|
||||
Proxied from Photon.
|
||||
|
||||
**Endpoint:** `GET /api/reverse`
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `lat` | float | Yes | Latitude |
|
||||
| `lon` | float | Yes | Longitude |
|
||||
| `limit` | integer | No | Max results (default: 1) |
|
||||
| `lang` | string | No | Preferred language |
|
||||
|
||||
**Response:** Same GeoJSON FeatureCollection format as the search endpoint.
|
||||
|
||||
### 5.4 Routing
|
||||
|
||||
Proxied from OSRM.
|
||||
|
||||
**Endpoint:** `GET /api/route/{profile}/{coordinates}`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `profile` | string | One of: `driving`, `walking`, `cycling` |
|
||||
| `coordinates` | string | Semicolon-separated coordinate pairs: `{lon},{lat};{lon},{lat}[;...]` |
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `alternatives` | integer | No | Number of alternative routes (default: 0, max: 3) |
|
||||
| `steps` | boolean | No | Include turn-by-turn steps (default: `true`) |
|
||||
| `geometries` | string | No | Response geometry format: `polyline`, `polyline6`, or `geojson` (default: `geojson`) |
|
||||
| `overview` | string | No | Geometry detail: `full`, `simplified`, or `false` (default: `full`) |
|
||||
| `language` | string | No | Language for turn instructions (ISO 639-1) |
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "Ok",
|
||||
"routes": [
|
||||
{
|
||||
"distance": 12456.7,
|
||||
"duration": 1823.4,
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[4.9041, 52.3676], [4.9100, 52.3700]]
|
||||
},
|
||||
"legs": [
|
||||
{
|
||||
"distance": 12456.7,
|
||||
"duration": 1823.4,
|
||||
"steps": [
|
||||
{
|
||||
"distance": 234.5,
|
||||
"duration": 32.1,
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[4.9041, 52.3676], [4.9060, 52.3680]]
|
||||
},
|
||||
"maneuver": {
|
||||
"type": "turn",
|
||||
"modifier": "left",
|
||||
"location": [4.9041, 52.3676],
|
||||
"bearing_before": 90,
|
||||
"bearing_after": 0,
|
||||
"instruction": "Turn left onto Keizersgracht"
|
||||
},
|
||||
"name": "Keizersgracht",
|
||||
"mode": "driving"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"waypoints": [
|
||||
{
|
||||
"name": "Vondelstraat",
|
||||
"location": [4.9041, 52.3676]
|
||||
},
|
||||
{
|
||||
"name": "Dam Square",
|
||||
"location": [4.8952, 52.3732]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 POI Details
|
||||
|
||||
Custom endpoint served by the Rust/Actix-web backend. Queries PostGIS for OSM-sourced POI data.
|
||||
|
||||
**List POIs in Bounding Box:**
|
||||
|
||||
`GET /api/pois`
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `bbox` | string | Yes | `minLon,minLat,maxLon,maxLat` |
|
||||
| `category` | string | No | Filter by category (comma-separated, e.g., `restaurant,cafe`) |
|
||||
| `limit` | integer | No | Max results (default: 100, max: 500) |
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [4.9041, 52.3676]
|
||||
},
|
||||
"properties": {
|
||||
"osm_id": 987654321,
|
||||
"osm_type": "N",
|
||||
"name": "Cafe de Jaren",
|
||||
"category": "cafe",
|
||||
"address": {
|
||||
"street": "Nieuwe Doelenstraat",
|
||||
"housenumber": "20",
|
||||
"postcode": "1012 CP",
|
||||
"city": "Amsterdam"
|
||||
},
|
||||
"opening_hours": "Mo-Th 09:30-01:00; Fr-Sa 09:30-02:00; Su 10:00-01:00",
|
||||
"opening_hours_parsed": {
|
||||
"is_open": true,
|
||||
"today": "09:30 - 01:00",
|
||||
"next_change": "01:00"
|
||||
},
|
||||
"phone": "+31 20 625 5771",
|
||||
"website": "https://www.cafedejaren.nl",
|
||||
"wheelchair": "yes"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Single POI Detail:**
|
||||
|
||||
`GET /api/pois/{osm_type}/{osm_id}`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `osm_type` | string | `N` (node), `W` (way), or `R` (relation) |
|
||||
| `osm_id` | integer | OpenStreetMap element ID |
|
||||
|
||||
**Response:** Single GeoJSON Feature with the same structure as above.
|
||||
|
||||
### 5.6 Offline Data Packages
|
||||
|
||||
Custom endpoint for downloading offline regions.
|
||||
|
||||
**List Available Regions:**
|
||||
|
||||
`GET /api/offline/regions`
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"regions": [
|
||||
{
|
||||
"id": "amsterdam",
|
||||
"name": "Amsterdam",
|
||||
"bbox": [4.7288, 52.2783, 5.0796, 52.4311],
|
||||
"size_mb": 95,
|
||||
"last_updated": "2026-03-25T00:00:00Z",
|
||||
"components": {
|
||||
"tiles_mb": 55,
|
||||
"routing_mb": 30,
|
||||
"pois_mb": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Download Region Component:**
|
||||
|
||||
`GET /api/offline/regions/{region_id}/{component}`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `region_id` | string | Region identifier |
|
||||
| `component` | string | One of: `tiles`, `routing-driving`, `routing-walking`, `routing-cycling`, `pois` |
|
||||
|
||||
**Response:**
|
||||
- `200 OK` — Binary file download. `Content-Type: application/octet-stream`. Supports `Range` headers for pause/resume.
|
||||
- `Accept-Ranges: bytes` header included.
|
||||
|
||||
### 5.7 Health Check
|
||||
|
||||
`GET /api/health`
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"services": {
|
||||
"martin": "ok",
|
||||
"photon": "ok",
|
||||
"osrm_driving": "ok",
|
||||
"osrm_walking": "ok",
|
||||
"osrm_cycling": "ok",
|
||||
"postgres": "ok"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Sources & Licensing
|
||||
|
||||
### 6.1 OpenStreetMap
|
||||
|
||||
- **Use:** Base map data for tiles, POIs, addresses.
|
||||
- **License:** Open Data Commons Open Database License (ODbL) 1.0.
|
||||
- **Obligations:**
|
||||
- Attribution: "© OpenStreetMap contributors" must be displayed on the map at all times.
|
||||
- Share-Alike: If the database is modified and redistributed, the modified database must be released under ODbL.
|
||||
- The application's own source code is not affected by ODbL (it is not a "derivative database").
|
||||
- **Update frequency:** Backend imports OSM data weekly via `osm2pgsql` or `imposm3`.
|
||||
|
||||
### 6.2 OSRM (Open Source Routing Machine)
|
||||
|
||||
- **Use:** Route calculation and turn-by-turn navigation.
|
||||
- **License:** BSD 2-Clause License.
|
||||
- **Obligations:** Include copyright notice and license text in documentation / about screen.
|
||||
- **Deployment:** Self-hosted OSRM backend, one instance per profile (driving, walking, cycling). Data preprocessed from OSM PBF files using `osrm-extract`, `osrm-partition`, `osrm-customize`.
|
||||
|
||||
### 6.3 Photon
|
||||
|
||||
- **Use:** Forward and reverse geocoding (search).
|
||||
- **License:** Apache License 2.0.
|
||||
- **Obligations:** Include copyright notice and license text. State any modifications.
|
||||
- **Deployment:** Self-hosted Photon instance. Data imported from Nominatim database (itself built from OSM data). Updated weekly alongside the OSM data import.
|
||||
|
||||
### 6.4 Martin
|
||||
|
||||
- **Use:** Vector tile serving.
|
||||
- **License:** Dual-licensed: MIT License and Apache License 2.0.
|
||||
- **Obligations:** Include copyright notice and license text (either MIT or Apache 2.0, at our choice).
|
||||
- **Deployment:** Self-hosted Martin instance connected to PostGIS database containing tile data generated from OSM via `openmaptiles` tools.
|
||||
|
||||
### 6.5 License Attribution in App
|
||||
|
||||
The "About" screen in the app must display:
|
||||
- OpenStreetMap attribution and ODbL license summary with link.
|
||||
- OSRM copyright and BSD-2 license notice.
|
||||
- Photon copyright and Apache 2.0 license notice.
|
||||
- Martin copyright and MIT/Apache 2.0 license notice.
|
||||
- Any additional open-source libraries used (auto-generated from dependency metadata).
|
||||
|
||||
---
|
||||
|
||||
## 7. Tech Stack Summary
|
||||
|
||||
### 7.1 Mobile App
|
||||
|
||||
| Component | Technology |
|
||||
|---|---|
|
||||
| Framework | Flutter (latest stable) |
|
||||
| Language | Dart |
|
||||
| Map renderer | `maplibre_gl` (Flutter plugin for MapLibre GL Native) |
|
||||
| Local storage | SQLite via `sqflite` / `drift` |
|
||||
| HTTP client | `dio` (with TLS enforcement, no third-party interceptors) |
|
||||
| State management | `riverpod` or `bloc` (to be decided during architecture) |
|
||||
| Tile cache | MBTiles in SQLite |
|
||||
| Offline routing | Embedded OSRM data files with Dart FFI bindings (or pre-calculated route graphs) |
|
||||
|
||||
### 7.2 Backend
|
||||
|
||||
| Component | Technology |
|
||||
|---|---|
|
||||
| API gateway / proxy | Rust + Actix-web |
|
||||
| Tile server | Martin (connected to PostGIS) |
|
||||
| Geocoding | Photon (Java, self-hosted) |
|
||||
| Routing | OSRM (C++, self-hosted, one instance per profile) |
|
||||
| Database | PostgreSQL 16+ with PostGIS 3.4+ |
|
||||
| OSM data import | `osm2pgsql` or `imposm3` |
|
||||
| Tile generation | `openmaptiles` toolchain |
|
||||
| Containerization | Docker Compose for all services |
|
||||
| Reverse proxy / TLS | Caddy or nginx (user-configured) |
|
||||
|
||||
### 7.3 Infrastructure Requirements (Minimum)
|
||||
|
||||
For serving a single country (e.g., Netherlands):
|
||||
|
||||
| Resource | Minimum | Recommended |
|
||||
|---|---|---|
|
||||
| CPU | 4 cores | 8 cores |
|
||||
| RAM | 8 GB | 16 GB |
|
||||
| Disk | 50 GB SSD | 100 GB SSD |
|
||||
| Network | 100 Mbps | 1 Gbps |
|
||||
|
||||
For global coverage, significantly more resources are needed (primarily for OSRM preprocessing and Photon index size).
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Glossary
|
||||
|
||||
| Term | Definition |
|
||||
|---|---|
|
||||
| **MVT** | Mapbox Vector Tile — a compact binary format for encoding tiled map data as vector geometry |
|
||||
| **PBF** | Protocolbuffer Binary Format — the compact binary format used for OSM data extracts and vector tiles |
|
||||
| **ODbL** | Open Data Commons Open Database License — the license governing OpenStreetMap data |
|
||||
| **PostGIS** | Spatial extension for PostgreSQL enabling geographic queries |
|
||||
| **MBTiles** | A specification for storing tiled map data in SQLite databases |
|
||||
| **OSRM** | Open Source Routing Machine — a C++ routing engine for shortest paths in road networks |
|
||||
| **Photon** | A geocoder built for OpenStreetMap data, powered by Elasticsearch |
|
||||
| **Martin** | A PostGIS/MBTiles vector tile server written in Rust |
|
||||
| **WGS84** | World Geodetic System 1984 — the coordinate reference system used by GPS (EPSG:4326) |
|
||||
|
||||
## Appendix B: Open Questions
|
||||
|
||||
1. **Voice navigation (v1.1):** Use platform TTS APIs (on-device, no network) or bundle an open-source TTS engine? Platform TTS is simpler but quality varies. To be decided before v1.1 planning.
|
||||
2. **Public transit routing:** OSRM does not support public transit. Options include integrating OpenTripPlanner (AGPL — license implications need review) or deferring to a future version.
|
||||
3. **Map style customization:** Should users be able to load custom Mapbox GL styles, or do we provide only the bundled Day/Night/Terrain themes? Custom styles add flexibility but increase support surface.
|
||||
4. **Multi-backend support:** Should the app support configuring multiple backend URLs (e.g., one for tiles, one for routing) or require a single unified backend? Single URL is simpler; multiple URLs enable distributed setups.
|
||||
5. **Delta updates for offline maps:** Implementing true delta updates (only download changed tiles) is complex. v1.0 may use full re-download of components. Delta updates can be a v1.1 optimization.
|
||||
45
mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
45
mobile/.metadata
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "ea121f8859e4b13e47a8f845e4586164519588bc"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
- platform: android
|
||||
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
- platform: ios
|
||||
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
- platform: linux
|
||||
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
- platform: macos
|
||||
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
- platform: web
|
||||
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
- platform: windows
|
||||
create_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
base_revision: ea121f8859e4b13e47a8f845e4586164519588bc
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
16
mobile/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# privacy_maps
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
28
mobile/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
14
mobile/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
44
mobile/android/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.privacymaps.privacy_maps"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.privacymaps.privacy_maps"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
45
mobile/android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="privacy_maps"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.privacymaps.privacy_maps
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
mobile/android/app/src/main/res/values-night/styles.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
mobile/android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
mobile/android/app/src/profile/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
21
mobile/android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
3
mobile/android/gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
5
mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
25
mobile/android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
pluginManagement {
|
||||
val flutterSdkPath = run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
34
mobile/ios/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
**/dgph
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
**/DerivedData/
|
||||
Icon?
|
||||
**/Pods/
|
||||
**/.symlinks/
|
||||
profile
|
||||
xcuserdata
|
||||
**/.generated/
|
||||
Flutter/App.framework
|
||||
Flutter/Flutter.framework
|
||||
Flutter/Flutter.podspec
|
||||
Flutter/Generated.xcconfig
|
||||
Flutter/ephemeral/
|
||||
Flutter/app.flx
|
||||
Flutter/app.zip
|
||||
Flutter/flutter_assets/
|
||||
Flutter/flutter_export_environment.sh
|
||||
ServiceDefinitions.json
|
||||
Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
26
mobile/ios/Flutter/AppFrameworkInfo.plist
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
2
mobile/ios/Flutter/Debug.xcconfig
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
2
mobile/ios/Flutter/Release.xcconfig
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
41
mobile/ios/Podfile
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '12.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
end
|
||||
end
|
||||
616
mobile/ios/Runner.xcodeproj/project.pbxproj
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
};
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C146FB1CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.privacymaps.privacyMaps;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C8088294A63A400263BE5 /* Debug */,
|
||||
331C8089294A63A400263BE5 /* Release */,
|
||||
331C808A294A63A400263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
7
mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
7
mobile/ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
13
mobile/ios/Runner/AppDelegate.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Launch Screen Assets
|
||||
|
||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||
|
||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||
37
mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
26
mobile/ios/Runner/Base.lproj/Main.storyboard
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
49
mobile/ios/Runner/Info.plist
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Privacy Maps</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>privacy_maps</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
1
mobile/ios/Runner/Runner-Bridging-Header.h
Normal file
|
|
@ -0,0 +1 @@
|
|||
#import "GeneratedPluginRegistrant.h"
|
||||
12
mobile/ios/RunnerTests/RunnerTests.swift
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
func testExample() {
|
||||
// If you add code to the Runner application, consider adding tests here.
|
||||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||
}
|
||||
|
||||
}
|
||||
22
mobile/lib/app/app.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'router.dart';
|
||||
import 'theme.dart';
|
||||
import '../features/settings/presentation/screens/settings_screen.dart';
|
||||
|
||||
class PrivacyMapsApp extends ConsumerWidget {
|
||||
const PrivacyMapsApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
return MaterialApp.router(
|
||||
title: 'Privacy Maps',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.dayTheme,
|
||||
darkTheme: AppTheme.nightTheme,
|
||||
themeMode: themeMode,
|
||||
routerConfig: routerConfig,
|
||||
);
|
||||
}
|
||||
}
|
||||
55
mobile/lib/app/router.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../features/map/presentation/screens/map_screen.dart';
|
||||
import '../features/search/presentation/screens/search_screen.dart';
|
||||
import '../features/routing/presentation/screens/route_screen.dart';
|
||||
import '../features/settings/presentation/screens/settings_screen.dart';
|
||||
import '../features/offline/presentation/screens/offline_screen.dart';
|
||||
import '../features/places/presentation/screens/place_detail_screen.dart';
|
||||
|
||||
final routerConfig = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const MapScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
pageBuilder: (context, state) => CustomTransitionPage(
|
||||
key: state.pageKey,
|
||||
child: const SearchScreen(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/route',
|
||||
builder: (context, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
return RouteScreen(
|
||||
destinationLat: extra?['destLat'] as double?,
|
||||
destinationLon: extra?['destLon'] as double?,
|
||||
destinationName: extra?['destName'] as String?,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/offline',
|
||||
builder: (context, state) => const OfflineScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/place/:osmType/:osmId',
|
||||
builder: (context, state) {
|
||||
final osmType = state.pathParameters['osmType']!;
|
||||
final osmId = int.parse(state.pathParameters['osmId']!);
|
||||
return PlaceDetailScreen(osmType: osmType, osmId: osmId);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
69
mobile/lib/app/theme.dart
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const _seedColor = Color(0xFF1B6B4A);
|
||||
|
||||
static ThemeData get dayTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _seedColor,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
showDragHandle: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData get nightTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _seedColor,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
elevation: 0,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
showDragHandle: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
mobile/lib/core/api/api_client.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../constants.dart';
|
||||
|
||||
/// Standard error format returned by the API.
|
||||
class ApiError {
|
||||
final String code;
|
||||
final String message;
|
||||
final int statusCode;
|
||||
|
||||
ApiError({
|
||||
required this.code,
|
||||
required this.message,
|
||||
required this.statusCode,
|
||||
});
|
||||
|
||||
factory ApiError.fromResponse(Response response) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic> && data.containsKey('error')) {
|
||||
final error = data['error'] as Map<String, dynamic>;
|
||||
return ApiError(
|
||||
code: error['code'] as String? ?? 'UNKNOWN',
|
||||
message: error['message'] as String? ?? 'Unknown error',
|
||||
statusCode: response.statusCode ?? 500,
|
||||
);
|
||||
}
|
||||
return ApiError(
|
||||
code: 'UNKNOWN',
|
||||
message: 'Unexpected error',
|
||||
statusCode: response.statusCode ?? 500,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ApiError($code: $message)';
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
final ApiError error;
|
||||
ApiException(this.error);
|
||||
|
||||
@override
|
||||
String toString() => error.toString();
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
String _baseUrl;
|
||||
|
||||
ApiClient({String? baseUrl})
|
||||
: _baseUrl = baseUrl ?? AppConstants.defaultBackendUrl {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: _baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
responseType: ResponseType.json,
|
||||
));
|
||||
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onError: (error, handler) {
|
||||
if (error.response != null) {
|
||||
final apiError = ApiError.fromResponse(error.response!);
|
||||
handler.reject(DioException(
|
||||
requestOptions: error.requestOptions,
|
||||
response: error.response,
|
||||
error: ApiException(apiError),
|
||||
));
|
||||
} else {
|
||||
handler.next(error);
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
String get baseUrl => _baseUrl;
|
||||
|
||||
void updateBaseUrl(String newUrl) {
|
||||
_baseUrl = newUrl;
|
||||
_dio.options.baseUrl = newUrl;
|
||||
}
|
||||
|
||||
/// GET request returning parsed JSON.
|
||||
Future<dynamic> get(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/// GET request returning raw Response (for downloads with progress).
|
||||
Future<Response> getRaw(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
ResponseType? responseType,
|
||||
void Function(int, int)? onReceiveProgress,
|
||||
Options? options,
|
||||
}) async {
|
||||
return _dio.get(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: options ?? Options(responseType: responseType),
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/// The underlying Dio instance, for advanced use cases.
|
||||
Dio get dio => _dio;
|
||||
}
|
||||
|
||||
/// Riverpod provider for the API client.
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
return ApiClient();
|
||||
});
|
||||
30
mobile/lib/core/constants.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class AppConstants {
|
||||
AppConstants._();
|
||||
|
||||
/// Default backend URL when none is configured.
|
||||
static const String defaultBackendUrl = 'http://localhost:8080';
|
||||
|
||||
/// Default map center: Amsterdam.
|
||||
static const double defaultLat = 52.3676;
|
||||
static const double defaultLon = 4.9041;
|
||||
static final LatLng defaultCenter = LatLng(defaultLat, defaultLon);
|
||||
|
||||
/// Zoom levels.
|
||||
static const double minZoom = 0;
|
||||
static const double maxZoom = 18;
|
||||
static const double defaultZoom = 13;
|
||||
static const double poiZoom = 16;
|
||||
static const double cityZoom = 12;
|
||||
|
||||
/// Search debounce duration in milliseconds.
|
||||
static const int searchDebounceMs = 300;
|
||||
|
||||
/// Maximum search history entries.
|
||||
static const int maxSearchHistory = 50;
|
||||
|
||||
/// Settings keys.
|
||||
static const String settingBackendUrl = 'backend_url';
|
||||
static const String settingThemeMode = 'theme_mode';
|
||||
}
|
||||
173
mobile/lib/core/database/app_database.dart
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'tables.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
@DriftDatabase(tables: [SearchHistory, Favorites, OfflineRegions, Settings])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
AppDatabase.forTesting(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search History DAO methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the most recent 50 search history items, newest first.
|
||||
Future<List<SearchHistoryData>> getRecentSearches() {
|
||||
return (select(searchHistory)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.timestamp)])
|
||||
..limit(50))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Watches search history as a reactive stream.
|
||||
Stream<List<SearchHistoryData>> watchRecentSearches() {
|
||||
return (select(searchHistory)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.timestamp)])
|
||||
..limit(50))
|
||||
.watch();
|
||||
}
|
||||
|
||||
/// Inserts a new search entry. Evicts entries beyond the 50-item limit.
|
||||
Future<void> addSearch(String query, {double? lat, double? lon}) async {
|
||||
await into(searchHistory).insert(SearchHistoryCompanion.insert(
|
||||
query: query,
|
||||
latitude: Value(lat),
|
||||
longitude: Value(lon),
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
));
|
||||
await customStatement('''
|
||||
DELETE FROM search_history
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM search_history ORDER BY timestamp DESC LIMIT 50
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
/// Deletes a single history entry by ID.
|
||||
Future<void> deleteSearchEntry(int id) {
|
||||
return (delete(searchHistory)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
/// Deletes all search history.
|
||||
Future<void> clearSearchHistory() {
|
||||
return delete(searchHistory).go();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Favorites DAO methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Watches all favorites ordered by group then name.
|
||||
Stream<List<Favorite>> watchAllFavorites() {
|
||||
return (select(favorites)
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.asc(t.groupName),
|
||||
(t) => OrderingTerm.asc(t.name),
|
||||
]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
/// Returns all favorites.
|
||||
Future<List<Favorite>> getAllFavorites() {
|
||||
return (select(favorites)
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.asc(t.groupName),
|
||||
(t) => OrderingTerm.asc(t.name),
|
||||
]))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Inserts a new favorite.
|
||||
Future<int> addFavorite(FavoritesCompanion entry) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return into(favorites).insert(entry.copyWith(
|
||||
createdAt: Value(now),
|
||||
updatedAt: Value(now),
|
||||
));
|
||||
}
|
||||
|
||||
/// Deletes a favorite by ID.
|
||||
Future<void> deleteFavorite(int id) {
|
||||
return (delete(favorites)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
/// Checks if a place is favorited by osmType and osmId.
|
||||
Future<Favorite?> findFavoriteByOsm(String osmType, int osmId) {
|
||||
return (select(favorites)
|
||||
..where(
|
||||
(t) => t.osmType.equals(osmType) & t.osmId.equals(osmId)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline Regions DAO methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Stream<List<OfflineRegion>> watchOfflineRegions() {
|
||||
return (select(offlineRegions)
|
||||
..orderBy([(t) => OrderingTerm.asc(t.name)]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
Future<OfflineRegion?> getOfflineRegionById(String regionId) {
|
||||
return (select(offlineRegions)..where((t) => t.id.equals(regionId)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> upsertOfflineRegion(OfflineRegionsCompanion entry) {
|
||||
return into(offlineRegions).insertOnConflictUpdate(entry);
|
||||
}
|
||||
|
||||
Future<void> deleteOfflineRegion(String regionId) {
|
||||
return (delete(offlineRegions)..where((t) => t.id.equals(regionId))).go();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings DAO methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<String?> getSetting(String key) async {
|
||||
final row = await (select(settings)..where((t) => t.key.equals(key)))
|
||||
.getSingleOrNull();
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
Future<void> setSetting(String key, String value) {
|
||||
return into(settings).insertOnConflictUpdate(
|
||||
SettingsCompanion.insert(key: key, value: value),
|
||||
);
|
||||
}
|
||||
|
||||
Stream<String?> watchSetting(String key) {
|
||||
return (select(settings)..where((t) => t.key.equals(key)))
|
||||
.watchSingleOrNull()
|
||||
.map((row) => row?.value);
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dbFolder.path, 'app.db'));
|
||||
return NativeDatabase.createInBackground(file);
|
||||
});
|
||||
}
|
||||
|
||||
/// Riverpod provider for the database.
|
||||
final appDatabaseProvider = Provider<AppDatabase>((ref) {
|
||||
final db = AppDatabase();
|
||||
ref.onDispose(() => db.close());
|
||||
return db;
|
||||
});
|
||||