add backend server setup with environment configuration, tile caching, and bus stop fetching functionality

This commit is contained in:
ImBenji 2026-03-31 16:37:34 +01:00
parent 618f3dd3ed
commit 49fc04591b
27 changed files with 1536 additions and 374 deletions

2
backend/.env Normal file
View file

@ -0,0 +1,2 @@
MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoiaW1iZW5qaW5ldCIsImEiOiJjbW01azQ1bTcwODJ5MnBzOG9tMTUxdGRoIn0.z22taz_5I_8D5GuDlser_Q
PORT=8080

2
backend/.env.example Normal file
View file

@ -0,0 +1,2 @@
MAPBOX_ACCESS_TOKEN=pk.your_token_here
PORT=8080

52
backend/bin/server.dart Normal file
View file

@ -0,0 +1,52 @@
import 'dart:io';
import 'package:dotenv/dotenv.dart';
import 'package:shelf/shelf.dart' show Middleware, Response;
import 'package:rra_backend/rra_backend.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
Middleware _logger() => (handler) => (request) async {
final sw = Stopwatch()..start();
final response = await handler(request);
sw.stop();
final ms = sw.elapsedMilliseconds;
print('${request.method} ${response.statusCode} ${ms}ms ${request.url.path}');
return response;
};
Middleware _cors() => (handler) => (request) async {
if (request.method == 'OPTIONS') {
return Response.ok('', headers: _corsHeaders);
}
final response = await handler(request);
return response.change(headers: _corsHeaders);
};
const _corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Origin, Content-Type',
};
void main() async {
final env = DotEnv(includePlatformEnvironment: true)..load();
final mapboxToken = env['MAPBOX_ACCESS_TOKEN'];
if (mapboxToken == null || mapboxToken.isEmpty) {
stderr.writeln('ERROR: MAPBOX_ACCESS_TOKEN not set');
exit(1);
}
final port = int.tryParse(env['PORT'] ?? '8080') ?? 8080;
await TileCache.init();
final handler = const Pipeline()
.addMiddleware(_logger())
.addMiddleware(_cors())
.addHandler(buildRouter(mapboxToken).call);
final server = await io.serve(handler, InternetAddress.anyIPv4, port);
print('rra_backend listening on :${server.port}');
}

View file

@ -0,0 +1,2 @@
export 'src/cache/tile_cache.dart';
export 'src/router.dart';

75
backend/lib/src/cache/tile_cache.dart vendored Normal file
View file

@ -0,0 +1,75 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:sqlite3/sqlite3.dart';
// disk cache for raw .pbf tile bytes
// stale after 14 days, max 50k tiles before oldest get pruned
class TileCache {
TileCache._();
static const _staleMs = 14 * 24 * 60 * 60 * 1000;
static const _maxTiles = 50000;
static late final Database _db;
static Future<void> init({String? path}) async {
final dbPath = path ?? '${Directory.current.path}/tile_cache.db';
_db = sqlite3.open(dbPath);
_db.execute('''
CREATE TABLE IF NOT EXISTS tiles (
key TEXT PRIMARY KEY,
ts INTEGER NOT NULL,
bytes BLOB NOT NULL
)
''');
_db.execute('CREATE INDEX IF NOT EXISTS idx_tiles_ts ON tiles(ts)');
// WAL mode much better for concurrent reads
_db.execute('PRAGMA journal_mode=WAL');
_db.execute('PRAGMA synchronous=NORMAL');
}
static Uint8List? get(String key) {
final now = DateTime.now().millisecondsSinceEpoch;
final result = _db.select(
'SELECT bytes, ts FROM tiles WHERE key = ?',
[key],
);
if (result.isEmpty) return null;
final row = result.first;
final ts = row['ts'] as int;
if (now - ts > _staleMs) {
_db.execute('DELETE FROM tiles WHERE key = ?', [key]);
return null;
}
return row['bytes'] as Uint8List;
}
static void put(String key, Uint8List bytes) {
final now = DateTime.now().millisecondsSinceEpoch;
_db.execute(
'INSERT OR REPLACE INTO tiles (key, ts, bytes) VALUES (?, ?, ?)',
[key, now, bytes],
);
_pruneIfNeeded();
}
static void _pruneIfNeeded() {
final count = (_db.select('SELECT COUNT(*) as c FROM tiles').first['c'] as int);
if (count <= _maxTiles) return;
final excess = count - _maxTiles;
_db.execute('''
DELETE FROM tiles WHERE key IN (
SELECT key FROM tiles ORDER BY ts ASC LIMIT ?
)
''', [excess]);
}
}

View file

@ -0,0 +1,116 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
final Map<String, _BusStopEntry> _cache = {};
const _ttlMs = 5 * 60 * 1000;
class _BusStopEntry {
_BusStopEntry(this.body, this.ts);
final String body;
final int ts;
}
class BusStopsHandler {
Future<Response> handle(Request req) async {
final params = req.url.queryParameters;
final minLat = double.tryParse(params['minLat'] ?? '');
final minLon = double.tryParse(params['minLon'] ?? '');
final maxLat = double.tryParse(params['maxLat'] ?? '');
final maxLon = double.tryParse(params['maxLon'] ?? '');
if (minLat == null || minLon == null || maxLat == null || maxLon == null) {
return Response.badRequest(body: 'minLat, minLon, maxLat, maxLon required');
}
String r2(double v) => ((v * 100).round() / 100).toString();
final key = '${r2(minLat)},${r2(minLon)},${r2(maxLat)},${r2(maxLon)}';
final now = DateTime.now().millisecondsSinceEpoch;
final cached = _cache[key];
final isStale = cached == null || now - cached.ts >= _ttlMs;
final controller = StreamController<List<int>>();
() async {
// emit cache immediately if we have it
if (cached != null) {
controller.add(_sseBytes(cached.body));
}
// always fetch fresh from TfL if stale
if (isStale) {
final fresh = await _fetchFromTfl(minLat, minLon, maxLat, maxLon);
if (fresh != null) {
_cache[key] = _BusStopEntry(fresh, DateTime.now().millisecondsSinceEpoch);
if (_cache.length > 2000) {
_cache.removeWhere((_, v) =>
DateTime.now().millisecondsSinceEpoch - v.ts > _ttlMs);
}
// only emit if different from what we already sent
if (fresh != cached?.body) {
controller.add(_sseBytes(fresh));
}
}
}
await controller.close();
}();
return Response.ok(
controller.stream,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
);
}
static List<int> _sseBytes(String json) {
return utf8.encode('data: $json\n\n');
}
static Future<String?> _fetchFromTfl(
double minLat,
double minLon,
double maxLat,
double maxLon,
) async {
final centerLat = (minLat + maxLat) / 2;
final centerLon = (minLon + maxLon) / 2;
final radius = _haversineM(minLat, minLon, maxLat, maxLon) / 2;
final r = radius.clamp(200, 1600).toInt();
final uri = Uri.parse(
'https://api.tfl.gov.uk/StopPoint'
'?stopTypes=NaptanPublicBusCoachTram'
'&lat=$centerLat&lon=$centerLon&radius=$r&modes=bus',
);
try {
final upstream = await http.get(uri, headers: {'Accept': 'application/json'});
if (upstream.statusCode != 200) return null;
return upstream.body;
} catch (_) {
return null;
}
}
static double _haversineM(double lat1, double lon1, double lat2, double lon2) {
const r = 6371000.0;
final dLat = (lat2 - lat1) * math.pi / 180;
final dLon = (lon2 - lon1) * math.pi / 180;
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(lat1 * math.pi / 180) *
math.cos(lat2 * math.pi / 180) *
math.sin(dLon / 2) *
math.sin(dLon / 2);
return 2 * r * math.atan2(math.sqrt(a), math.sqrt(1 - a));
}
}

View file

@ -0,0 +1,60 @@
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
// cache routes by waypoints+profile routes dont change often
final Map<String, _RouteEntry> _routeCache = {};
const _routeTtlMs = 60 * 60 * 1000; // 1 hour
class _RouteEntry {
_RouteEntry(this.body, this.ts);
final String body;
final int ts;
}
class RouteHandler {
static const _osrm = 'https://router.project-osrm.org';
Future<Response> handle(Request req) async {
final params = req.url.queryParameters;
final waypoints = params['waypoints'];
final profile = params['profile'] ?? 'driving';
if (waypoints == null || waypoints.isEmpty) {
return Response.badRequest(body: 'waypoints required');
}
final cacheKey = '$profile|$waypoints';
final now = DateTime.now().millisecondsSinceEpoch;
final cached = _routeCache[cacheKey];
if (cached != null && now - cached.ts < _routeTtlMs) {
return Response.ok(
cached.body,
headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT'},
);
}
final uri = Uri.parse(
'$_osrm/route/v1/$profile/$waypoints'
'?overview=full&geometries=geojson',
);
final upstream = await http.get(uri);
if (upstream.statusCode != 200) {
return Response(upstream.statusCode, body: 'OSRM error');
}
_routeCache[cacheKey] = _RouteEntry(upstream.body, now);
if (_routeCache.length > 500) {
_routeCache.removeWhere((_, v) => now - v.ts > _routeTtlMs);
}
return Response.ok(
upstream.body,
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'},
);
}
}

