Consolidate / dedupe some code

This commit is contained in:
stopflock
2025-08-24 17:46:58 -05:00
parent bedfdcca6e
commit 9e620ef9e4
6 changed files with 95 additions and 190 deletions

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'models/camera_profile.dart';

View File

@@ -1,5 +1,6 @@
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import '../models/camera_profile.dart';
import '../models/osm_camera_node.dart';
@@ -125,7 +126,7 @@ class MapDataProvider {
if (offline) {
throw OfflineModeException("Cannot fetch remote tiles in offline mode.");
}
return fetchOSMTile(z: z, x: x, y: y);
return _fetchRemoteTileFromCurrentProvider(z, x, y);
}
// Explicitly local
@@ -138,13 +139,29 @@ class MapDataProvider {
return await fetchLocalTile(z: z, x: x, y: y);
} catch (_) {
if (!offline) {
return fetchOSMTile(z: z, x: x, y: y);
return _fetchRemoteTileFromCurrentProvider(z, x, y);
} else {
throw OfflineModeException("Tile $z/$x/$y not found in offline areas and offline mode is enabled.");
}
}
}
/// Fetch remote tile using current provider from AppState
Future<List<int>> _fetchRemoteTileFromCurrentProvider(int z, int x, int y) async {
final appState = AppState.instance;
final selectedTileType = appState.selectedTileType;
final selectedProvider = appState.selectedTileProvider;
if (selectedTileType != null && selectedProvider != null) {
// Use current provider
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
return fetchRemoteTile(z: z, x: x, y: y, url: tileUrl);
} else {
// Fallback to OSM if no provider selected
return fetchOSMTile(z: z, x: x, y: y);
}
}
/// Clear any queued tile requests (call when map view changes significantly)
void clearTileQueue() {
clearRemoteTileQueue();

View File

@@ -41,23 +41,23 @@ Future<List<int>> fetchRemoteTile({
while (true) {
await _tileFetchSemaphore.acquire();
try {
print('[fetchRemoteTile] FETCH $z/$x/$y from $hostInfo');
// Only log on first attempt or errors
if (attempt == 1) {
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
}
attempt++;
final resp = await http.get(Uri.parse(url));
print('[fetchRemoteTile] HTTP ${resp.statusCode} for $z/$x/$y from $hostInfo, length=${resp.bodyBytes.length}');
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
print('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo');
// Success - no logging for normal operation
NetworkStatus.instance.reportOsmTileSuccess(); // Still use OSM reporting for now
return resp.bodyBytes;
} else {
print('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
NetworkStatus.instance.reportOsmTileIssue(); // Still use OSM reporting for now
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
}
} catch (e) {
print('[fetchRemoteTile] Exception $z/$x/$y from $hostInfo: $e');
// Report network issues on connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
@@ -66,12 +66,14 @@ Future<List<int>> fetchRemoteTile({
}
if (attempt >= maxAttempts) {
print("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e");
debugPrint("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e");
rethrow;
}
final delay = delays[attempt - 1].clamp(0, 60000);
print("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
if (attempt == 1) {
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
}
await Future.delayed(Duration(milliseconds: delay));
} finally {
_tileFetchSemaphore.release();

View File

@@ -4,13 +4,10 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:collection/collection.dart';
import '../../app_state.dart';
import '../../models/osm_camera_node.dart';
import '../../models/tile_provider.dart';
import '../map_data_provider.dart';
import '../map_data_submodules/tiles_from_remote.dart';
import 'offline_area_models.dart';
import 'offline_tile_utils.dart';
import 'package:flock_map_app/dev_config.dart';
@@ -30,32 +27,6 @@ class OfflineAreaDownloader {
required Future<void> Function() saveAreasToDisk,
required Future<void> Function(OfflineArea) getAreaSizeBytes,
}) async {
// Get tile provider info from the area metadata or current AppState
TileProvider? tileProvider;
TileType? tileType;
final appState = AppState.instance;
if (area.tileProviderId != null && area.tileTypeId != null) {
// Use the provider info stored with the area (for refreshing existing areas)
try {
tileProvider = appState.tileProviders.firstWhere(
(p) => p.id == area.tileProviderId,
);
tileType = tileProvider.tileTypes.firstWhere(
(t) => t.id == area.tileTypeId,
);
} catch (e) {
// Fallback if stored provider/type not found
tileProvider = appState.selectedTileProvider ?? appState.tileProviders.firstOrNull;
tileType = appState.selectedTileType ?? tileProvider?.tileTypes.firstOrNull;
}
} else {
// New area - use currently selected provider
tileProvider = appState.selectedTileProvider ?? appState.tileProviders.firstOrNull;
tileType = appState.selectedTileType ?? tileProvider?.tileTypes.firstOrNull;
}
// Calculate tiles to download
Set<List<int>> allTiles;
if (area.isPermanent) {
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
@@ -72,8 +43,6 @@ class OfflineAreaDownloader {
onProgress: onProgress,
saveAreasToDisk: saveAreasToDisk,
getAreaSizeBytes: getAreaSizeBytes,
tileProvider: tileProvider,
tileType: tileType,
);
// Download cameras for non-permanent areas
@@ -99,8 +68,6 @@ class OfflineAreaDownloader {
void Function(double progress)? onProgress,
required Future<void> Function() saveAreasToDisk,
required Future<void> Function(OfflineArea) getAreaSizeBytes,
TileProvider? tileProvider,
TileType? tileType,
}) async {
int pass = 0;
Set<List<int>> tilesToFetch = allTiles;
@@ -113,7 +80,7 @@ class OfflineAreaDownloader {
for (final tile in tilesToFetch) {
if (area.status == OfflineAreaStatus.cancelled) break;
if (await _downloadSingleTile(tile, directory, area, tileProvider, tileType)) {
if (await _downloadSingleTile(tile, directory, area)) {
totalDone++;
area.tilesDownloaded = totalDone;
area.progress = area.tilesTotal == 0 ? 0.0 : (totalDone / area.tilesTotal);
@@ -134,25 +101,20 @@ class OfflineAreaDownloader {
return false; // Failed after max retries
}
/// Download a single tile
/// Download a single tile using the unified MapDataProvider path
static Future<bool> _downloadSingleTile(
List<int> tile,
String directory,
OfflineArea area,
TileProvider? tileProvider,
TileType? tileType,
) async {
try {
List<int> bytes;
if (tileType != null && tileProvider != null) {
// Use the same path as live tiles: build URL and fetch directly
final tileUrl = tileType.getTileUrl(tile[0], tile[1], tile[2], apiKey: tileProvider.apiKey);
bytes = await fetchRemoteTile(z: tile[0], x: tile[1], y: tile[2], url: tileUrl);
} else {
// Fallback to OSM for legacy areas or when no provider info
bytes = await fetchOSMTile(z: tile[0], x: tile[1], y: tile[2]);
}
// Use the same unified path as live tiles: always go through MapDataProvider
final bytes = await MapDataProvider().getTile(
z: tile[0],
x: tile[1],
y: tile[2],
source: MapSource.remote, // Force remote fetch for downloads
);
if (bytes.isNotEmpty) {
await OfflineAreaDownloader.saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
return true;

View File

@@ -13,77 +13,50 @@ class SimpleTileHttpClient extends http.BaseClient {
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// Try to parse as a tile request from any provider
final tileInfo = _parseTileRequest(request.url);
if (tileInfo != null) {
return _handleTileRequest(request, tileInfo);
// Extract tile coordinates from the URL using our standard pattern
final tileCoords = _extractTileCoords(request.url);
if (tileCoords != null) {
final z = tileCoords['z']!; // We know these are not null from _extractTileCoords
final x = tileCoords['x']!;
final y = tileCoords['y']!;
return _handleTileRequest(z, x, y);
}
// Pass through non-tile requests
return _inner.send(request);
}
/// Parse URL to extract tile coordinates if it looks like a tile request
Map<String, dynamic>? _parseTileRequest(Uri url) {
/// Extract z/x/y coordinates from our standard tile URL pattern
Map<String, int>? _extractTileCoords(Uri url) {
// We'll use a simple standard pattern: /{z}/{x}/{y}.png
// This will be the format we use in map_view.dart
final pathSegments = url.pathSegments;
// Common patterns for tile URLs:
// OSM: /z/x/y.png
// Google: /vt/lyrs=y&x=x&y=y&z=z (query params)
// Mapbox: /styles/v1/mapbox/streets-v12/tiles/z/x/y
// ArcGIS: /tile/z/y/x.png
// Try query parameters first (Google style)
final query = url.queryParameters;
if (query.containsKey('x') && query.containsKey('y') && query.containsKey('z')) {
final x = int.tryParse(query['x']!);
final y = int.tryParse(query['y']!);
final z = int.tryParse(query['z']!);
if (x != null && y != null && z != null) {
return {'z': z, 'x': x, 'y': y, 'originalUrl': url.toString()};
}
}
// Try path-based patterns
if (pathSegments.length >= 3) {
// Try z/x/y pattern (OSM style) - can be at different positions
for (int i = 0; i <= pathSegments.length - 3; i++) {
final z = int.tryParse(pathSegments[i]);
final x = int.tryParse(pathSegments[i + 1]);
final yWithExt = pathSegments[i + 2];
final y = int.tryParse(yWithExt.replaceAll(RegExp(r'\.[^.]*$'), '')); // Remove file extension
if (z != null && x != null && y != null) {
return {'z': z, 'x': x, 'y': y, 'originalUrl': url.toString()};
}
}
}
return null; // Not a recognizable tile request
}
Future<http.StreamedResponse> _handleTileRequest(http.BaseRequest request, Map<String, dynamic> tileInfo) async {
final z = tileInfo['z'] as int;
final x = tileInfo['x'] as int;
final y = tileInfo['y'] as int;
final originalUrl = tileInfo['originalUrl'] as String;
return _getTile(z, x, y, originalUrl, request.url.host);
}
Future<http.StreamedResponse> _getTile(int z, int x, int y, String originalUrl, String providerHost) async {
try {
// First try to get tile from offline storage
final localTileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.local);
if (pathSegments.length == 3) {
final z = int.tryParse(pathSegments[0]);
final x = int.tryParse(pathSegments[1]);
final yWithExt = pathSegments[2];
final y = int.tryParse(yWithExt.replaceAll(RegExp(r'\.[^.]*$'), '')); // Remove .png
debugPrint('[SimpleTileService] Serving tile $z/$x/$y from offline storage');
if (z != null && x != null && y != null) {
return {'z': z, 'x': x, 'y': y};
}
}
return null;
}
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
try {
// Always go through MapDataProvider - it handles offline/online routing
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
// Clear waiting status - we got data
NetworkStatus.instance.clearWaiting();
// Serve offline tile with proper cache headers
// Serve tile with proper cache headers
return http.StreamedResponse(
Stream.value(localTileBytes),
Stream.value(tileBytes),
200,
headers: {
'Content-Type': 'image/png',
@@ -94,39 +67,15 @@ class SimpleTileHttpClient extends http.BaseClient {
);
} catch (e) {
// No offline tile available
debugPrint('[SimpleTileService] No offline tile for $z/$x/$y');
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
// Check if we're in offline mode
if (AppState.instance.offlineMode) {
debugPrint('[SimpleTileService] Offline mode - not attempting $providerHost fetch for $z/$x/$y');
// Report that we couldn't serve this tile offline
NetworkStatus.instance.reportOfflineMiss();
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Tile not available offline',
);
}
// We're online - try the original provider with proper error handling
debugPrint('[SimpleTileService] Online mode - trying $providerHost for $z/$x/$y');
try {
final response = await _inner.send(http.Request('GET', Uri.parse(originalUrl)));
// Clear waiting status on successful network tile
if (response.statusCode == 200) {
NetworkStatus.instance.clearWaiting();
}
return response;
} catch (networkError) {
debugPrint('[SimpleTileService] $providerHost request failed for $z/$x/$y: $networkError');
// Return 404 instead of throwing - let flutter_map handle gracefully
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Network tile unavailable: $networkError',
);
}
// Let MapDataProvider handle offline mode logic
// Just return 404 and let flutter_map handle it gracefully
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Tile unavailable: $e',
);
}
}

View File

@@ -54,6 +54,9 @@ class MapViewState extends State<MapView> {
// Track zoom to clear queue on zoom changes
double? _lastZoom;
// Track tile type changes to clear cache
String? _lastTileTypeId;
@override
void initState() {
@@ -171,56 +174,16 @@ class MapViewState extends State<MapView> {
return ids1.length == ids2.length && ids1.containsAll(ids2);
}
/// Build tile layer based on selected tile provider
/// Build tile layer - uses standard URL that SimpleTileHttpClient can parse
Widget _buildTileLayer(AppState appState) {
final selectedTileType = appState.selectedTileType;
final selectedProvider = appState.selectedTileProvider;
// Fallback to first available tile type if none selected
if (selectedTileType == null || selectedProvider == null) {
final allTypes = <TileType>[];
for (final provider in appState.tileProviders) {
allTypes.addAll(provider.availableTileTypes);
}
final fallback = allTypes.firstOrNull;
if (fallback != null) {
return TileLayer(
urlTemplate: fallback.urlTemplate,
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
),
);
}
// Ultimate fallback - hardcoded OSM
return TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
),
);
}
// Get the URL template with API key if needed
String urlTemplate = selectedTileType.urlTemplate;
if (selectedTileType.requiresApiKey && selectedProvider.apiKey != null) {
urlTemplate = urlTemplate.replaceAll('{api_key}', selectedProvider.apiKey!);
}
// For now, use our custom HTTP client for all tile requests
// This will enable offline support for all providers
// Use a generic URL template that SimpleTileHttpClient recognizes
// The actual provider URL will be built by MapDataProvider using current AppState
return TileLayer(
urlTemplate: urlTemplate,
urlTemplate: 'https://tiles.local/{z}/{x}/{y}.png',
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
),
additionalOptions: {
'attribution': selectedTileType.attribution,
},
);
}
@@ -245,6 +208,17 @@ class MapViewState extends State<MapView> {
});
}
// Check if tile type changed and clear cache if needed
final currentTileTypeId = appState.selectedTileType?.id;
if (_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear our tile request queue
_tileHttpClient.clearTileQueue();
// Note: The ValueKey on FlutterMap will cause flutter_map to rebuild and clear its cache
});
}
_lastTileTypeId = currentTileTypeId;
// Seed addmode target once, after first controller center is available.
if (session != null && session.target == null) {
try {