add TFL bus stop caching and fetching functionality, enhance map rendering with bus stop markers
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:rra_app/pages/map/tfl/tfl_bus_stop_service.dart';
|
||||
|
||||
class TflBusStopCache {
|
||||
TflBusStopCache._();
|
||||
|
||||
static const _boxName = 'tfl_bus_stop_cache_v1';
|
||||
static const _staleAfter = Duration(hours: 12);
|
||||
static const _maxEntries = 512;
|
||||
|
||||
static Future<void> init() async {
|
||||
if (!Hive.isBoxOpen(_boxName)) {
|
||||
await Hive.openBox(_boxName);
|
||||
}
|
||||
}
|
||||
|
||||
static Box? get _box {
|
||||
try {
|
||||
if (!Hive.isBoxOpen(_boxName)) return null;
|
||||
return Hive.box(_boxName);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// rounds to ~1km grid cells so nearby pans reuse the same cache entry
|
||||
static String keyFor(LatLng center) {
|
||||
final lat = (center.latitude * 100).round() / 100;
|
||||
final lon = (center.longitude * 100).round() / 100;
|
||||
return '$lat,$lon';
|
||||
}
|
||||
|
||||
static List<BusStop>? peek(String key) {
|
||||
final box = _box;
|
||||
if (box == null) return null;
|
||||
|
||||
final entry = box.get(key);
|
||||
if (entry is! Map) return null;
|
||||
|
||||
final ts = entry['ts'];
|
||||
if (ts is! int) return null;
|
||||
|
||||
final age = DateTime.now().millisecondsSinceEpoch - ts;
|
||||
if (age > _staleAfter.inMilliseconds) return null;
|
||||
|
||||
final raw = entry['stops'];
|
||||
if (raw is! List) return null;
|
||||
|
||||
try {
|
||||
return _decodeStops(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> store(String key, List<BusStop> stops) async {
|
||||
final box = _box;
|
||||
if (box == null) return;
|
||||
|
||||
await box.put(key, {
|
||||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||||
'stops': _encodeStops(stops),
|
||||
});
|
||||
|
||||
await _pruneIfNeeded(box);
|
||||
}
|
||||
|
||||
|
||||
static List<Map<String, dynamic>> _encodeStops(List<BusStop> stops) {
|
||||
return stops.map((s) => {
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'lat': s.position.latitude,
|
||||
'lon': s.position.longitude,
|
||||
if (s.stopLetter != null) 'stopLetter': s.stopLetter,
|
||||
if (s.towards != null) 'towards': s.towards,
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static List<BusStop> _decodeStops(List raw) {
|
||||
final result = <BusStop>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) continue;
|
||||
final lat = (item['lat'] as num?)?.toDouble();
|
||||
final lon = (item['lon'] as num?)?.toDouble();
|
||||
if (lat == null || lon == null) continue;
|
||||
result.add(BusStop(
|
||||
id: item['id'] as String? ?? '',
|
||||
name: item['name'] as String? ?? '',
|
||||
position: LatLng(lat, lon),
|
||||
stopLetter: item['stopLetter'] as String?,
|
||||
towards: item['towards'] as String?,
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<void> _pruneIfNeeded(Box box) async {
|
||||
final overflow = box.length - _maxEntries;
|
||||
if (overflow <= 0) return;
|
||||
|
||||
final entries = box.keys.map((k) {
|
||||
final v = box.get(k);
|
||||
final ts = (v is Map && v['ts'] is int) ? v['ts'] as int : 0;
|
||||
return MapEntry(k, ts);
|
||||
}).toList()
|
||||
..sort((a, b) => a.value.compareTo(b.value));
|
||||
|
||||
for (var i = 0; i < overflow; i++) {
|
||||
await box.delete(entries[i].key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class BusStop {
|
||||
const BusStop({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.position,
|
||||
this.stopLetter,
|
||||
this.towards,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final LatLng position;
|
||||
|
||||
// the letter on the physical bus stop flag e.g. "A", "ZH"
|
||||
final String? stopLetter;
|
||||
|
||||
// may be null for parent stops
|
||||
final String? towards;
|
||||
}
|
||||
|
||||
class TflBusStopService {
|
||||
TflBusStopService();
|
||||
|
||||
static const _baseUrl = 'https://api.tfl.gov.uk';
|
||||
|
||||
// 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(
|
||||
'$_baseUrl/StopPoint'
|
||||
'?stopTypes=NaptanPublicBusCoachTram'
|
||||
'&lat=${center.latitude}'
|
||||
'&lon=${center.longitude}'
|
||||
'&radius=$r'
|
||||
'&modes=bus',
|
||||
);
|
||||
|
||||
final response = await http.get(uri, headers: {
|
||||
'Accept': 'application/json',
|
||||
});
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('TfL StopPoint request failed (${response.statusCode})');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final stopPoints = body['stopPoints'];
|
||||
if (stopPoints is! List) return const [];
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stops.add(BusStop(
|
||||
id: id,
|
||||
name: name,
|
||||
position: LatLng(lat, lon),
|
||||
stopLetter: stopLetter,
|
||||
towards: towards,
|
||||
));
|
||||
}
|
||||
|
||||
return stops;
|
||||
}
|
||||
|
||||
// rough haversine distance in metres, good enough for radius estimation
|
||||
static double metersPerPixel(double lat, double zoom) {
|
||||
const earthCircumference = 40075016.686;
|
||||
final latRad = lat * math.pi / 180.0;
|
||||
return (earthCircumference * math.cos(latRad)) /
|
||||
(256 * math.pow(2, zoom));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user