View file

@ -0,0 +1,60 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../cache/tile_cache.dart';
class TileHandler {
TileHandler({required this.mapboxToken});
final String mapboxToken;
static const _upstreamTemplate =
'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token={token}';
Future<Response> handle(Request req) async {
final z = req.params['z']!;
final x = req.params['x']!;
final y = req.params['y']!;
final cacheKey = 'tile/$z/$x/$y';
final cached = TileCache.get(cacheKey);
if (cached != null) {
return _tileResponse(cached, fromCache: true);
}
final url = _upstreamTemplate
.replaceAll('{z}', z)
.replaceAll('{x}', x)
.replaceAll('{y}', y)
.replaceAll('{token}', mapboxToken);
final upstream = await http.get(Uri.parse(url));
if (upstream.statusCode != 200) {
return Response(upstream.statusCode, body: 'upstream error');
}
final bytes = upstream.bodyBytes;
TileCache.put(cacheKey, bytes);
return _tileResponse(bytes, fromCache: false);
}
static Response _tileResponse(Uint8List bytes, {required bool fromCache}) {
final compressed = gzip.encode(bytes);
return Response.ok(
compressed,
headers: {
'Content-Type': 'application/x-protobuf',
'Content-Encoding': 'gzip',
'Cache-Control': 'public, max-age=86400',
'X-Cache': fromCache ? 'HIT' : 'MISS',
},
);
}
}

View file

@ -0,0 +1,24 @@
import 'package:shelf_router/shelf_router.dart';
import 'handlers/bus_stops_handler.dart';
import 'handlers/route_handler.dart';
import 'handlers/tile_handler.dart';
Router buildRouter(String mapboxToken) {
final router = Router();
final tileHandler = TileHandler(mapboxToken: mapboxToken);
final busStopsHandler = BusStopsHandler();
final routeHandler = RouteHandler();
// GET /tiles/{z}/{x}/{y}
router.get('/tiles/<z>/<x>/<y>', tileHandler.handle);
// GET /stops?lat=51.5&lon=-0.1&radius=600
router.get('/stops', busStopsHandler.handle);
// GET /route?waypoints=lon,lat;lon,lat&profile=driving
router.get('/route', routeHandler.handle);
return router;
}

173
backend/pubspec.lock Normal file
View file

@ -0,0 +1,173 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
dotenv:
dependency: "direct main"
description:
name: dotenv
sha256: "379e64b6fc82d3df29461d349a1796ecd2c436c480d4653f3af6872eccbc90e1"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_methods:
dependency: transitive
description:
name: http_methods
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
lints:
dependency: "direct dev"
description:
name: lints
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev"
source: hosted
version: "3.0.0"
meta:
dependency: transitive
description:
name: meta
sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739
url: "https://pub.dev"
source: hosted
version: "1.18.2"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
shelf:
dependency: "direct main"
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_router:
dependency: "direct main"
description:
name: shelf_router
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
url: "https://pub.dev"
source: hosted
version: "1.1.4"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqlite3:
dependency: "direct main"
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.7.0 <4.0.0"

17
backend/pubspec.yaml Normal file
View file

@ -0,0 +1,17 @@
name: rra_backend
description: Backend proxy/cache server for rra_app
version: 1.0.0
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
shelf: ^1.4.0
shelf_router: ^1.1.0
http: ^1.2.0
sqlite3: ^2.4.0
dotenv: ^4.0.0
dev_dependencies:
lints: ^3.0.0

View file

@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict> </dict>
</plist> </plist>

View file

@ -8,9 +8,12 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
26DCCBCFF47641A189F46375 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9E2B72AB7FC986FC7292C27B /* Pods_Runner.framework */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
6A08BC70BBE3CBD0627A4BE1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 81A5C3940BC8570BABCA562A /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@ -40,14 +43,19 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
01BC4FEF809E87D61F7EC1FC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
39AB1A8747EDBAA39C75148E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4D757F63BD16F17354CAFDAE /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; 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>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
81A5C3940BC8570BABCA562A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.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>"; }; 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; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -55,19 +63,42 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; 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>"; }; 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>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9E2B72AB7FC986FC7292C27B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AD097AB0DE1E1D72CD972EE1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
B0768FD4CFE5F6B5F38F8296 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
C69AED9D09BFAE561F2EA023 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
2D2EF8518DC68D268BB75F78 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6A08BC70BBE3CBD0627A4BE1 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = { 97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
26DCCBCFF47641A189F46375 /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
0FA9A9998E29E2A691A2A3BB /* Frameworks */ = {
isa = PBXGroup;
children = (
9E2B72AB7FC986FC7292C27B /* Pods_Runner.framework */,
81A5C3940BC8570BABCA562A /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = { 331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -79,6 +110,7 @@
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@ -94,6 +126,8 @@
97C146F01CF9000F007C117D /* Runner */, 97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
B4CAA32E841ED9413BF9A155 /* Pods */,
0FA9A9998E29E2A691A2A3BB /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -121,6 +155,20 @@
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B4CAA32E841ED9413BF9A155 /* Pods */ = {
isa = PBXGroup;
children = (
39AB1A8747EDBAA39C75148E /* Pods-Runner.debug.xcconfig */,
AD097AB0DE1E1D72CD972EE1 /* Pods-Runner.release.xcconfig */,
C69AED9D09BFAE561F2EA023 /* Pods-Runner.profile.xcconfig */,
4D757F63BD16F17354CAFDAE /* Pods-RunnerTests.debug.xcconfig */,
B0768FD4CFE5F6B5F38F8296 /* Pods-RunnerTests.release.xcconfig */,
01BC4FEF809E87D61F7EC1FC /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -128,8 +176,10 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = ( buildPhases = (
25C458846219BE655061800B /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */, 331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */, 331C807F294A63A400263BE5 /* Resources */,
2D2EF8518DC68D268BB75F78 /* Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -145,6 +195,7 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
296FAA439AE10256186EEEC8 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
@ -157,6 +208,9 @@
dependencies = ( dependencies = (
); );
name = Runner; name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner; productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@ -190,6 +244,9 @@
Base, Base,
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@ -222,6 +279,50 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
25C458846219BE655061800B /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
296FAA439AE10256186EEEC8 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -379,6 +480,7 @@
}; };
331C8088294A63A400263BE5 /* Debug */ = { 331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 4D757F63BD16F17354CAFDAE /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@ -396,6 +498,7 @@
}; };
331C8089294A63A400263BE5 /* Release */ = { 331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = B0768FD4CFE5F6B5F38F8296 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@ -411,6 +514,7 @@
}; };
331C808A294A63A400263BE5 /* Profile */ = { 331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 01BC4FEF809E87D61F7EC1FC /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@ -614,6 +718,20 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 97C146E61CF9000F007C117D /* Project object */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
} }

View file

@ -5,6 +5,24 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"

View file

@ -4,4 +4,7 @@
<FileRef <FileRef
location = "group:Runner.xcodeproj"> location = "group:Runner.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace> </Workspace>

View file

@ -2,12 +2,15 @@ import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
} }

View file

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@ -24,6 +26,29 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@ -41,9 +66,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict> </dict>
</plist> </plist>

View file

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
const String kBackendBaseUrl = 'http://192.168.0.25:8080';
final Color kMapBackgroundColor = Color(0xFFA7BFDB); final Color kMapBackgroundColor = Color(0xFFA7BFDB);
final Color kMapLanduseColor = Color(0xFFE500FE); final Color kMapLanduseColor = Color(0xFFE500FE);
final Color kMapGrassColor = Color(0xFFA2CE83); final Color kMapGrassColor = Color(0xFF9EC582);
final Color kMapWaterColor = Color(0xFF74B8ED); final Color kMapWaterColor = Color(0xFF74B8ED);
final Color kMapBuildingFillColor = Color(0xFFFED000); final Color kMapBuildingFillColor = Color(0xFFFED000);
final Color kMapBuildingOutlineColor = Color(0xFFE7B600); final Color kMapBuildingOutlineColor = Color(0xFFE7B600);
@ -32,3 +34,6 @@ final Color kMapPoiLabelColor = Color(0xFF032D51);
final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF); final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF);
const bool kMapDebugShowTileBoundaries = false; const bool kMapDebugShowTileBoundaries = false;
const bool kMapDebugShowPlaceSymbolRank = false;
const bool kMapUseSymbolRankAsZoomGate = true;

View file

@ -14,6 +14,7 @@ import 'package:flutter/scheduler.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:latlong2/latlong.dart' show LatLng; import 'package:latlong2/latlong.dart' show LatLng;
import 'package:rra_app/pages/map/constants.dart';
import 'package:rra_app/pages/map/routing/osrm_routing_service.dart'; import 'package:rra_app/pages/map/routing/osrm_routing_service.dart';
import 'package:rra_app/pages/map/tfl/tfl_bus_stop_cache.dart'; import 'package:rra_app/pages/map/tfl/tfl_bus_stop_cache.dart';
import 'package:rra_app/pages/map/tfl/tfl_bus_stop_service.dart'; import 'package:rra_app/pages/map/tfl/tfl_bus_stop_service.dart';
@ -48,13 +49,13 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
static const _interactionIdleDelay = Duration(milliseconds: 280); static const _interactionIdleDelay = Duration(milliseconds: 280);
static const _fpsWindowSize = 90; static const _fpsWindowSize = 90;
static const _fpsUpdateInterval = Duration(milliseconds: 240); static const _fpsUpdateInterval = Duration(milliseconds: 240);
static const _tileUrlTemplate = static const _tileUrlTemplate = '$kBackendBaseUrl/tiles/{z}/{x}/{y}';
'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=pk.eyJ1IjoiaW1iZW5qaW5ldCIsImEiOiJjbW01azQ1bTcwODJ5MnBzOG9tMTUxdGRoIn0.z22taz_5I_8D5GuDlser_Q';
final GlobalKey _mapKey = GlobalKey(); final GlobalKey _mapKey = GlobalKey();
final OsrmRoutingService _routingService = const OsrmRoutingService(); final OsrmRoutingService _routingService = const OsrmRoutingService();
final List<LatLng> _points = <LatLng>[]; final List<LatLng> _points = <LatLng>[];
final List<String?> _pointLabels = <String?>[];
List<LatLng> _resolvedRoute = <LatLng>[]; List<LatLng> _resolvedRoute = <LatLng>[];
LatLng _mapCenter = _london; LatLng _mapCenter = _london;
@ -68,6 +69,8 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
late final Ticker _zoomTicker; late final Ticker _zoomTicker;
double _lastPinchScale = 1.0; double _lastPinchScale = 1.0;
double _lastScaleValue = 1.0;
Offset _lastScaleFocal = Offset.zero;
Offset _pendingPanDelta = Offset.zero; Offset _pendingPanDelta = Offset.zero;
bool _panFrameScheduled = false; bool _panFrameScheduled = false;
Offset _pendingPanZoomDelta = Offset.zero; Offset _pendingPanZoomDelta = Offset.zero;
@ -87,8 +90,9 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
final TflBusStopService _busStopService = TflBusStopService(); final TflBusStopService _busStopService = TflBusStopService();
List<BusStop> _busStops = const []; List<BusStop> _busStops = const [];
Timer? _busStopFetchTimer; Timer? _busStopFetchTimer;
// zoom threshold below which we dont bother fetching LatLng? _lastFetchedCenter;
static const _busStopMinZoom = 18.0; double? _lastFetchedZoom;
static const _busStopMinZoom = 14.0;
late final TilePyramidPainter _pyramidPainter; late final TilePyramidPainter _pyramidPainter;
@ -163,9 +167,10 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
}); });
} }
void _addPoint(LatLng point) { void _addPoint(LatLng point, {String? label}) {
setState(() { setState(() {
_points.add(point); _points.add(point);
_pointLabels.add(label);
}); });
_resolveRoute(); _resolveRoute();
} }
@ -174,6 +179,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
if (_points.isEmpty) return; if (_points.isEmpty) return;
setState(() { setState(() {
_points.removeLast(); _points.removeLast();
_pointLabels.removeLast();
}); });
_resolveRoute(); _resolveRoute();
} }
@ -182,6 +188,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
if (_points.isEmpty) return; if (_points.isEmpty) return;
setState(() { setState(() {
_points.clear(); _points.clear();
_pointLabels.clear();
_resolvedRoute = <LatLng>[]; _resolvedRoute = <LatLng>[];
_routeError = null; _routeError = null;
_isResolvingRoute = false; _isResolvingRoute = false;
@ -394,7 +401,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
..devicePixelRatio = _devicePixelRatio ..devicePixelRatio = _devicePixelRatio
..interactionActive = _interactionIdleTimer?.isActive ?? false; ..interactionActive = _interactionIdleTimer?.isActive ?? false;
_pyramidPainter.markDirty(); _pyramidPainter.markDirty();
_scheduleBusStopFetch(); _maybeScheduleBusStopFetch();
} }
LatLng _segmentMidpoint(LatLng a, LatLng b) { LatLng _segmentMidpoint(LatLng a, LatLng b) {
@ -413,6 +420,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
final insertIndex = segmentIndex + 1; final insertIndex = segmentIndex + 1;
setState(() { setState(() {
_points.insert(insertIndex, midpoint); _points.insert(insertIndex, midpoint);
_pointLabels.insert(insertIndex, null);
_draggingInsertPointIndex = insertIndex; _draggingInsertPointIndex = insertIndex;
_isAddPointArmed = false; _isAddPointArmed = false;
_resolvedRoute = _points.toList(); _resolvedRoute = _points.toList();
@ -461,6 +469,11 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
}); });
} }
void _addBusStopToRoute(BusStop stop) {
setState(() => _isAddPointArmed = false);
_addPoint(stop.position, label: stop.name);
}
void _inspectBusStop(BusStop stop) { void _inspectBusStop(BusStop stop) {
final props = <String, Object?>{ final props = <String, Object?>{
'name': stop.name, 'name': stop.name,
@ -485,14 +498,31 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
}); });
} }
void _scheduleBusStopFetch() { void _maybeScheduleBusStopFetch() {
_busStopFetchTimer?.cancel();
if (_zoom < _busStopMinZoom) { if (_zoom < _busStopMinZoom) {
if (_busStops.isNotEmpty) setState(() => _busStops = const []); if (_busStops.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _busStops = const []);
});
}
_lastFetchedCenter = null;
_lastFetchedZoom = null;
return; return;
} }
_busStopFetchTimer = Timer(const Duration(milliseconds: 600), () { // dont re-debounce if the viewport hasnt moved meaningfully
final zoomChanged = _lastFetchedZoom == null ||
(_zoom - _lastFetchedZoom!).abs() > 0.5;
final centerMoved = _lastFetchedCenter == null ||
(_mapCenter.latitude - _lastFetchedCenter!.latitude).abs() > 0.002 ||
(_mapCenter.longitude - _lastFetchedCenter!.longitude).abs() > 0.002;
if (!zoomChanged && !centerMoved) return;
_busStopFetchTimer?.cancel();
_busStopFetchTimer = Timer(const Duration(milliseconds: 400), () {
_lastFetchedCenter = _mapCenter;
_lastFetchedZoom = _zoom;
_fetchBusStops(); _fetchBusStops();
}); });
} }
@ -500,27 +530,24 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
Future<void> _fetchBusStops() async { Future<void> _fetchBusStops() async {
if (_zoom < _busStopMinZoom || !mounted) return; if (_zoom < _busStopMinZoom || !mounted) return;
final cacheKey = TflBusStopCache.keyFor(_mapCenter);
// phase 1: show cached stops immediately
final cached = TflBusStopCache.peek(cacheKey);
if (cached != null && mounted) {
setState(() => _busStops = cached);
}
// phase 2: fetch from TfL and refresh
final mpp = TflBusStopService.metersPerPixel(_mapCenter.latitude, _zoom); final mpp = TflBusStopService.metersPerPixel(_mapCenter.latitude, _zoom);
final halfDiag = (_mapSize.longestSide / 2) * mpp; final halfWidthM = (_mapSize.width / 2) * mpp;
final radius = halfDiag.clamp(200, 800).toInt(); final halfHeightM = (_mapSize.height / 2) * mpp;
const metersPerDegLat = 111320.0;
final latOffset = halfHeightM / metersPerDegLat;
final lonOffset = halfWidthM / (metersPerDegLat * math.cos(_mapCenter.latitude * math.pi / 180));
final sw = LatLng(_mapCenter.latitude - latOffset, _mapCenter.longitude - lonOffset);
final ne = LatLng(_mapCenter.latitude + latOffset, _mapCenter.longitude + lonOffset);
final cacheKey = TflBusStopCache.keyFor(sw, ne);
try { try {
final stops = await _busStopService.fetchNearby( await for (final stops in _busStopService.fetchInRect(sw, ne)) {
_mapCenter, if (!mounted) return;
radius: radius, setState(() => _busStops = stops);
); await TflBusStopCache.store(cacheKey, stops);
if (!mounted) return; }
setState(() => _busStops = stops);
await TflBusStopCache.store(cacheKey, stops);
} catch (_) { } catch (_) {
// silently swallow // silently swallow
} }
@ -543,7 +570,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
// repulsion pass push overlapping markers apart in screen space // repulsion pass push overlapping markers apart in screen space
// only affects rendering positions, not the underlying LatLng data // only affects rendering positions, not the underlying LatLng data
const minDist = 28.0; final minDist = _zoom < 17.5 ? 18.0 : 32.0;
const iterations = 6; const iterations = 6;
for (var iter = 0; iter < iterations; iter++) { for (var iter = 0; iter < iterations; iter++) {
@ -577,14 +604,20 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
final screen = positions[i]; final screen = positions[i];
widgets.add( widgets.add(
Positioned( Positioned(
left: screen.dx - 13, left: screen.dx - (_zoom < 17.5 ? 8 : 15),
top: screen.dy - 13, top: screen.dy - (_zoom < 17.5 ? 8 : 15),
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => _inspectBusStop(stop), onTap: () => _isAddPointArmed
child: Tooltip( ? _addBusStopToRoute(stop)
message: stop.name, : _inspectBusStop(stop),
child: _BusStopMarker(stop: stop), child: shadcn.Tooltip(
tooltip: (context) => shadcn.TooltipContainer(
child: Text(stop.name),
),
alignment: Alignment.centerRight,
anchorAlignment: Alignment.centerLeft,
child: _BusStopMarker(stop: stop, compact: _zoom < 17.5),
), ),
), ),
), ),
@ -681,6 +714,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
final routePoints = _resolvedRoute.isNotEmpty ? _resolvedRoute : _points; final routePoints = _resolvedRoute.isNotEmpty ? _resolvedRoute : _points;
final routeScreenPoints = routePoints.map(_latLngToScreen).toList(); final routeScreenPoints = routePoints.map(_latLngToScreen).toList();
_pyramidPainter.routeScreenPoints = routeScreenPoints;
return Scaffold( return Scaffold(
body: Stack( body: Stack(
@ -744,9 +778,34 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
child: GestureDetector( child: GestureDetector(
key: _mapKey, key: _mapKey,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onPanUpdate: (details) { onScaleStart: (details) {
_lastScaleValue = 1.0;
_lastScaleFocal = details.localFocalPoint;
_noteInteraction();
},
onScaleUpdate: (details) {
if (_draggingInsertPointIndex != null) return; if (_draggingInsertPointIndex != null) return;
_queuePan(details.delta);
final panDelta = details.localFocalPoint - _lastScaleFocal;
_lastScaleFocal = details.localFocalPoint;
final scaleChange = details.scale / _lastScaleValue;
_lastScaleValue = details.scale;
if ((scaleChange - 1.0).abs() > 0.001) {
_noteInteraction();
setState(() {
_setZoomAroundFocal(
_zoom + math.log(scaleChange) / math.ln2,
details.localFocalPoint,
);
_targetZoom = _zoom;
});
}
if (panDelta != Offset.zero) {
_queuePan(panDelta);
}
}, },
onTapUp: (details) { onTapUp: (details) {
if (_draggingInsertPointIndex != null) return; if (_draggingInsertPointIndex != null) return;
@ -780,6 +839,13 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
..._buildBusStopWidgets(), ..._buildBusStopWidgets(),
..._buildPointWidgets(), ..._buildPointWidgets(),
..._buildInsertHandles(), ..._buildInsertHandles(),
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: TileLabelPainter(_pyramidPainter),
),
),
),
], ],
), ),
), ),
@ -876,6 +942,14 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
), ),
), ),
), ),
Positioned(
bottom: 34,
left: 10,
child: _RoutePanel(
points: _points,
labels: _pointLabels,
),
),
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,
@ -908,14 +982,14 @@ class _RoutePainter extends CustomPainter {
final white = Paint() final white = Paint()
..color = Colors.white ..color = Colors.white
..strokeWidth = 9 ..strokeWidth = 14
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round ..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
final route = Paint() final route = Paint()
..color = routeColor ..color = routeColor
..strokeWidth = 5 ..strokeWidth = 9
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round ..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round;
@ -1212,21 +1286,170 @@ class _StatusPanel extends StatelessWidget {
} }
} }
class _BusStopMarker extends StatelessWidget { class _RoutePanel extends StatelessWidget {
const _BusStopMarker({required this.stop}); const _RoutePanel({required this.points, required this.labels});
final BusStop stop; final List<LatLng> points;
final List<String?> labels;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (points.isEmpty) return const SizedBox.shrink();
const stopColor = Color(0xFFD50000);
const waypointColor = Color(0xFF2563EB);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 260, maxHeight: 320),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x22000000)),
boxShadow: const [
BoxShadow(
color: Color(0x22000000),
blurRadius: 8,
spreadRadius: 1,
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Route',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: Color(0xFF111827),
),
),
const SizedBox(height: 8),
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < points.length; i++) ...[
if (i > 0)
Padding(
padding: const EdgeInsets.only(left: 9),
child: Container(
width: 2,
height: 10,
color: const Color(0xFFE02425),
),
),
_RoutePanelStop(
index: i,
total: points.length,
label: labels[i],
position: points[i],
stopColor: stopColor,
waypointColor: waypointColor,
),
],
],
),
),
),
],
),
),
),
);
}
}
class _RoutePanelStop extends StatelessWidget {
const _RoutePanelStop({
required this.index,
required this.total,
required this.label,
required this.position,
required this.stopColor,
required this.waypointColor,
});
final int index;
final int total;
final String? label;
final LatLng position;
final Color stopColor;
final Color waypointColor;
@override
Widget build(BuildContext context) {
final isBusStop = label != null;
final isFirst = index == 0;
final isLast = index == total - 1;
final dotColor = isFirst
? const Color(0xFF0F9D58)
: (isLast ? const Color(0xFFE11D48) : waypointColor);
final displayLabel = label ?? '${position.latitude.toStringAsFixed(4)}, ${position.longitude.toStringAsFixed(4)}';
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: isBusStop ? stopColor : dotColor,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: isBusStop
? const Icon(Icons.directions_bus, size: 10, color: Colors.white)
: null,
),
const SizedBox(width: 8),
Expanded(
child: Text(
displayLabel,
style: TextStyle(
fontSize: 12,
fontWeight: isBusStop ? FontWeight.w600 : FontWeight.w400,
color: const Color(0xFF1F2937),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
class _BusStopMarker extends StatelessWidget {
const _BusStopMarker({required this.stop, this.compact = false});
final BusStop stop;
final bool compact;
@override
Widget build(BuildContext context) {
final size = compact ? 16.0 : 30.0;
final borderWidth = compact ? 2.0 : 3.0;
return SizedBox( return SizedBox(
width: 26, width: size,
height: 26, height: size,
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFD50000), color: const Color(0xFFD50000),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5), border: Border.all(color: Colors.white, width: borderWidth),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Color(0x44000000), color: Color(0x44000000),
@ -1235,17 +1458,23 @@ class _BusStopMarker extends StatelessWidget {
), ),
], ],
), ),
child: Center( child: compact
child: Text( ? null
stop.stopLetter ?? 'B', : Center(
style: TextStyle( child: Builder(builder: (context) {
fontSize: stop.stopLetter != null && stop.stopLetter!.length > 1 ? 9 : 11, final raw = stop.stopLetter ?? 'B';
fontWeight: FontWeight.w900, final letter = raw.replaceAll(RegExp(r'^-+>'), '').trim();
color: Colors.white, return Text(
height: 1, letter.isEmpty ? 'B' : letter,
), style: TextStyle(
), fontSize: letter.length > 1 ? 9 : 11,
), fontWeight: FontWeight.w900,
color: Colors.white,
height: 1,
),
);
}),
),
), ),
); );
} }

View file

@ -2,10 +2,11 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:rra_app/pages/map/constants.dart';
class OsrmRoutingService { class OsrmRoutingService {
const OsrmRoutingService({ const OsrmRoutingService({
this.baseUrl = 'https://router.project-osrm.org', this.baseUrl = kBackendBaseUrl,
this.profile = 'driving', this.profile = 'driving',
}); });
@ -21,8 +22,7 @@ class OsrmRoutingService {
.map((point) => '${point.longitude},${point.latitude}') .map((point) => '${point.longitude},${point.latitude}')
.join(';'); .join(';');
final uri = Uri.parse( final uri = Uri.parse(
'$baseUrl/route/v1/$profile/$coordinates' '$baseUrl/route?waypoints=$coordinates&profile=$profile',
'?overview=full&geometries=geojson',
); );
final response = await http.get(uri); final response = await http.get(uri);

View file

@ -26,11 +26,10 @@ class TflBusStopCache {
} }
} }
// rounds to ~1km grid cells so nearby pans reuse the same cache entry // rounds bbox corners to ~1km grid so nearby pans reuse the same entry
static String keyFor(LatLng center) { static String keyFor(LatLng sw, LatLng ne) {
final lat = (center.latitude * 100).round() / 100; double r(double v) => (v * 100).round() / 100;
final lon = (center.longitude * 100).round() / 100; return '${r(sw.latitude)},${r(sw.longitude)},${r(ne.latitude)},${r(ne.longitude)}';
return '$lat,$lon';
} }
static List<BusStop>? peek(String key) { static List<BusStop>? peek(String key) {

View file

@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:rra_app/pages/map/constants.dart';
class BusStop { class BusStop {
const BusStop({ const BusStop({
@ -17,82 +19,94 @@ class BusStop {
final String name; final String name;
final LatLng position; final LatLng position;
// the letter on the physical bus stop flag e.g. "A", "ZH"
final String? stopLetter; final String? stopLetter;
// may be null for parent stops
final String? towards; final String? towards;
} }
class TflBusStopService { class TflBusStopService {
TflBusStopService(); TflBusStopService();
static const _baseUrl = 'https://api.tfl.gov.uk'; // streams bus stops emits once from cache, then again from TfL if fresher
Stream<List<BusStop>> fetchInRect(LatLng sw, LatLng ne) async* {
// max radius the TfL API accepts is 1600m but in practice
// we clamp to 800 so we dont get swamped at low zoom
static const _maxRadius = 800;
Future<List<BusStop>> fetchNearby(LatLng center, {int radius = 600}) async {
final r = radius.clamp(0, _maxRadius);
final uri = Uri.parse( final uri = Uri.parse(
'$_baseUrl/StopPoint' '$kBackendBaseUrl/stops'
'?stopTypes=NaptanPublicBusCoachTram' '?minLat=${sw.latitude}'
'&lat=${center.latitude}' '&minLon=${sw.longitude}'
'&lon=${center.longitude}' '&maxLat=${ne.latitude}'
'&radius=$r' '&maxLon=${ne.longitude}',
'&modes=bus',
); );
final response = await http.get(uri, headers: { final client = http.Client();
'Accept': 'application/json', try {
}); final request = http.Request('GET', uri);
final streamed = await client.send(request);
if (response.statusCode != 200) { final buffer = StringBuffer();
throw Exception('TfL StopPoint request failed (${response.statusCode})');
}
final body = jsonDecode(response.body) as Map<String, dynamic>; await for (final chunk in streamed.stream.transform(utf8.decoder)) {
final stopPoints = body['stopPoints']; buffer.write(chunk);
if (stopPoints is! List) return const []; final text = buffer.toString();
final stops = <BusStop>[]; // SSE events are delimited by \n\n
for (final raw in stopPoints) { int idx;
if (raw is! Map<String, dynamic>) continue; var remaining = text;
while ((idx = remaining.indexOf('\n\n')) != -1) {
final event = remaining.substring(0, idx).trim();
remaining = remaining.substring(idx + 2);
final lat = (raw['lat'] as num?)?.toDouble(); if (event.startsWith('data: ')) {
final lon = (raw['lon'] as num?)?.toDouble(); final json = event.substring(6);
if (lat == null || lon == null) continue; final stops = _parseStops(json);
if (stops != null) yield stops;
final id = raw['id'] as String? ?? '';
final name = raw['commonName'] as String? ?? '';
final stopLetter = raw['stopLetter'] as String?;
String? towards;
final addInfo = raw['additionalProperties'];
if (addInfo is List) {
for (final prop in addInfo) {
if (prop is Map && prop['key'] == 'Towards') {
towards = prop['value'] as String?;
break;
} }
} }
buffer.clear();
buffer.write(remaining);
} }
} finally {
stops.add(BusStop( client.close();
id: id, }
name: name, }
position: LatLng(lat, lon),
stopLetter: stopLetter, static List<BusStop>? _parseStops(String json) {
towards: towards, try {
)); final body = jsonDecode(json) as Map<String, dynamic>;
final stopPoints = body['stopPoints'];
if (stopPoints is! List) return null;
final stops = <BusStop>[];
for (final raw in stopPoints) {
if (raw is! Map<String, dynamic>) continue;
final lat = (raw['lat'] as num?)?.toDouble();
final lon = (raw['lon'] as num?)?.toDouble();
if (lat == null || lon == null) continue;
String? towards;
final addInfo = raw['additionalProperties'];
if (addInfo is List) {
for (final prop in addInfo) {
if (prop is Map && prop['key'] == 'Towards') {
towards = prop['value'] as String?;
break;
}
}
}
stops.add(BusStop(
id: raw['id'] as String? ?? '',
name: raw['commonName'] as String? ?? '',
position: LatLng(lat, lon),
stopLetter: raw['stopLetter'] as String?,
towards: towards,
));
}
return stops;
} catch (_) {
return null;
} }
return stops;
} }
// rough haversine distance in metres, good enough for radius estimation
static double metersPerPixel(double lat, double zoom) { static double metersPerPixel(double lat, double zoom) {
const earthCircumference = 40075016.686; const earthCircumference = 40075016.686;
final latRad = lat * math.pi / 180.0; final latRad = lat * math.pi / 180.0;

View file

@ -1,13 +1,23 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart'; import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
import 'package:rra_app/pages/map/vector/mvt_parser.dart'; import 'package:rra_app/pages/map/vector/mvt_parser.dart';
// top-level so compute() can find it
MvtTile? _parseMvtBackground(({Uint8List bytes, Set<String> layerNames}) args) {
try {
return const MvtParser().parse(args.bytes, layerNames: args.layerNames);
} catch (_) {
return null;
}
}
class MapboxVectorTileCache { class MapboxVectorTileCache {
MapboxVectorTileCache._(); MapboxVectorTileCache._();
static final MvtParser _parser = const MvtParser();
static const Set<String> _styleLayerWhitelist = <String>{ static const Set<String> _styleLayerWhitelist = <String>{
'water', 'water',
'landuse', 'landuse',
@ -18,12 +28,16 @@ class MapboxVectorTileCache {
'place_label', 'place_label',
'poi_label', 'poi_label',
}; };
static const int _maxEntries = 220; static final bool _isMobile =
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
static final int _maxEntries = _isMobile ? 60 : 220;
static final LinkedHashMap<String, MvtTile> _cache = static final LinkedHashMap<String, MvtTile> _cache =
LinkedHashMap<String, MvtTile>(); LinkedHashMap<String, MvtTile>();
static final Map<String, Future<MvtTile?>> _inFlight = static final Map<String, Future<MvtTile?>> _inFlight =
<String, Future<MvtTile?>>{}; <String, Future<MvtTile?>>{};
static bool isFetching(String url) => _inFlight.containsKey(url);
static MvtTile? peek(String url) { static MvtTile? peek(String url) {
final cached = _cache.remove(url); final cached = _cache.remove(url);
if (cached != null) { if (cached != null) {
@ -48,21 +62,25 @@ class MapboxVectorTileCache {
static Future<MvtTile?> _fetch(String url) async { static Future<MvtTile?> _fetch(String url) async {
final bytes = await HiveTileCache.getOrFetch(url); final bytes = await HiveTileCache.getOrFetch(url);
if (bytes == null || bytes.isEmpty) return null; if (bytes == null || bytes.isEmpty) return null;
final tile = _parse(bytes);
// dart:ui Offset doesn't survive structured-clone on web workers,
// so on web we parse synchronously on the main thread
MvtTile? tile;
if (kIsWeb) {
tile = _parseMvtBackground((bytes: bytes, layerNames: _styleLayerWhitelist));
} else {
tile = await compute(
_parseMvtBackground,
(bytes: bytes, layerNames: _styleLayerWhitelist),
);
}
if (tile == null) return null; if (tile == null) return null;
_cache[url] = tile; _cache[url] = tile;
_trim(); _trim();
return tile; return tile;
} }
static MvtTile? _parse(Uint8List bytes) {
try {
return _parser.parse(bytes, layerNames: _styleLayerWhitelist);
} catch (_) {
return null;
}
}
static void _trim() { static void _trim() {
while (_cache.length > _maxEntries) { while (_cache.length > _maxEntries) {
_cache.remove(_cache.keys.first); _cache.remove(_cache.keys.first);

View file

@ -1,6 +1,9 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
class _TileCoord { class _TileCoord {
const _TileCoord(this.z, this.x, this.y); const _TileCoord(this.z, this.x, this.y);
@ -27,13 +30,18 @@ class CachedTile {
class TilePyramidCache { class TilePyramidCache {
TilePyramidCache._(); TilePyramidCache._();
static const int _maxEntries = 384; static final bool _isMobile =
static const int _maxApproxBytes = 768 * 1024 * 1024; !kIsWeb && (Platform.isAndroid || Platform.isIOS);
static final int _maxEntries = kIsWeb ? 64 : (_isMobile ? 96 : 384);
static final int _maxApproxBytes = kIsWeb
? 64 * 1024 * 1024
: (_isMobile ? 80 * 1024 * 1024 : 768 * 1024 * 1024);
// Cap concurrent renders to avoid saturating the main isolate with // Cap concurrent renders to avoid saturating the main isolate with
// synchronous canvas recording. I/O (network/Hive) is still concurrent // synchronous canvas recording. I/O (network/Hive) is still concurrent
// up to this limit. // up to this limit.
static const int _maxConcurrent = 4; static final int _maxConcurrent = kIsWeb ? 2 : (_isMobile ? 2 : 4);
static final LinkedHashMap<_TileCoord, CachedTile> _cache = static final LinkedHashMap<_TileCoord, CachedTile> _cache =
LinkedHashMap<_TileCoord, CachedTile>(); LinkedHashMap<_TileCoord, CachedTile>();

View file

@ -1,6 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
@ -10,7 +11,8 @@ import 'package:rra_app/pages/map/vector/mvt_parser.dart';
import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart'; import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart';
const int _kMaxAncestorLookup = 4; const int _kMaxAncestorLookup = 4;
const double _kTileResolutionScale = 2.0; // web WASM heap is limited use 1x resolution to keep memory under control
final double _kTileResolutionScale = kIsWeb ? 1.0 : 2.0;
const double _kLineReferenceZoom = 14.0; const double _kLineReferenceZoom = 14.0;
class TilePyramidPainter extends ChangeNotifier implements CustomPainter { class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
@ -37,12 +39,26 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
int? fallbackTileZoom; int? fallbackTileZoom;
double devicePixelRatio = 1; double devicePixelRatio = 1;
bool interactionActive = false; bool interactionActive = false;
List<Offset> routeScreenPoints = const [];
// cache of tile-local road label placements keyed by "$url:$featureIndex" // cache of tile-local road label placements keyed by "$url:$featureIndex"
// placement is stored in tile extent coordinates so it never changes on pan // placement is stored in tile extent coordinates so it never changes on pan
final Map<String, _RoadLabelPlacement?> _roadLabelPlacementCache = {}; final Map<String, _RoadLabelPlacement?> _roadLabelPlacementCache = {};
void markDirty() => notifyListeners(); // laid-out TextPainters keyed by "text|fontSize" layout is expensive
final Map<String, TextPainter> _textPainterCache = {};
static const int _maxTextPainterCacheSize = 600;
// world-space road label cache rebuilt only when tiles change or zoom changes
List<_WorldRoadLabel>? _roadLabelWorld;
int _tileVersion = 0;
int _roadLabelCacheVersion = -1;
double _roadLabelCacheZoom = -1.0;
void markDirty() {
_tileVersion++;
notifyListeners();
}
String _tileUrl(int z, int x, int y) => tileUrlTemplate String _tileUrl(int z, int x, int y) => tileUrlTemplate
.replaceAll('{z}', '$z') .replaceAll('{z}', '$z')
@ -106,6 +122,10 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
} }
_drawTileLayer(canvas, activeTileZoom, queueTiles: true); _drawTileLayer(canvas, activeTileZoom, queueTiles: true);
}
void paintLabels(Canvas canvas) {
if (mapWidth == 0 || mapHeight == 0) return;
_drawAllRoadLabels(canvas); _drawAllRoadLabels(canvas);
_drawAllPlaceLabels(canvas); _drawAllPlaceLabels(canvas);
} }
@ -128,7 +148,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
final minTY = (minWY / tileSize).floor() - tileOverscan; final minTY = (minWY / tileSize).floor() - tileOverscan;
final maxTY = (maxWY / tileSize).floor() + tileOverscan; final maxTY = (maxWY / tileSize).floor() + tileOverscan;
if (queueTiles) { if (queueTiles && !kIsWeb) {
final allowed = _allowedQueuedTileHashes( final allowed = _allowedQueuedTileHashes(
z, z,
minTX, minTX,
@ -160,7 +180,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
} }
} }
if (queueTiles) { if (queueTiles && !kIsWeb) {
_queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex); _queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex);
if (!interactionActive) { if (!interactionActive) {
@ -213,6 +233,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
} }
void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) { void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) {
// on web, skip the rasterization cache entirely and paint directly
if (kIsWeb) {
_drawTileWeb(canvas, z, x, y, dst);
return;
}
final native = TilePyramidCache.peek(z, x, y); final native = TilePyramidCache.peek(z, x, y);
if (native != null) { if (native != null) {
_blitImage( _blitImage(
@ -251,9 +277,54 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
_blitImage(canvas, ancestor.image, src, dst); _blitImage(canvas, ancestor.image, src, dst);
break; break;
} }
}
if (!TilePyramidCache.isInFlight(z, x, y)) { void _drawTileWeb(Canvas canvas, int z, int x, int y, Rect dst) {
_queueTile(z, x, y); final url = _tileUrl(z, x, y);
final tile = MapboxVectorTileCache.peek(url);
if (tile != null) {
canvas.save();
canvas.clipRect(dst);
canvas.translate(dst.left, dst.top);
canvas.scale(dst.width / tileSize, dst.height / tileSize);
_paintTile(canvas, Size.square(tileSize), tile, tileZoom: z.toDouble());
canvas.restore();
return;
}
// fetch if not cached yet
if (!MapboxVectorTileCache.isFetching(url)) {
MapboxVectorTileCache.getOrFetch(url).then((_) => markDirty());
}
// show ancestor tile scaled up while loading
final maxIndex = 1 << z;
for (var dz = 1; dz <= _kMaxAncestorLookup; dz++) {
final az = z - dz;
if (az < minTileZoom) break;
final subdivision = 1 << dz;
final ancestorX = x >> dz;
final ancestorY = y >> dz;
final ancestorUrl = _tileUrl(az, ((ancestorX % (maxIndex >> dz)) + (maxIndex >> dz)) % (maxIndex >> dz), ancestorY);
final ancestor = MapboxVectorTileCache.peek(ancestorUrl);
if (ancestor == null) continue;
final fracX = (x - ancestorX * subdivision).toDouble() / subdivision;
final fracY = (y - ancestorY * subdivision).toDouble() / subdivision;
final srcLeft = fracX * tileSize;
final srcTop = fracY * tileSize;
final srcSize = tileSize / subdivision;
canvas.save();
canvas.clipRect(dst);
canvas.translate(dst.left, dst.top);
canvas.scale(dst.width / srcSize, dst.height / srcSize);
canvas.translate(-srcLeft, -srcTop);
_paintTile(canvas, Size.square(tileSize), ancestor, tileZoom: az.toDouble());
canvas.restore();
break;
} }
} }
@ -264,8 +335,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
void _queueTile(int z, int x, int y) { void _queueTile(int z, int x, int y) {
final url = _tileUrl(z, x, y); final url = _tileUrl(z, x, y);
final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale)
.round(); // cap render resolution so high-DPI mobile doesnt blow the image cache
// (256 * 3dpr * 2x scale = 1536px = 9MB per tile, way too big)
// 512px = 1MB per tile, fits ~80 tiles in the mobile budget
final dprCapped = devicePixelRatio.clamp(1.0, 2.0);
final rasterPx = (tileSize * dprCapped * _kTileResolutionScale).round();
TilePyramidCache.enqueue( TilePyramidCache.enqueue(
z, z,
@ -296,6 +371,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
int visMinTY = 0, int visMinTY = 0,
int visMaxTY = 0, int visMaxTY = 0,
}) { }) {
// centerWorldX/Y are already in pixel coords at activeTileZoom scale
final centerTX = centerWorldX / tileSize;
final centerTY = centerWorldY / tileSize;
final pending = <(int tx, int ty, int wrappedX, double dist)>[];
for (var tx = minTX; tx <= maxTX; tx++) { for (var tx = minTX; tx <= maxTX; tx++) {
for (var ty = minTY; ty <= maxTY; ty++) { for (var ty = minTY; ty <= maxTY; ty++) {
if (ty < 0 || ty >= maxIndex) continue; if (ty < 0 || ty >= maxIndex) continue;
@ -310,9 +391,17 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex; final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
if (TilePyramidCache.peek(z, wrappedX, ty) != null) continue; if (TilePyramidCache.peek(z, wrappedX, ty) != null) continue;
if (TilePyramidCache.isInFlight(z, wrappedX, ty)) continue; if (TilePyramidCache.isInFlight(z, wrappedX, ty)) continue;
_queueTile(z, wrappedX, ty);
final dx = tx + 0.5 - centerTX;
final dy = ty + 0.5 - centerTY;
pending.add((tx, ty, wrappedX, dx * dx + dy * dy));
} }
} }
pending.sort((a, b) => a.$4.compareTo(b.$4));
for (final t in pending) {
_queueTile(z, t.$3, t.$2);
}
} }
MapDebugHit? _inspectLayerHit( MapDebugHit? _inspectLayerHit(
@ -386,7 +475,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
final recorder = ui.PictureRecorder(); final recorder = ui.PictureRecorder();
final size = Size.square(rasterPx.toDouble()); final size = Size.square(rasterPx.toDouble());
final canvas = Canvas(recorder); final canvas = Canvas(recorder, Offset.zero & size);
_paintTile(canvas, size, tile, tileZoom: tileZoom); _paintTile(canvas, size, tile, tileZoom: tileZoom);
@ -449,7 +538,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
baseZ: 25.0, baseZ: 25.0,
); );
// water above grass so rivers/lakes cut through parks // water
_collectPolygonCommands( _collectPolygonCommands(
commands, commands,
size, size,
@ -483,6 +572,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
fillColor: kMapPathFillColour, fillColor: kMapPathFillColour,
tileZoom: tileZoom, tileZoom: tileZoom,
allowedClasses: const <String>{'school'}, allowedClasses: const <String>{'school'},
allowedTypes: const <String>{'school', 'college', 'university'},
baseZ: 35.0, baseZ: 35.0,
); );
@ -672,6 +762,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
final styledLines = <({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey, String roadClass})>[]; final styledLines = <({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey, String roadClass})>[];
for (final feature in roadLayer.features) { for (final feature in roadLayer.features) {
if (feature.type != MvtGeometryType.lineString) continue; if (feature.type != MvtGeometryType.lineString) continue;
final roadClass = (feature.properties['class'] ?? '').toString().toLowerCase();
if (tileZoom < 13 && !_isMajorRoadClass(roadClass) && !roadClass.contains('street') && !roadClass.contains('rail') && roadClass != 'aerialway') continue;
final style = _roadStyleFor(feature, tileZoom: tileZoom); final style = _roadStyleFor(feature, tileZoom: tileZoom);
if (style == null) continue; if (style == null) continue;
styledLines.add(( styledLines.add((
@ -679,7 +771,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
style: style, style: style,
layerProp: _featureLayerProp(feature), layerProp: _featureLayerProp(feature),
sortKey: _toDouble(feature.properties['sort_key']) ?? 0.0, sortKey: _toDouble(feature.properties['sort_key']) ?? 0.0,
roadClass: (feature.properties['class'] ?? '').toString(), roadClass: roadClass,
)); ));
} }
@ -783,13 +875,14 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
// erase with all same-class cores (across all layerProps) // erase with all same-class cores (across all layerProps)
final classErase = eraseByClass[roadClass] ?? []; final classErase = eraseByClass[roadClass] ?? [];
final needsErase = classErase.isNotEmpty;
final railBias = roadClass.contains('rail') ? -10.0 : 0.0; final railBias = roadClass.contains('rail') ? -10.0 : 0.0;
final underlayZ = 50.0 + lp * 30.0 + railBias; final underlayZ = 50.0 + lp * 30.0 + railBias;
commands.add(_DrawCommand( commands.add(_DrawCommand(
z: underlayZ, z: underlayZ,
draw: (c) { draw: (c) {
c.saveLayer(Offset.zero & size, Paint()); if (needsErase) c.saveLayer(Offset.zero & size, Paint());
for (final d in underlayDrawData) { for (final d in underlayDrawData) {
for (final line in d.lines) { for (final line in d.lines) {
@ -805,30 +898,31 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
} }
} }
for (final d in classErase) { if (needsErase) {
for (final line in d.lines) { for (final d in classErase) {
if (line.length < 2) continue; for (final line in d.lines) {
final startIsTerminal = !junctions.contains(_epKey(line.first)); if (line.length < 2) continue;
final endIsTerminal = !junctions.contains(_epKey(line.last)); final startIsTerminal = !junctions.contains(_epKey(line.first));
final trimmed = _trimPolyline( final endIsTerminal = !junctions.contains(_epKey(line.last));
line, final trimmed = _trimPolyline(
startIsTerminal ? d.terminalInset : 0.0, line,
endIsTerminal ? d.terminalInset : 0.0, startIsTerminal ? d.terminalInset : 0.0,
); endIsTerminal ? d.terminalInset : 0.0,
if (trimmed == null || trimmed.length < 2) continue; );
c.drawPath(_scaledPath(trimmed, scale), d.paint); if (trimmed == null || trimmed.length < 2) continue;
} c.drawPath(_scaledPath(trimmed, scale), d.paint);
final circlePaint = Paint() }
..color = d.paint.color final circlePaint = Paint()
..style = PaintingStyle.fill ..color = d.paint.color
..blendMode = BlendMode.dstOut ..style = PaintingStyle.fill
..isAntiAlias = true; ..blendMode = BlendMode.dstOut
for (final pt in d.junctionPts) { ..isAntiAlias = true;
c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint); for (final pt in d.junctionPts) {
c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint);
}
} }
c.restore();
} }
c.restore();
}, },
)); ));
@ -1392,9 +1486,10 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
final isCapital = capital >= 2; final isCapital = capital >= 2;
final symbolrank = (_toDouble(feature.properties['symbolrank']) ?? 99).toInt(); final symbolrank = (_toDouble(feature.properties['symbolrank']) ?? 99).toInt();
if (symbolrank > 15 && zoom <= 14.0) continue; if (kMapUseSymbolRankAsZoomGate && zoom < symbolrank) continue;
if (!isCapital) { final isMajorCity = symbolrank < 13;
if (!isCapital && !isMajorCity) {
if (zoom < 12.5 && featureClass == 'settlement_subdivision') continue; if (zoom < 12.5 && featureClass == 'settlement_subdivision') continue;
if (zoom < 12.5 && featureClass == 'settlement') continue; if (zoom < 12.5 && featureClass == 'settlement') continue;
} }
@ -1404,7 +1499,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
.toString() .toString()
.trim()); .trim());
if (rawText.isEmpty || rawText.length > 30) continue; if (rawText.isEmpty || rawText.length > 30) continue;
final text = rawText; final text = kMapDebugShowPlaceSymbolRank ? '$rawText [$symbolrank]' : rawText;
final anchor = final anchor =
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
@ -1693,147 +1788,193 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
final minTY = (minWY / tileSize).floor() - tileOverscan; final minTY = (minWY / tileSize).floor() - tileOverscan;
final maxTY = (maxWY / tileSize).floor() + tileOverscan; final maxTY = (maxWY / tileSize).floor() + tileOverscan;
final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight);
const minSameNameSpacing = 500.0; const minSameNameSpacing = 500.0;
// collect all candidates first so we can sort by path length and always // rebuild world-space label list only when tiles or zoom changed
// pick the longest visible segment per name stable across panning // pan only changes screen positions, which we recompute cheaply from worldX/Y
final candidates = <_RoadLabelCandidate>[]; if (_tileVersion != _roadLabelCacheVersion || zoom != _roadLabelCacheZoom) {
final worldLabels = <_WorldRoadLabel>[];
for (var tx = minTX; tx <= maxTX; tx++) { for (var tx = minTX; tx <= maxTX; tx++) {
for (var ty = minTY; ty <= maxTY; ty++) { for (var ty = minTY; ty <= maxTY; ty++) {
if (ty < 0 || ty >= maxIndex) continue; if (ty < 0 || ty >= maxIndex) continue;
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex; final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
final url = _tileUrl(z, wrappedX, ty); final url = _tileUrl(z, wrappedX, ty);
final tile = MapboxVectorTileCache.peek(url); final tile = MapboxVectorTileCache.peek(url);
if (tile == null) continue; if (tile == null) continue;
final layer = tile.layers['road']; final layer = tile.layers['road'];
if (layer == null) continue; if (layer == null) continue;
final tileLeft = (tx * tileSize - minWX) * factor; final scale = (tileSize * factor) / layer.extent;
final tileTop = (ty * tileSize - minWY) * factor;
final scale = (tileSize * factor) / layer.extent;
for (final feature in layer.features) { for (var fi = 0; fi < layer.features.length; fi++) {
if (feature.type != MvtGeometryType.lineString) continue; final feature = layer.features[fi];
if (feature.type != MvtGeometryType.lineString) continue;
final roadClass = final roadClass =
(feature.properties['class'] ?? feature.properties['type'] ?? '') (feature.properties['class'] ?? feature.properties['type'] ?? '')
.toString() .toString()
.toLowerCase(); .toLowerCase();
if (_shouldSkipRoadLabelClass(roadClass)) continue; if (_shouldSkipRoadLabelClass(roadClass)) continue;
if (majorRoadsOnly && !_isMajorRoadClass(roadClass)) continue; if (majorRoadsOnly && !_isMajorRoadClass(roadClass)) continue;
final style = _roadStyleFor(feature, tileZoom: z.toDouble()); final style = _roadStyleFor(feature, tileZoom: z.toDouble());
if (style == null) continue; if (style == null) continue;
final text = _sanitizeLabel( final text = _sanitizeLabel(
(feature.properties['name_en'] ?? (feature.properties['name_en'] ??
feature.properties['name'] ?? feature.properties['name'] ??
'') '')
.toString() .toString()
.trim()); .trim());
if (text.isEmpty || text.length > 32) continue; if (text.isEmpty || text.length > 32) continue;
final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom; final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom;
final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble(); final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble();
final roadCorePx = _worldStrokePx( final roadCorePx = _worldStrokePx(
style.coreWorldUnits, style.coreWorldUnits,
extent: layer.extent, extent: layer.extent,
rasterSize: tileSize * fontFactor, rasterSize: tileSize * fontFactor,
tileZoom: z.toDouble(), tileZoom: z.toDouble(),
); );
final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0); final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0);
final painter = TextPainter( final painterKey = '$text|${fontSize.toStringAsFixed(1)}';
text: TextSpan( final painter = _textPainterCache[painterKey] ?? () {
final p = TextPainter(
text: TextSpan(
text: text,
style: GoogleFonts.inter(
color: kMapRoadLabelColor,
fontSize: fontSize,
fontWeight: FontWeight.w600,
shadows: <Shadow>[
Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2),
],
height: 1.0,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout();
if (_textPainterCache.length >= _maxTextPainterCacheSize) {
_textPainterCache.remove(_textPainterCache.keys.first);
}
_textPainterCache[painterKey] = p;
return p;
}();
final cacheKey = '$url:$fi';
if (!_roadLabelPlacementCache.containsKey(cacheKey)) {
final placement = _bestRoadLabelPlacement(
feature.geometry,
scale: 1.0,
labelWidth: painter.width / scale,
);
if (placement != null) {
_roadLabelPlacementCache[cacheKey] = placement;
}
}
final localPlacement = _roadLabelPlacementCache[cacheKey];
if (localPlacement == null) continue;
// store in world space so pan is free
final worldX = tx * tileSize + localPlacement.center.dx * tileSize / layer.extent;
final worldY = ty * tileSize + localPlacement.center.dy * tileSize / layer.extent;
worldLabels.add(_WorldRoadLabel(
worldX: worldX,
worldY: worldY,
angle: localPlacement.angle,
pathLength: localPlacement.pathLength,
text: text, text: text,
style: GoogleFonts.inter( painter: painter,
color: kMapRoadLabelColor, ));
fontSize: fontSize, }
fontWeight: FontWeight.w600, }
shadows: <Shadow>[ }
Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2),
], // longest segment always wins regardless of tile iteration order
height: 1.0, worldLabels.sort((a, b) => b.pathLength.compareTo(a.pathLength));
_roadLabelWorld = worldLabels;
_roadLabelCacheVersion = _tileVersion;
_roadLabelCacheZoom = zoom;
}
final worldLabels = _roadLabelWorld;
if (worldLabels == null || worldLabels.isEmpty) return;
// reproject world screen using current pan/zoom (cheap)
final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight);
final occupiedRects = <Rect>[];
final placedByName = <String, List<Offset>>{};
for (final w in worldLabels) {
final screenCenter = Offset(
(w.worldX - minWX) * factor,
(w.worldY - minWY) * factor,
);
if (!screenBounds.contains(screenCenter)) continue;
final bounds = Rect.fromCenter(
center: screenCenter,
width: w.painter.width + 12,
height: w.painter.height + 10,
);
if (occupiedRects.any((r) => r.overlaps(bounds))) continue;
final key = w.text.toLowerCase();
final existing = placedByName[key];
if (existing != null) {
final tooClose = existing.any((p) {
final dx = p.dx - screenCenter.dx;
final dy = p.dy - screenCenter.dy;
return math.sqrt(dx * dx + dy * dy) < minSameNameSpacing;
});
if (tooClose) continue;
}
final overRoute = _labelOverRoute(bounds);
canvas.save();
canvas.translate(screenCenter.dx, screenCenter.dy);
canvas.rotate(w.angle);
if (overRoute) {
final strokeKey = '${w.text}|route_stroke';
final strokePainter = _textPainterCache[strokeKey] ?? () {
final baseStyle = w.painter.text!.style!;
final p = TextPainter(
text: TextSpan(
text: w.text,
style: baseStyle.copyWith(
color: null,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.5
..strokeJoin = StrokeJoin.round
..color = kMapRoadLabelHaloColor,
shadows: null,
), ),
), ),
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
maxLines: 1, maxLines: 1,
ellipsis: '', ellipsis: '',
)..layout(); )..layout();
if (_textPainterCache.length >= _maxTextPainterCacheSize) {
final featureIndex = layer.features.indexOf(feature); _textPainterCache.remove(_textPainterCache.keys.first);
final cacheKey = '$url:$featureIndex';
if (!_roadLabelPlacementCache.containsKey(cacheKey)) {
final placement = _bestRoadLabelPlacement(
feature.geometry,
scale: 1.0,
labelWidth: painter.width / scale,
);
// only cache successful placements null means segment too short,
// which can change at different zoom/scale so we shouldn't lock it in
if (placement != null) {
_roadLabelPlacementCache[cacheKey] = placement;
}
} }
final localPlacement = _roadLabelPlacementCache[cacheKey]; _textPainterCache[strokeKey] = p;
if (localPlacement == null) continue; return p;
}();
final screenCenter = Offset( strokePainter.paint(canvas, Offset(-strokePainter.width / 2, -strokePainter.height / 2));
tileLeft + localPlacement.center.dx * scale,
tileTop + localPlacement.center.dy * scale,
);
if (!screenBounds.contains(screenCenter)) continue;
candidates.add(_RoadLabelCandidate(
text: text,
screenCenter: screenCenter,
angle: localPlacement.angle,
pathLength: localPlacement.pathLength,
painter: painter,
));
}
} }
} w.painter.paint(canvas, Offset(-w.painter.width / 2, -w.painter.height / 2));
// longest segment wins so the winner is always the same regardless of
// which order tiles were iterated
candidates.sort((a, b) => b.pathLength.compareTo(a.pathLength));
final occupiedRects = <Rect>[];
final placedByName = <String, List<Offset>>{};
for (final c in candidates) {
final bounds = Rect.fromCenter(
center: c.screenCenter,
width: c.painter.width + 12,
height: c.painter.height + 10,
);
if (occupiedRects.any((r) => r.overlaps(bounds))) continue;
final key = c.text.toLowerCase();
final existing = placedByName[key];
if (existing != null) {
final tooClose = existing.any((p) {
final dx = p.dx - c.screenCenter.dx;
final dy = p.dy - c.screenCenter.dy;
return math.sqrt(dx * dx + dy * dy) < minSameNameSpacing;
});
if (tooClose) continue;
}
canvas.save();
canvas.translate(c.screenCenter.dx, c.screenCenter.dy);
canvas.rotate(c.angle);
c.painter.paint(canvas, Offset(-c.painter.width / 2, -c.painter.height / 2));
canvas.restore(); canvas.restore();
occupiedRects.add(bounds); occupiedRects.add(bounds);
(placedByName[key] ??= <Offset>[]).add(c.screenCenter); (placedByName[key] ??= <Offset>[]).add(screenCenter);
} }
} }
@ -2112,6 +2253,36 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
return length; return length;
} }
bool _labelOverRoute(Rect labelBounds) {
final pts = routeScreenPoints;
if (pts.length < 2) return false;
final inflated = labelBounds.inflate(4);
for (var i = 0; i < pts.length - 1; i++) {
if (_segmentIntersectsRect(pts[i], pts[i + 1], inflated)) return true;
}
return false;
}
bool _segmentIntersectsRect(Offset a, Offset b, Rect r) {
// quick AABB check first
final minX = math.min(a.dx, b.dx);
final maxX = math.max(a.dx, b.dx);
final minY = math.min(a.dy, b.dy);
final maxY = math.max(a.dy, b.dy);
if (maxX < r.left || minX > r.right || maxY < r.top || minY > r.bottom) return false;
// check if any rect corner is on opposite sides of the segment
final dx = b.dx - a.dx;
final dy = b.dy - a.dy;
double sign(Offset p) => dx * (p.dy - a.dy) - dy * (p.dx - a.dx);
final s1 = sign(r.topLeft);
final s2 = sign(r.topRight);
final s3 = sign(r.bottomLeft);
final s4 = sign(r.bottomRight);
final mn = math.min(math.min(s1, s2), math.min(s3, s4));
final mx = math.max(math.max(s1, s2), math.max(s3, s4));
return mn <= 0 && mx >= 0;
}
bool _isMajorRoadClass(String roadClass) { bool _isMajorRoadClass(String roadClass) {
return roadClass.contains('motorway') || return roadClass.contains('motorway') ||
roadClass.contains('trunk') || roadClass.contains('trunk') ||
@ -2189,6 +2360,33 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
bool hitTest(Offset position) => false; bool hitTest(Offset position) => false;
} }
class TileLabelPainter implements CustomPainter {
TileLabelPainter(this._painter);
final TilePyramidPainter _painter;
@override
void paint(Canvas canvas, Size size) => _painter.paintLabels(canvas);
@override
bool shouldRepaint(covariant TileLabelPainter old) => true;
@override
bool shouldRebuildSemantics(covariant CustomPainter old) => false;
@override
SemanticsBuilderCallback? get semanticsBuilder => null;
@override
bool hitTest(Offset position) => false;
@override
void addListener(ui.VoidCallback listener) {}
@override
void removeListener(ui.VoidCallback listener) {}
}
class _RoadStyle { class _RoadStyle {
const _RoadStyle({ const _RoadStyle({
required this.sortOrder, required this.sortOrder,
@ -2276,6 +2474,26 @@ class _LabelCandidate {
final String featureClass; final String featureClass;
} }
// road label stored in world-space so screen position can be recomputed on pan
// without re-iterating tile features
class _WorldRoadLabel {
const _WorldRoadLabel({
required this.worldX,
required this.worldY,
required this.angle,
required this.pathLength,
required this.text,
required this.painter,
});
final double worldX;
final double worldY;
final double angle;
final double pathLength;
final String text;
final TextPainter painter;
}
class _DrawCommand { class _DrawCommand {
const _DrawCommand({required this.z, required this.draw}); const _DrawCommand({required this.z, required this.draw});
final double z; final double z;

View file

@ -97,14 +97,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dart_earcut:
dependency: transitive
description:
name: dart_earcut
sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b
url: "https://pub.dev"
source: hosted
version: "1.2.0"
data_widget: data_widget:
dependency: transitive dependency: transitive
description: description:
@ -163,14 +155,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_map:
dependency: "direct main"
description:
name: flutter_map
sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -301,22 +285,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
lists:
dependency: transitive
description:
name: lists
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logger:
dependency: transitive
description:
name: logger
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.2"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@ -349,14 +317,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.0"
mgrs_dart:
dependency: transitive
description:
name: mgrs_dart
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
url: "https://pub.dev"
source: hosted
version: "2.0.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -445,22 +405,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
polylabel:
dependency: transitive
description:
name: polylabel
sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
proj4dart:
dependency: transitive
description:
name: proj4dart
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
url: "https://pub.dev"
source: hosted
version: "2.1.0"
quiver: quiver:
dependency: transitive dependency: transitive
description: description:
@ -554,14 +498,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
unicode:
dependency: transitive
description:
name: unicode
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -586,14 +522,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
wkt_parser:
dependency: transitive
description:
name: wkt_parser
sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View file

@ -23,7 +23,7 @@ environment:
# Dependencies specify other packages that your package needs in order to work. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively, # consider running `flutter pub upgrade --major-versions`. Alternatively,Still
# dependencies can be manually updated by changing the version numbers below to # dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer # the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`. # versions available, run `flutter pub outdated`.
@ -34,7 +34,6 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_map: ^7.0.2
hive: ^2.2.3 hive: ^2.2.3
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
latlong2: ^0.9.1 latlong2: ^0.9.1