mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 09:12:56 +00:00
Compare commits
13 Commits
v0.8.7-bet
...
v0.8.10-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf7f93dd5 | ||
|
|
d56a6e8e7c | ||
|
|
84e057c986 | ||
|
|
c1e25ec5b1 | ||
|
|
a3edcfc2de | ||
|
|
17c9ee0c5c | ||
|
|
9e620ef9e4 | ||
|
|
bedfdcca6e | ||
|
|
f1c73a5e55 | ||
|
|
4ee783793f | ||
|
|
aada97295b | ||
|
|
813f4f69ea | ||
|
|
2d615128aa |
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'models/camera_profile.dart';
|
||||
@@ -11,10 +12,9 @@ import 'state/session_state.dart';
|
||||
import 'state/settings_state.dart';
|
||||
import 'state/upload_queue_state.dart';
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
// Re-export types
|
||||
export 'state/settings_state.dart' show UploadMode;
|
||||
export 'state/session_state.dart' show AddCameraSession;
|
||||
export 'models/tile_provider.dart' show TileProviderType;
|
||||
|
||||
// ------------------ AppState ------------------
|
||||
class AppState extends ChangeNotifier {
|
||||
@@ -66,7 +66,13 @@ class AppState extends ChangeNotifier {
|
||||
bool get offlineMode => _settingsState.offlineMode;
|
||||
int get maxCameras => _settingsState.maxCameras;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
TileProviderType get tileProvider => _settingsState.tileProvider;
|
||||
|
||||
// Tile provider state
|
||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||
TileType? get selectedTileType => _settingsState.selectedTileType;
|
||||
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
|
||||
|
||||
|
||||
|
||||
// Upload queue state
|
||||
int get pendingCount => _uploadQueueState.pendingCount;
|
||||
@@ -179,10 +185,23 @@ class AppState extends ChangeNotifier {
|
||||
_startUploader(); // Restart uploader with new mode
|
||||
}
|
||||
|
||||
Future<void> setTileProvider(TileProviderType provider) async {
|
||||
await _settingsState.setTileProvider(provider);
|
||||
/// Select a tile type by ID
|
||||
Future<void> setSelectedTileType(String tileTypeId) async {
|
||||
await _settingsState.setSelectedTileType(tileTypeId);
|
||||
}
|
||||
|
||||
/// Add or update a tile provider
|
||||
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
|
||||
await _settingsState.addOrUpdateTileProvider(provider);
|
||||
}
|
||||
|
||||
/// Delete a tile provider
|
||||
Future<void> deleteTileProvider(String providerId) async {
|
||||
await _settingsState.deleteTileProvider(providerId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
_uploadQueueState.clearQueue();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// lib/dev_config.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Developer/build-time configuration for global/non-user-tunable constants.
|
||||
const int kWorldMinZoom = 1;
|
||||
const int kWorldMaxZoom = 5;
|
||||
@@ -7,8 +9,9 @@ const int kWorldMaxZoom = 5;
|
||||
const double kTileEstimateKb = 25.0;
|
||||
|
||||
// Direction cone for map view
|
||||
const double kDirectionConeHalfAngle = 20.0; // degrees
|
||||
const double kDirectionConeBaseLength = 0.0012; // multiplier
|
||||
const double kDirectionConeHalfAngle = 30.0; // degrees
|
||||
const double kDirectionConeBaseLength = 0.001; // multiplier
|
||||
const Color kDirectionConeColor = Color(0xFF111111); // FOV cone color
|
||||
|
||||
// Margin (bottom) for positioning the floating bottom button bar
|
||||
const double kBottomButtonBarMargin = 4.0;
|
||||
@@ -18,12 +21,12 @@ const double kAttributionBottomOffset = 110.0;
|
||||
const double kZoomIndicatorBottomOffset = 142.0;
|
||||
const double kScaleBarBottomOffset = 170.0;
|
||||
|
||||
// Add Camera pin vertical offset (for pin tip to match coordinate on map)
|
||||
const double kAddPinYOffset = -16.0;
|
||||
// Add Camera icon vertical offset (no offset needed since circle is centered)
|
||||
const double kAddPinYOffset = 0.0;
|
||||
|
||||
// Client name and version for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'FlockMap';
|
||||
const String kClientVersion = '0.8.3';
|
||||
const String kClientVersion = '0.8.10';
|
||||
|
||||
// Marker/camera interaction
|
||||
const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning
|
||||
@@ -43,5 +46,13 @@ const int kTileFetchJitter3Ms = 5000;
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
|
||||
// Download area limits and constants
|
||||
const int kMaxReasonableTileCount = 10000;
|
||||
const int kMaxReasonableTileCount = 20000;
|
||||
const int kAbsoluteMaxZoom = 19;
|
||||
|
||||
// Camera icon configuration
|
||||
const double kCameraIconDiameter = 20.0;
|
||||
const double kCameraRingThickness = 4.0;
|
||||
const double kCameraDotOpacity = 0.4; // Opacity for the grey dot interior
|
||||
const Color kCameraRingColorReal = Color(0xC43F55F3); // Real cameras from OSM - blue
|
||||
const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add camera mock point - white
|
||||
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending cameras - purple
|
||||
|
||||
@@ -1,100 +1,214 @@
|
||||
enum TileProviderType {
|
||||
osmStreet,
|
||||
googleHybrid,
|
||||
arcgisSatellite,
|
||||
mapboxSatellite,
|
||||
}
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class TileProviderConfig {
|
||||
final TileProviderType type;
|
||||
/// A specific tile type within a provider
|
||||
class TileType {
|
||||
final String id;
|
||||
final String name;
|
||||
final String urlTemplate;
|
||||
final String attribution;
|
||||
final bool requiresApiKey;
|
||||
final String? description;
|
||||
|
||||
const TileProviderConfig({
|
||||
required this.type,
|
||||
required this.name,
|
||||
final Uint8List? previewTile; // Single tile image data for preview
|
||||
|
||||
const TileType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
this.requiresApiKey = false,
|
||||
this.description,
|
||||
this.previewTile,
|
||||
});
|
||||
|
||||
/// Returns the URL template with API key inserted if needed
|
||||
String getUrlTemplate({String? apiKey}) {
|
||||
if (requiresApiKey && apiKey != null) {
|
||||
return urlTemplate.replaceAll('{api_key}', apiKey);
|
||||
/// Create URL for a specific tile, replacing template variables
|
||||
String getTileUrl(int z, int x, int y, {String? apiKey}) {
|
||||
String url = urlTemplate
|
||||
.replaceAll('{z}', z.toString())
|
||||
.replaceAll('{x}', x.toString())
|
||||
.replaceAll('{y}', y.toString());
|
||||
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
url = url.replaceAll('{api_key}', apiKey);
|
||||
}
|
||||
return urlTemplate;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Check if this provider is available (has required API key if needed)
|
||||
bool isAvailable({String? apiKey}) {
|
||||
if (requiresApiKey) {
|
||||
return apiKey != null && apiKey.isNotEmpty;
|
||||
}
|
||||
return true;
|
||||
/// Check if this tile type needs an API key
|
||||
bool get requiresApiKey => urlTemplate.contains('{api_key}');
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'urlTemplate': urlTemplate,
|
||||
'attribution': attribution,
|
||||
'previewTile': previewTile != null ? base64Encode(previewTile!) : null,
|
||||
};
|
||||
|
||||
static TileType fromJson(Map<String, dynamic> json) => TileType(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
urlTemplate: json['urlTemplate'],
|
||||
attribution: json['attribution'],
|
||||
previewTile: json['previewTile'] != null
|
||||
? base64Decode(json['previewTile'])
|
||||
: null,
|
||||
);
|
||||
|
||||
TileType copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? urlTemplate,
|
||||
String? attribution,
|
||||
Uint8List? previewTile,
|
||||
}) => TileType(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
urlTemplate: urlTemplate ?? this.urlTemplate,
|
||||
attribution: attribution ?? this.attribution,
|
||||
previewTile: previewTile ?? this.previewTile,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TileType && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// A tile provider containing multiple tile types
|
||||
class TileProvider {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? apiKey;
|
||||
final List<TileType> tileTypes;
|
||||
|
||||
const TileProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.apiKey,
|
||||
required this.tileTypes,
|
||||
});
|
||||
|
||||
/// Check if this provider is usable (has API key if any tile types need it)
|
||||
bool get isUsable {
|
||||
final needsKey = tileTypes.any((type) => type.requiresApiKey);
|
||||
return !needsKey || (apiKey != null && apiKey!.isNotEmpty);
|
||||
}
|
||||
|
||||
/// Get available tile types (those that don't need API key or have one)
|
||||
List<TileType> get availableTileTypes {
|
||||
return tileTypes.where((type) => !type.requiresApiKey || isUsable).toList();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'apiKey': apiKey,
|
||||
'tileTypes': tileTypes.map((type) => type.toJson()).toList(),
|
||||
};
|
||||
|
||||
static TileProvider fromJson(Map<String, dynamic> json) => TileProvider(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
apiKey: json['apiKey'],
|
||||
tileTypes: (json['tileTypes'] as List)
|
||||
.map((typeJson) => TileType.fromJson(typeJson))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
TileProvider copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? apiKey,
|
||||
List<TileType>? tileTypes,
|
||||
}) => TileProvider(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
apiKey: apiKey ?? this.apiKey,
|
||||
tileTypes: tileTypes ?? this.tileTypes,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TileProvider && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// Factory for creating default tile providers
|
||||
class DefaultTileProviders {
|
||||
/// Create the default set of tile providers
|
||||
static List<TileProvider> createDefaults() {
|
||||
return [
|
||||
TileProvider(
|
||||
id: 'openstreetmap',
|
||||
name: 'OpenStreetMap',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'google_hybrid',
|
||||
name: 'Satellite + Roads',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
),
|
||||
TileType(
|
||||
id: 'google_satellite',
|
||||
name: 'Satellite Only',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
),
|
||||
TileType(
|
||||
id: 'google_roadmap',
|
||||
name: 'Road Map',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'esri',
|
||||
name: 'Esri',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'esri_satellite',
|
||||
name: 'Satellite Imagery',
|
||||
urlTemplate: 'https://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png',
|
||||
attribution: '© Esri © Maxar',
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'mapbox',
|
||||
name: 'Mapbox',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox © Maxar',
|
||||
),
|
||||
TileType(
|
||||
id: 'mapbox_streets',
|
||||
name: 'Streets',
|
||||
urlTemplate: 'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/{z}/{x}/{y}?access_token={api_key}',
|
||||
attribution: '© Mapbox © OpenStreetMap',
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in tile provider configurations
|
||||
class TileProviders {
|
||||
static const osmStreet = TileProviderConfig(
|
||||
type: TileProviderType.osmStreet,
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
description: 'Standard street map with roads, buildings, and labels',
|
||||
);
|
||||
|
||||
static const googleHybrid = TileProviderConfig(
|
||||
type: TileProviderType.googleHybrid,
|
||||
name: 'Satellite + Roads',
|
||||
urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
|
||||
attribution: '© Google',
|
||||
description: 'Satellite imagery with road and label overlays',
|
||||
);
|
||||
|
||||
static const arcgisSatellite = TileProviderConfig(
|
||||
type: TileProviderType.arcgisSatellite,
|
||||
name: 'Pure Satellite',
|
||||
urlTemplate: 'https://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png',
|
||||
attribution: '© Esri © Maxar',
|
||||
description: 'High-resolution satellite imagery without overlays',
|
||||
);
|
||||
|
||||
static const mapboxSatellite = TileProviderConfig(
|
||||
type: TileProviderType.mapboxSatellite,
|
||||
name: 'Pure Satellite (Mapbox)',
|
||||
urlTemplate: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox © Maxar',
|
||||
requiresApiKey: true,
|
||||
description: 'High-resolution satellite imagery without overlays',
|
||||
);
|
||||
|
||||
/// Get all available tile providers (those with API keys if required)
|
||||
static List<TileProviderConfig> getAvailable({String? mapboxApiKey}) {
|
||||
return [
|
||||
osmStreet,
|
||||
googleHybrid,
|
||||
arcgisSatellite,
|
||||
if (mapboxSatellite.isAvailable(apiKey: mapboxApiKey)) mapboxSatellite,
|
||||
];
|
||||
}
|
||||
|
||||
/// Get provider config by type
|
||||
static TileProviderConfig? getByType(TileProviderType type) {
|
||||
switch (type) {
|
||||
case TileProviderType.osmStreet:
|
||||
return osmStreet;
|
||||
case TileProviderType.googleHybrid:
|
||||
return googleHybrid;
|
||||
case TileProviderType.arcgisSatellite:
|
||||
return arcgisSatellite;
|
||||
case TileProviderType.mapboxSatellite:
|
||||
return mapboxSatellite;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,34 +76,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
if (_followMe) setState(() => _followMe = false);
|
||||
},
|
||||
),
|
||||
// Zoom buttons
|
||||
Positioned(
|
||||
right: 10,
|
||||
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarMargin + 120,
|
||||
child: Column(
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () {
|
||||
final currentZoom = _mapController.camera.zoom;
|
||||
_mapController.move(_mapController.camera.center, currentZoom + 0.5);
|
||||
},
|
||||
child: Icon(Icons.add),
|
||||
heroTag: 'zoom_in',
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () {
|
||||
final currentZoom = _mapController.camera.zoom;
|
||||
_mapController.move(_mapController.camera.center, currentZoom - 0.5);
|
||||
},
|
||||
child: Icon(Icons.remove),
|
||||
heroTag: 'zoom_out',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
|
||||
@@ -41,6 +41,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} KB"
|
||||
: '--';
|
||||
String subtitle =
|
||||
'Provider: ${area.tileProviderDisplay}\n' +
|
||||
'Z${area.minZoom}-${area.maxZoom}\n' +
|
||||
'Lat: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
|
||||
'Lat: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
|
||||
@@ -121,6 +122,10 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
name: area.name,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: area.tileProviderId,
|
||||
tileProviderName: area.tileProviderName,
|
||||
tileTypeId: area.tileTypeId,
|
||||
tileTypeName: area.tileTypeName,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
|
||||
@@ -3,57 +3,35 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/tile_provider.dart';
|
||||
import '../tile_provider_management_screen.dart';
|
||||
|
||||
class TileProviderSection extends StatelessWidget {
|
||||
const TileProviderSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final currentProvider = appState.tileProvider;
|
||||
|
||||
// Get available providers (for now, all free ones are available)
|
||||
final availableProviders = [
|
||||
TileProviders.osmStreet,
|
||||
TileProviders.googleHybrid,
|
||||
TileProviders.arcgisSatellite,
|
||||
// Don't include Mapbox for now since we don't have API key handling
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Map Type',
|
||||
'Map Tiles',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...availableProviders.map((config) {
|
||||
final isSelected = config.type == currentProvider;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Radio<TileProviderType>(
|
||||
value: config.type,
|
||||
groupValue: currentProvider,
|
||||
onChanged: (TileProviderType? value) {
|
||||
if (value != null) {
|
||||
appState.setTileProvider(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
title: Text(config.name),
|
||||
subtitle: config.description != null
|
||||
? Text(
|
||||
config.description!,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
appState.setTileProvider(config.type);
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderManagementScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
icon: const Icon(Icons.settings),
|
||||
label: const Text('Manage Providers'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
415
lib/screens/tile_provider_editor_screen.dart
Normal file
415
lib/screens/tile_provider_editor_screen.dart
Normal file
@@ -0,0 +1,415 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
|
||||
class TileProviderEditorScreen extends StatefulWidget {
|
||||
final TileProvider? provider; // null for adding new provider
|
||||
|
||||
const TileProviderEditorScreen({super.key, this.provider});
|
||||
|
||||
@override
|
||||
State<TileProviderEditorScreen> createState() => _TileProviderEditorScreenState();
|
||||
}
|
||||
|
||||
class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _apiKeyController;
|
||||
late List<TileType> _tileTypes;
|
||||
|
||||
bool get _isEditing => widget.provider != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final provider = widget.provider;
|
||||
_nameController = TextEditingController(text: provider?.name ?? '');
|
||||
_apiKeyController = TextEditingController(text: provider?.apiKey ?? '');
|
||||
_tileTypes = provider != null
|
||||
? List.from(provider.tileTypes)
|
||||
: <TileType>[];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_apiKeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? 'Edit Provider' : 'Add Provider'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saveProvider,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Provider Name',
|
||||
hintText: 'e.g., Custom Maps Inc.',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Provider name is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _apiKeyController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Key (Optional)',
|
||||
hintText: 'Enter API key if required by tile types',
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Tile Types',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _addTileType,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Type'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_tileTypes.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('No tile types configured'),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._tileTypes.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final tileType = entry.value;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(tileType.name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tileType.urlTemplate),
|
||||
Text(
|
||||
tileType.attribution,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _editTileType(index),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: _tileTypes.length > 1
|
||||
? () => _deleteTileType(index)
|
||||
: null, // Can't delete last tile type
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _editTileType(index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addTileType() {
|
||||
_showTileTypeDialog();
|
||||
}
|
||||
|
||||
void _editTileType(int index) {
|
||||
_showTileTypeDialog(tileType: _tileTypes[index], index: index);
|
||||
}
|
||||
|
||||
void _deleteTileType(int index) {
|
||||
if (_tileTypes.length <= 1) return;
|
||||
|
||||
final tileTypeToDelete = _tileTypes[index];
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
setState(() {
|
||||
_tileTypes.removeAt(index);
|
||||
});
|
||||
|
||||
// If we're deleting the currently selected tile type, switch to another one
|
||||
if (appState.selectedTileType?.id == tileTypeToDelete.id) {
|
||||
// Find first remaining tile type in this provider or any other provider
|
||||
TileType? replacement;
|
||||
if (_tileTypes.isNotEmpty) {
|
||||
replacement = _tileTypes.first;
|
||||
} else {
|
||||
// Look in other providers
|
||||
for (final provider in appState.tileProviders) {
|
||||
if (provider.availableTileTypes.isNotEmpty) {
|
||||
replacement = provider.availableTileTypes.first;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replacement != null) {
|
||||
appState.setSelectedTileType(replacement.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showTileTypeDialog({TileType? tileType, int? index}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _TileTypeDialog(
|
||||
tileType: tileType,
|
||||
onSave: (newTileType) {
|
||||
setState(() {
|
||||
if (index != null) {
|
||||
_tileTypes[index] = newTileType;
|
||||
} else {
|
||||
_tileTypes.add(newTileType);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveProvider() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_tileTypes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('At least one tile type is required')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final providerId = widget.provider?.id ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final provider = TileProvider(
|
||||
id: providerId,
|
||||
name: _nameController.text.trim(),
|
||||
apiKey: _apiKeyController.text.trim().isEmpty ? null : _apiKeyController.text.trim(),
|
||||
tileTypes: _tileTypes,
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateTileProvider(provider);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
class _TileTypeDialog extends StatefulWidget {
|
||||
final TileType? tileType;
|
||||
final Function(TileType) onSave;
|
||||
|
||||
const _TileTypeDialog({
|
||||
required this.onSave,
|
||||
this.tileType,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TileTypeDialog> createState() => _TileTypeDialogState();
|
||||
}
|
||||
|
||||
class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _urlController;
|
||||
late final TextEditingController _attributionController;
|
||||
Uint8List? _previewTile;
|
||||
bool _isLoadingPreview = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final tileType = widget.tileType;
|
||||
_nameController = TextEditingController(text: tileType?.name ?? '');
|
||||
_urlController = TextEditingController(text: tileType?.urlTemplate ?? '');
|
||||
_attributionController = TextEditingController(text: tileType?.attribution ?? '');
|
||||
_previewTile = tileType?.previewTile;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_urlController.dispose();
|
||||
_attributionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.tileType != null ? 'Edit Tile Type' : 'Add Tile Type'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
hintText: 'e.g., Satellite',
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? 'Name is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL Template',
|
||||
hintText: 'https://example.com/{z}/{x}/{y}.png',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value?.trim().isEmpty == true) return 'URL template is required';
|
||||
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
|
||||
return 'URL must contain {z}, {x}, and {y} placeholders';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _attributionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Attribution',
|
||||
hintText: '© Map Provider',
|
||||
),
|
||||
validator: (value) => value?.trim().isEmpty == true ? 'Attribution is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _isLoadingPreview ? null : _fetchPreviewTile,
|
||||
icon: _isLoadingPreview
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.preview),
|
||||
label: const Text('Fetch Preview'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_previewTile != null)
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
child: Image.memory(_previewTile!, fit: BoxFit.cover),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _saveTileType,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchPreviewTile() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingPreview = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Use a sample tile (zoom 10, somewhere in the world)
|
||||
final url = _urlController.text
|
||||
.replaceAll('{z}', '10')
|
||||
.replaceAll('{x}', '512')
|
||||
.replaceAll('{y}', '384');
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
setState(() {
|
||||
_previewTile = response.bodyBytes;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Preview tile loaded successfully')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to fetch preview: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoadingPreview = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _saveTileType() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final tileTypeId = widget.tileType?.id ??
|
||||
'${_nameController.text.toLowerCase().replaceAll(' ', '_')}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
final tileType = TileType(
|
||||
id: tileTypeId,
|
||||
name: _nameController.text.trim(),
|
||||
urlTemplate: _urlController.text.trim(),
|
||||
attribution: _attributionController.text.trim(),
|
||||
previewTile: _previewTile,
|
||||
);
|
||||
|
||||
widget.onSave(tileType);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
160
lib/screens/tile_provider_management_screen.dart
Normal file
160
lib/screens/tile_provider_management_screen.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import 'tile_provider_editor_screen.dart';
|
||||
|
||||
class TileProviderManagementScreen extends StatelessWidget {
|
||||
const TileProviderManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Tile Providers'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _addProvider(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: providers.isEmpty
|
||||
? const Center(
|
||||
child: Text('No tile providers configured'),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: providers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = providers[index];
|
||||
final isSelected = appState.selectedTileProvider?.id == provider.id;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
provider.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${provider.tileTypes.length} tile types'),
|
||||
if (provider.apiKey?.isNotEmpty == true)
|
||||
const Text(
|
||||
'API Key configured',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (!provider.isUsable)
|
||||
Text(
|
||||
'Needs API key',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: providers.length > 1
|
||||
? PopupMenuButton<String>(
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
_editProvider(context, provider);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteProvider(context, provider);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit),
|
||||
SizedBox(width: 8),
|
||||
Text('Edit'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete),
|
||||
SizedBox(width: 8),
|
||||
Text('Delete'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(Icons.lock, size: 16), // Can't delete last provider
|
||||
onTap: () => _editProvider(context, provider),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addProvider(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TileProviderEditorScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editProvider(BuildContext context, TileProvider provider) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TileProviderEditorScreen(provider: provider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteProvider(BuildContext context, TileProvider provider) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Provider'),
|
||||
content: Text('Are you sure you want to delete "${provider.name}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<AppState>().deleteTileProvider(provider.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
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';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_submodules/cameras_from_overpass.dart';
|
||||
import 'map_data_submodules/tiles_from_osm.dart';
|
||||
import 'map_data_submodules/tiles_from_remote.dart';
|
||||
import 'map_data_submodules/cameras_from_local.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
|
||||
@@ -78,7 +79,7 @@ class MapDataProvider {
|
||||
pageSize: AppState.instance.maxCameras,
|
||||
);
|
||||
} catch (e) {
|
||||
print('[MapDataProvider] Remote camera fetch failed, error: $e. Falling back to local.');
|
||||
debugPrint('[MapDataProvider] Remote camera fetch failed, error: $e. Falling back to local.');
|
||||
return fetchLocalCameras(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
@@ -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,15 +139,30 @@ 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;
|
||||
|
||||
// We guarantee that a provider and tile type are always selected
|
||||
if (selectedTileType == null || selectedProvider == null) {
|
||||
throw Exception('No tile provider selected - this should never happen');
|
||||
}
|
||||
|
||||
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
|
||||
return fetchRemoteTile(z: z, x: x, y: y, url: tileUrl);
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests (call when map view changes significantly)
|
||||
void clearTileQueue() {
|
||||
clearOSMTileQueue();
|
||||
clearRemoteTileQueue();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
@@ -43,15 +44,19 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
|
||||
print('[camerasFromOverpass] Querying Overpass...');
|
||||
print('[camerasFromOverpass] Query:\n$query');
|
||||
final resp = await http.post(Uri.parse(prodEndpoint), body: {'data': query.trim()});
|
||||
print('[camerasFromOverpass] Status: ${resp.statusCode}, Length: ${resp.body.length}');
|
||||
// Only log errors
|
||||
if (resp.statusCode != 200) {
|
||||
print('[camerasFromOverpass] Overpass failed: ${resp.body}');
|
||||
debugPrint('[camerasFromOverpass] Overpass failed: ${resp.body}');
|
||||
NetworkStatus.instance.reportOverpassIssue();
|
||||
return [];
|
||||
}
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
print('[camerasFromOverpass] Retrieved elements: ${elements.length}');
|
||||
|
||||
// Only log if many cameras found or if it's a bulk download
|
||||
if (elements.length > 20 || fetchAllPages) {
|
||||
debugPrint('[camerasFromOverpass] Retrieved ${elements.length} cameras');
|
||||
}
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
return elements.whereType<Map<String, dynamic>>().map((e) {
|
||||
return OsmCameraNode(
|
||||
|
||||
@@ -3,9 +3,14 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
import '../offline_areas/offline_tile_utils.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
/// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found.
|
||||
/// Fetch a tile from the newest offline area that matches the current provider, or throw if not found.
|
||||
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
|
||||
final appState = AppState.instance;
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
final areas = offlineService.offlineAreas;
|
||||
@@ -14,6 +19,9 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
|
||||
for (final area in areas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
if (z < area.minZoom || z > area.maxZoom) continue;
|
||||
|
||||
// Only consider areas that match the current provider/type
|
||||
if (area.tileProviderId != currentProvider?.id || area.tileTypeId != currentTileType?.id) continue;
|
||||
|
||||
// Get tile coverage for area at this zoom only
|
||||
final coveredTiles = computeTileList(area.bounds, z, z);
|
||||
@@ -28,7 +36,7 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty) {
|
||||
throw Exception('Tile $z/$x/$y not found in any offline area');
|
||||
throw Exception('Tile $z/$x/$y from current provider ${currentProvider?.id}/${currentTileType?.id} not found in any offline area');
|
||||
}
|
||||
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
|
||||
return await candidates.first.file.readAsBytes();
|
||||
|
||||
@@ -10,19 +10,24 @@ import '../network_status.dart';
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
|
||||
|
||||
/// Clear queued tile requests when map view changes significantly
|
||||
void clearOSMTileQueue() {
|
||||
void clearRemoteTileQueue() {
|
||||
final clearedCount = _tileFetchSemaphore.clearQueue();
|
||||
debugPrint('[OSMTiles] Cleared $clearedCount queued tile requests');
|
||||
// Only log if we actually cleared something significant
|
||||
if (clearedCount > 5) {
|
||||
debugPrint('[RemoteTiles] Cleared $clearedCount queued tile requests');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a tile from OSM, with in-memory retries/backoff, and global concurrency limit.
|
||||
|
||||
|
||||
/// Fetches a tile from any remote provider, with in-memory retries/backoff, and global concurrency limit.
|
||||
/// Returns tile image bytes, or throws on persistent failure.
|
||||
Future<List<int>> fetchOSMTile({
|
||||
Future<List<int>> fetchRemoteTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
final url = 'https://tile.openstreetmap.org/$z/$x/$y.png';
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
@@ -32,40 +37,44 @@ Future<List<int>> fetchOSMTile({
|
||||
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
|
||||
];
|
||||
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire();
|
||||
try {
|
||||
print('[fetchOSMTile] FETCH $z/$x/$y');
|
||||
// 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('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}');
|
||||
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
print('[fetchOSMTile] SUCCESS $z/$x/$y');
|
||||
NetworkStatus.instance.reportOsmTileSuccess();
|
||||
// Success - no logging for normal operation
|
||||
NetworkStatus.instance.reportOsmTileSuccess(); // Generic tile server reporting
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
NetworkStatus.instance.reportOsmTileIssue();
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
|
||||
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[fetchOSMTile] Exception $z/$x/$y: $e');
|
||||
|
||||
// Report network issues on connection errors
|
||||
if (e.toString().contains('Connection refused') ||
|
||||
e.toString().contains('Connection timed out') ||
|
||||
e.toString().contains('Connection reset')) {
|
||||
NetworkStatus.instance.reportOsmTileIssue();
|
||||
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
|
||||
}
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
print("[fetchOSMTile] Failed for $z/$x/$y 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("[fetchOSMTile] Attempt $attempt for $z/$x/$y 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();
|
||||
@@ -73,6 +82,21 @@ Future<List<int>> fetchOSMTile({
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy function for backward compatibility
|
||||
@Deprecated('Use fetchRemoteTile instead')
|
||||
Future<List<int>> fetchOSMTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
}) async {
|
||||
return fetchRemoteTile(
|
||||
z: z,
|
||||
x: x,
|
||||
y: y,
|
||||
url: 'https://tile.openstreetmap.org/$z/$x/$y.png',
|
||||
);
|
||||
}
|
||||
|
||||
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
@@ -4,7 +4,7 @@ import 'dart:async';
|
||||
import '../app_state.dart';
|
||||
|
||||
enum NetworkIssueType { osmTiles, overpassApi, both }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
|
||||
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
static final NetworkStatus instance = NetworkStatus._();
|
||||
@@ -15,11 +15,13 @@ class NetworkStatus extends ChangeNotifier {
|
||||
bool _isWaitingForData = false;
|
||||
bool _isTimedOut = false;
|
||||
bool _hasNoData = false;
|
||||
bool _hasSuccess = false;
|
||||
int _recentOfflineMisses = 0;
|
||||
Timer? _osmRecoveryTimer;
|
||||
Timer? _overpassRecoveryTimer;
|
||||
Timer? _waitingTimer;
|
||||
Timer? _noDataResetTimer;
|
||||
Timer? _successResetTimer;
|
||||
|
||||
// Getters
|
||||
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
|
||||
@@ -28,12 +30,14 @@ class NetworkStatus extends ChangeNotifier {
|
||||
bool get isWaitingForData => _isWaitingForData;
|
||||
bool get isTimedOut => _isTimedOut;
|
||||
bool get hasNoData => _hasNoData;
|
||||
bool get hasSuccess => _hasSuccess;
|
||||
|
||||
NetworkStatusType get currentStatus {
|
||||
if (hasAnyIssues) return NetworkStatusType.issues;
|
||||
if (_isWaitingForData) return NetworkStatusType.waiting;
|
||||
if (_isTimedOut) return NetworkStatusType.timedOut;
|
||||
if (_hasNoData) return NetworkStatusType.noData;
|
||||
if (_hasSuccess) return NetworkStatusType.success;
|
||||
return NetworkStatusType.ready;
|
||||
}
|
||||
|
||||
@@ -44,12 +48,12 @@ class NetworkStatus extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Report OSM tile server issues
|
||||
/// Report tile server issues (for any provider)
|
||||
void reportOsmTileIssue() {
|
||||
if (!_osmTilesHaveIssues) {
|
||||
_osmTilesHaveIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] OSM tile server issues detected');
|
||||
debugPrint('[NetworkStatus] Tile server issues detected');
|
||||
}
|
||||
|
||||
// Reset recovery timer - if we keep getting errors, keep showing indicator
|
||||
@@ -57,7 +61,7 @@ class NetworkStatus extends ChangeNotifier {
|
||||
_osmRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_osmTilesHaveIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] OSM tile server issues cleared');
|
||||
debugPrint('[NetworkStatus] Tile server issues cleared');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,7 +86,7 @@ class NetworkStatus extends ChangeNotifier {
|
||||
void reportOsmTileSuccess() {
|
||||
// Clear issues immediately on success (they were likely temporary)
|
||||
if (_osmTilesHaveIssues) {
|
||||
debugPrint('[NetworkStatus] OSM tile server issues cleared after success');
|
||||
// Quietly clear - don't log routine success
|
||||
_osmTilesHaveIssues = false;
|
||||
_osmRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
@@ -91,7 +95,7 @@ class NetworkStatus extends ChangeNotifier {
|
||||
|
||||
void reportOverpassSuccess() {
|
||||
if (_overpassHasIssues) {
|
||||
debugPrint('[NetworkStatus] Overpass API issues cleared after success');
|
||||
// Quietly clear - don't log routine success
|
||||
_overpassHasIssues = false;
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
@@ -109,40 +113,76 @@ class NetworkStatus extends ChangeNotifier {
|
||||
if (!_isWaitingForData) {
|
||||
_isWaitingForData = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Waiting for data...');
|
||||
// Don't log routine waiting - only log if we stay waiting too long
|
||||
}
|
||||
|
||||
// Set timeout to show appropriate status after reasonable time
|
||||
// Set timeout for genuine network issues (not 404s)
|
||||
_waitingTimer?.cancel();
|
||||
_waitingTimer = Timer(const Duration(seconds: 10), () {
|
||||
_waitingTimer = Timer(const Duration(seconds: 8), () {
|
||||
_isWaitingForData = false;
|
||||
|
||||
// If in offline mode, this is "no data" not "timed out"
|
||||
if (AppState.instance.offlineMode) {
|
||||
_hasNoData = true;
|
||||
debugPrint('[NetworkStatus] No offline data available (timeout in offline mode)');
|
||||
} else {
|
||||
_isTimedOut = true;
|
||||
debugPrint('[NetworkStatus] Data request timed out (online mode)');
|
||||
}
|
||||
|
||||
_isTimedOut = true;
|
||||
debugPrint('[NetworkStatus] Request timed out - likely network issues');
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear waiting/timeout/no-data status when data arrives
|
||||
/// Show success status briefly when data loads
|
||||
void setSuccess() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = true;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear success status after 2 seconds
|
||||
_successResetTimer?.cancel();
|
||||
_successResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasSuccess) {
|
||||
_hasSuccess = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show no-data status briefly when tiles aren't available
|
||||
void setNoData() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasSuccess = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear no-data status after 2 seconds
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasNoData) {
|
||||
_hasNoData = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
|
||||
void clearWaiting() {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData) {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Waiting/timeout/no-data status cleared - data arrived');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Report that a tile was not available offline
|
||||
void reportOfflineMiss() {
|
||||
|
||||
@@ -60,6 +60,7 @@ class OfflineAreaService {
|
||||
|
||||
await _loadAreasFromDisk();
|
||||
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
|
||||
await saveAreasToDisk(); // Save any world area updates
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
@@ -182,6 +183,10 @@ class OfflineAreaService {
|
||||
void Function(double progress)? onProgress,
|
||||
void Function(OfflineAreaStatus status)? onComplete,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) async {
|
||||
OfflineArea? area;
|
||||
for (final a in _areas) {
|
||||
@@ -202,21 +207,25 @@ class OfflineAreaService {
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
isPermanent: area?.isPermanent ?? false,
|
||||
tileProviderId: tileProviderId,
|
||||
tileProviderName: tileProviderName,
|
||||
tileTypeId: tileTypeId,
|
||||
tileTypeName: tileTypeName,
|
||||
);
|
||||
_areas.add(area);
|
||||
await saveAreasToDisk();
|
||||
|
||||
try {
|
||||
final success = await OfflineAreaDownloader.downloadArea(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
final success = await OfflineAreaDownloader.downloadArea(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
|
||||
await getAreaSizeBytes(area);
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ class OfflineAreaDownloader {
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
// Calculate tiles to download
|
||||
Set<List<int>> allTiles;
|
||||
if (area.isPermanent) {
|
||||
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
|
||||
@@ -81,7 +80,7 @@ class OfflineAreaDownloader {
|
||||
for (final tile in tilesToFetch) {
|
||||
if (area.status == OfflineAreaStatus.cancelled) break;
|
||||
|
||||
if (await _downloadSingleTile(tile, directory)) {
|
||||
if (await _downloadSingleTile(tile, directory, area)) {
|
||||
totalDone++;
|
||||
area.tilesDownloaded = totalDone;
|
||||
area.progress = area.tilesTotal == 0 ? 0.0 : (totalDone / area.tilesTotal);
|
||||
@@ -102,14 +101,20 @@ class OfflineAreaDownloader {
|
||||
return false; // Failed after max retries
|
||||
}
|
||||
|
||||
/// Download a single tile
|
||||
static Future<bool> _downloadSingleTile(List<int> tile, String directory) async {
|
||||
/// Download a single tile using the unified MapDataProvider path
|
||||
static Future<bool> _downloadSingleTile(
|
||||
List<int> tile,
|
||||
String directory,
|
||||
OfflineArea area,
|
||||
) async {
|
||||
try {
|
||||
// Use the same unified path as live tiles: always go through MapDataProvider
|
||||
// MapDataProvider will use current AppState provider for downloads
|
||||
final bytes = await MapDataProvider().getTile(
|
||||
z: tile[0],
|
||||
x: tile[1],
|
||||
y: tile[2],
|
||||
source: MapSource.remote,
|
||||
source: MapSource.remote, // Force remote fetch for downloads
|
||||
);
|
||||
if (bytes.isNotEmpty) {
|
||||
await OfflineAreaDownloader.saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
|
||||
@@ -144,11 +149,11 @@ class OfflineAreaDownloader {
|
||||
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
|
||||
final cameras = await MapDataProvider().getAllCamerasForDownload(
|
||||
bounds: cameraBounds,
|
||||
profiles: AppState.instance.enabledProfiles,
|
||||
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
|
||||
);
|
||||
area.cameras = cameras;
|
||||
await OfflineAreaDownloader.saveCameras(cameras, directory);
|
||||
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds');
|
||||
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds (all profiles)');
|
||||
}
|
||||
|
||||
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
|
||||
|
||||
@@ -20,6 +20,12 @@ class OfflineArea {
|
||||
List<OsmCameraNode> cameras;
|
||||
int sizeBytes; // Disk size in bytes
|
||||
final bool isPermanent; // Not user-deletable if true
|
||||
|
||||
// Tile provider metadata (null for legacy areas)
|
||||
final String? tileProviderId;
|
||||
final String? tileProviderName;
|
||||
final String? tileTypeId;
|
||||
final String? tileTypeName;
|
||||
|
||||
OfflineArea({
|
||||
required this.id,
|
||||
@@ -35,6 +41,10 @@ class OfflineArea {
|
||||
this.cameras = const [],
|
||||
this.sizeBytes = 0,
|
||||
this.isPermanent = false,
|
||||
this.tileProviderId,
|
||||
this.tileProviderName,
|
||||
this.tileTypeId,
|
||||
this.tileTypeName,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -54,6 +64,10 @@ class OfflineArea {
|
||||
'cameras': cameras.map((c) => c.toJson()).toList(),
|
||||
'sizeBytes': sizeBytes,
|
||||
'isPermanent': isPermanent,
|
||||
'tileProviderId': tileProviderId,
|
||||
'tileProviderName': tileProviderName,
|
||||
'tileTypeId': tileTypeId,
|
||||
'tileTypeName': tileTypeName,
|
||||
};
|
||||
|
||||
static OfflineArea fromJson(Map<String, dynamic> json) {
|
||||
@@ -77,6 +91,27 @@ class OfflineArea {
|
||||
.map((e) => OsmCameraNode.fromJson(e)).toList(),
|
||||
sizeBytes: json['sizeBytes'] ?? 0,
|
||||
isPermanent: json['isPermanent'] ?? false,
|
||||
tileProviderId: json['tileProviderId'],
|
||||
tileProviderName: json['tileProviderName'],
|
||||
tileTypeId: json['tileTypeId'],
|
||||
tileTypeName: json['tileTypeName'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Get display text for the tile provider used in this area
|
||||
String get tileProviderDisplay {
|
||||
if (tileProviderName != null && tileTypeName != null) {
|
||||
return '$tileProviderName - $tileTypeName';
|
||||
} else if (tileTypeName != null) {
|
||||
return tileTypeName!;
|
||||
} else if (tileProviderName != null) {
|
||||
return tileProviderName!;
|
||||
} else {
|
||||
// Legacy area - assume OSM
|
||||
return 'OpenStreetMap (Legacy)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this area has tile provider metadata
|
||||
bool get hasTileProviderInfo => tileProviderId != null && tileTypeId != null;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ class WorldAreaManager {
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
// Find existing world area
|
||||
@@ -34,7 +38,7 @@ class WorldAreaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Create world area if it doesn't exist
|
||||
// Create world area if it doesn't exist, or update existing area without provider info
|
||||
if (world == null) {
|
||||
final appDocDir = await getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$_worldAreaId";
|
||||
@@ -47,8 +51,38 @@ class WorldAreaManager {
|
||||
directory: dir,
|
||||
status: OfflineAreaStatus.downloading,
|
||||
isPermanent: true,
|
||||
// World area always uses OpenStreetMap
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
areas.insert(0, world);
|
||||
} else if (world.tileProviderId == null || world.tileTypeId == null) {
|
||||
// Update existing world area that lacks provider metadata
|
||||
final updatedWorld = OfflineArea(
|
||||
id: world.id,
|
||||
name: world.name,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
status: world.status,
|
||||
progress: world.progress,
|
||||
tilesDownloaded: world.tilesDownloaded,
|
||||
tilesTotal: world.tilesTotal,
|
||||
cameras: world.cameras,
|
||||
sizeBytes: world.sizeBytes,
|
||||
isPermanent: world.isPermanent,
|
||||
// Add missing provider metadata
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
final index = areas.indexOf(world);
|
||||
areas[index] = updatedWorld;
|
||||
world = updatedWorld;
|
||||
}
|
||||
|
||||
// Check world area status and start download if needed
|
||||
@@ -66,6 +100,10 @@ class WorldAreaManager {
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
if (world.status == OfflineAreaStatus.complete) return;
|
||||
@@ -97,7 +135,7 @@ class WorldAreaManager {
|
||||
world.status = OfflineAreaStatus.downloading;
|
||||
debugPrint('WorldAreaManager: Starting world area download. ${world.tilesDownloaded}/${world.tilesTotal} tiles found.');
|
||||
|
||||
// Start download (fire and forget)
|
||||
// Start download (fire and forget) - use OSM for world areas
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
@@ -105,6 +143,10 @@ class WorldAreaManager {
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,47 +13,52 @@ class SimpleTileHttpClient extends http.BaseClient {
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
// Only intercept tile requests to OSM (for now - other providers pass through)
|
||||
if (request.url.host == 'tile.openstreetmap.org') {
|
||||
return _handleTileRequest(request);
|
||||
// Extract tile coordinates from our custom URL scheme
|
||||
final tileCoords = _extractTileCoords(request.url);
|
||||
if (tileCoords != null) {
|
||||
final z = tileCoords['z']!;
|
||||
final x = tileCoords['x']!;
|
||||
final y = tileCoords['y']!;
|
||||
return _handleTileRequest(z, x, y);
|
||||
}
|
||||
|
||||
// Pass through all other requests (Google, Mapbox, etc.)
|
||||
// Pass through non-tile requests
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _handleTileRequest(http.BaseRequest request) async {
|
||||
final pathSegments = request.url.pathSegments;
|
||||
/// Extract z/x/y coordinates from our fake domain: https://tiles.local/provider/type/z/x/y
|
||||
/// We ignore the provider/type in the URL since we use current AppState for actual fetching
|
||||
Map<String, int>? _extractTileCoords(Uri url) {
|
||||
if (url.host != 'tiles.local') return null;
|
||||
|
||||
// Parse z/x/y from URL like: /15/5242/12666.png
|
||||
if (pathSegments.length == 3) {
|
||||
final z = int.tryParse(pathSegments[0]);
|
||||
final x = int.tryParse(pathSegments[1]);
|
||||
final yPng = pathSegments[2];
|
||||
final y = int.tryParse(yPng.replaceAll('.png', ''));
|
||||
|
||||
if (z != null && x != null && y != null) {
|
||||
return _getTile(z, x, y);
|
||||
}
|
||||
final pathSegments = url.pathSegments;
|
||||
if (pathSegments.length != 5) return null;
|
||||
|
||||
// pathSegments[0] = providerId (for cache separation only)
|
||||
// pathSegments[1] = tileTypeId (for cache separation only)
|
||||
final z = int.tryParse(pathSegments[2]);
|
||||
final x = int.tryParse(pathSegments[3]);
|
||||
final y = int.tryParse(pathSegments[4]);
|
||||
|
||||
if (z != null && x != null && y != null) {
|
||||
return {'z': z, 'x': x, 'y': y};
|
||||
}
|
||||
|
||||
// Malformed tile URL - pass through to OSM
|
||||
return _inner.send(request);
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _getTile(int z, int x, int y) async {
|
||||
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
|
||||
try {
|
||||
// First try to get tile from offline storage
|
||||
final localTileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.local);
|
||||
// Always go through MapDataProvider - it handles offline/online routing
|
||||
// MapDataProvider will get current provider from AppState
|
||||
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
|
||||
|
||||
debugPrint('[SimpleTileService] Serving tile $z/$x/$y from offline storage');
|
||||
// Show success status briefly
|
||||
NetworkStatus.instance.setSuccess();
|
||||
|
||||
// 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',
|
||||
@@ -64,39 +69,17 @@ 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 OSM 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',
|
||||
);
|
||||
}
|
||||
// 404 means no tiles available - show "no data" status briefly
|
||||
NetworkStatus.instance.setNoData();
|
||||
|
||||
// We're online - try OSM with proper error handling
|
||||
debugPrint('[SimpleTileService] Online mode - trying OSM for $z/$x/$y');
|
||||
try {
|
||||
final response = await _inner.send(http.Request('GET', Uri.parse('https://tile.openstreetmap.org/$z/$x/$y.png')));
|
||||
// Clear waiting status on successful network tile
|
||||
if (response.statusCode == 200) {
|
||||
NetworkStatus.instance.clearWaiting();
|
||||
}
|
||||
return response;
|
||||
} catch (networkError) {
|
||||
debugPrint('[SimpleTileService] OSM 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',
|
||||
);
|
||||
}
|
||||
// Return 404 and let flutter_map handle it gracefully
|
||||
return http.StreamedResponse(
|
||||
Stream.value(<int>[]),
|
||||
404,
|
||||
reasonPhrase: 'Tile unavailable: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../models/tile_provider.dart';
|
||||
|
||||
@@ -10,19 +12,55 @@ class SettingsState extends ChangeNotifier {
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
static const String _maxCamerasPrefsKey = 'max_cameras';
|
||||
static const String _uploadModePrefsKey = 'upload_mode';
|
||||
static const String _tileProviderPrefsKey = 'tile_provider';
|
||||
static const String _tileProvidersPrefsKey = 'tile_providers';
|
||||
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
|
||||
static const String _legacyTestModePrefsKey = 'test_mode';
|
||||
|
||||
bool _offlineMode = false;
|
||||
int _maxCameras = 250;
|
||||
UploadMode _uploadMode = UploadMode.simulate;
|
||||
TileProviderType _tileProvider = TileProviderType.osmStreet;
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
|
||||
// Getters
|
||||
bool get offlineMode => _offlineMode;
|
||||
int get maxCameras => _maxCameras;
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
TileProviderType get tileProvider => _tileProvider;
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
|
||||
/// Get the currently selected tile type
|
||||
TileType? get selectedTileType {
|
||||
for (final provider in _tileProviders) {
|
||||
for (final tileType in provider.tileTypes) {
|
||||
if (tileType.id == _selectedTileTypeId) {
|
||||
return tileType;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get the provider that contains the selected tile type
|
||||
TileProvider? get selectedTileProvider {
|
||||
for (final provider in _tileProviders) {
|
||||
if (provider.tileTypes.any((type) => type.id == _selectedTileTypeId)) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all available tile types from all providers
|
||||
List<TileType> get allAvailableTileTypes {
|
||||
final types = <TileType>[];
|
||||
for (final provider in _tileProviders) {
|
||||
types.addAll(provider.availableTileTypes);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Initialize settings from preferences
|
||||
Future<void> init() async {
|
||||
@@ -50,15 +88,53 @@ class SettingsState extends ChangeNotifier {
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
|
||||
// Load tile provider
|
||||
if (prefs.containsKey(_tileProviderPrefsKey)) {
|
||||
final idx = prefs.getInt(_tileProviderPrefsKey) ?? 0;
|
||||
if (idx >= 0 && idx < TileProviderType.values.length) {
|
||||
_tileProvider = TileProviderType.values[idx];
|
||||
// Load tile providers (default to built-in providers if none saved)
|
||||
await _loadTileProviders(prefs);
|
||||
|
||||
// Load selected tile type (default to first available)
|
||||
_selectedTileTypeId = prefs.getString(_selectedTileTypePrefsKey) ?? '';
|
||||
if (_selectedTileTypeId.isEmpty || selectedTileType == null) {
|
||||
final firstType = allAvailableTileTypes.firstOrNull;
|
||||
if (firstType != null) {
|
||||
_selectedTileTypeId = firstType.id;
|
||||
await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTileProviders(SharedPreferences prefs) async {
|
||||
if (prefs.containsKey(_tileProvidersPrefsKey)) {
|
||||
try {
|
||||
final providersJson = prefs.getString(_tileProvidersPrefsKey);
|
||||
if (providersJson != null) {
|
||||
final providersList = jsonDecode(providersJson) as List;
|
||||
_tileProviders = providersList
|
||||
.map((json) => TileProvider.fromJson(json))
|
||||
.toList();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading tile providers: $e');
|
||||
// Fall back to defaults on error
|
||||
_tileProviders = DefaultTileProviders.createDefaults();
|
||||
}
|
||||
} else {
|
||||
// First time - use defaults
|
||||
_tileProviders = DefaultTileProviders.createDefaults();
|
||||
await _saveTileProviders(prefs);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveTileProviders(SharedPreferences prefs) async {
|
||||
try {
|
||||
final providersJson = jsonEncode(
|
||||
_tileProviders.map((provider) => provider.toJson()).toList(),
|
||||
);
|
||||
await prefs.setString(_tileProvidersPrefsKey, providersJson);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving tile providers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
_offlineMode = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -82,10 +158,58 @@ class SettingsState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setTileProvider(TileProviderType provider) async {
|
||||
_tileProvider = provider;
|
||||
/// Select a tile type by ID
|
||||
Future<void> setSelectedTileType(String tileTypeId) async {
|
||||
if (_selectedTileTypeId != tileTypeId) {
|
||||
_selectedTileTypeId = tileTypeId;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_selectedTileTypePrefsKey, tileTypeId);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a tile provider
|
||||
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
|
||||
final existingIndex = _tileProviders.indexWhere((p) => p.id == provider.id);
|
||||
if (existingIndex >= 0) {
|
||||
_tileProviders[existingIndex] = provider;
|
||||
} else {
|
||||
_tileProviders.add(provider);
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_tileProviderPrefsKey, provider.index);
|
||||
await _saveTileProviders(prefs);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Delete a tile provider
|
||||
Future<void> deleteTileProvider(String providerId) async {
|
||||
// Don't allow deleting all providers
|
||||
if (_tileProviders.length <= 1) return;
|
||||
|
||||
final providerToDelete = _tileProviders.firstWhereOrNull((p) => p.id == providerId);
|
||||
if (providerToDelete == null) return;
|
||||
|
||||
// If selected tile type belongs to this provider, switch to another
|
||||
if (providerToDelete.tileTypes.any((type) => type.id == _selectedTileTypeId)) {
|
||||
// Find first available tile type from remaining providers
|
||||
final remainingProviders = _tileProviders.where((p) => p.id != providerId).toList();
|
||||
final firstAvailable = remainingProviders
|
||||
.expand((p) => p.availableTileTypes)
|
||||
.firstOrNull;
|
||||
|
||||
if (firstAvailable != null) {
|
||||
_selectedTileTypeId = firstAvailable.id;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId);
|
||||
}
|
||||
}
|
||||
|
||||
_tileProviders.removeWhere((p) => p.id == providerId);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _saveTileProviders(prefs);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
42
lib/widgets/camera_icon.dart
Normal file
42
lib/widgets/camera_icon.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
enum CameraIconType {
|
||||
real, // Blue ring - real cameras from OSM
|
||||
mock, // White ring - add camera mock point
|
||||
pending, // Purple ring - submitted/pending cameras
|
||||
}
|
||||
|
||||
/// Simple camera icon with grey dot and colored ring
|
||||
class CameraIcon extends StatelessWidget {
|
||||
final CameraIconType type;
|
||||
|
||||
const CameraIcon({super.key, required this.type});
|
||||
|
||||
Color get _ringColor {
|
||||
switch (type) {
|
||||
case CameraIconType.real:
|
||||
return kCameraRingColorReal;
|
||||
case CameraIconType.mock:
|
||||
return kCameraRingColorMock;
|
||||
case CameraIconType.pending:
|
||||
return kCameraRingColorPending;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: kCameraIconDiameter,
|
||||
height: kCameraIconDiameter,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withOpacity(kCameraDotOpacity),
|
||||
border: Border.all(
|
||||
color: _ringColor,
|
||||
width: kCameraRingThickness,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@ class CameraTagSheet extends StatelessWidget {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Camera #${node.id}',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
@@ -55,6 +56,7 @@ class CameraTagSheet extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,6 +236,11 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
@@ -246,6 +251,10 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: selectedProvider?.id,
|
||||
tileProviderName: selectedProvider?.name,
|
||||
tileTypeId: selectedTileType?.id,
|
||||
tileTypeName: selectedTileType?.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../camera_tag_sheet.dart';
|
||||
import '../camera_icon.dart';
|
||||
|
||||
/// Smart marker widget for camera with single/double tap distinction
|
||||
class CameraMapMarker extends StatefulWidget {
|
||||
@@ -52,9 +53,8 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
child: Icon(
|
||||
Icons.videocam,
|
||||
color: isPending ? Colors.purple : Colors.orange,
|
||||
child: CameraIcon(
|
||||
type: isPending ? CameraIconType.pending : CameraIconType.real,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -73,8 +73,8 @@ class CameraMarkersBuilder {
|
||||
.where(_isValidCameraCoordinate)
|
||||
.map((n) => Marker(
|
||||
point: n.coord,
|
||||
width: 24,
|
||||
height: 24,
|
||||
width: kCameraIconDiameter,
|
||||
height: kCameraIconDiameter,
|
||||
child: CameraMapMarker(node: n, mapController: mapController),
|
||||
)),
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ class DirectionConesBuilder {
|
||||
n.coord,
|
||||
n.directionDeg!,
|
||||
zoom,
|
||||
isPending: _isPendingUpload(n),
|
||||
))
|
||||
);
|
||||
|
||||
@@ -58,9 +57,13 @@ class DirectionConesBuilder {
|
||||
double bearingDeg,
|
||||
double zoom, {
|
||||
bool isPending = false,
|
||||
bool isSession = false,
|
||||
}) {
|
||||
final halfAngle = kDirectionConeHalfAngle;
|
||||
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
|
||||
|
||||
// Number of points to create the arc (more = smoother curve)
|
||||
const int arcPoints = 12;
|
||||
|
||||
LatLng project(double deg) {
|
||||
final rad = deg * math.pi / 180;
|
||||
@@ -70,16 +73,22 @@ class DirectionConesBuilder {
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
}
|
||||
|
||||
final left = project(bearingDeg - halfAngle);
|
||||
final right = project(bearingDeg + halfAngle);
|
||||
|
||||
// Use purple color for pending uploads
|
||||
final color = isPending ? Colors.purple : Colors.redAccent;
|
||||
// Build pizza slice with curved edge
|
||||
final points = <LatLng>[origin];
|
||||
|
||||
// Add arc points from left to right
|
||||
for (int i = 0; i <= arcPoints; i++) {
|
||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||
points.add(project(angle));
|
||||
}
|
||||
|
||||
// Close the shape back to origin
|
||||
points.add(origin);
|
||||
|
||||
return Polygon(
|
||||
points: [origin, left, right, origin],
|
||||
color: color.withOpacity(0.25),
|
||||
borderColor: color,
|
||||
points: points,
|
||||
color: kDirectionConeColor.withOpacity(0.25),
|
||||
borderColor: kDirectionConeColor,
|
||||
borderStrokeWidth: 1,
|
||||
);
|
||||
}
|
||||
|
||||
234
lib/widgets/map/layer_selector_button.dart
Normal file
234
lib/widgets/map/layer_selector_button.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/tile_provider.dart';
|
||||
import '../../services/offline_area_service.dart';
|
||||
|
||||
class LayerSelectorButton extends StatelessWidget {
|
||||
const LayerSelectorButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () => _showLayerSelector(context),
|
||||
child: const Icon(Icons.layers),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLayerSelector(BuildContext context) {
|
||||
// Check if any downloads are active
|
||||
final offlineService = OfflineAreaService();
|
||||
if (offlineService.hasActiveDownloads) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cannot change tile types while downloading offline areas'),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const _LayerSelectorDialog(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LayerSelectorDialog extends StatefulWidget {
|
||||
const _LayerSelectorDialog();
|
||||
|
||||
@override
|
||||
State<_LayerSelectorDialog> createState() => _LayerSelectorDialogState();
|
||||
}
|
||||
|
||||
class _LayerSelectorDialogState extends State<_LayerSelectorDialog> {
|
||||
String? _selectedTileTypeId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
_selectedTileTypeId = appState.selectedTileType?.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final providers = appState.tileProviders;
|
||||
|
||||
// Group tile types by provider for display
|
||||
final providerGroups = <TileProvider, List<TileType>>{};
|
||||
for (final provider in providers) {
|
||||
final availableTypes = provider.availableTileTypes;
|
||||
if (availableTypes.isNotEmpty) {
|
||||
providerGroups[provider] = availableTypes;
|
||||
}
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: double.maxFinite,
|
||||
constraints: const BoxConstraints(maxHeight: 500),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.layers),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Select Map Layer',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
if (providerGroups.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text('No tile providers available'),
|
||||
),
|
||||
)
|
||||
else
|
||||
...providerGroups.entries.map((entry) {
|
||||
final provider = entry.key;
|
||||
final tileTypes = entry.value;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Provider header
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Text(
|
||||
provider.name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tile types
|
||||
...tileTypes.map((tileType) => _TileTypeListItem(
|
||||
tileType: tileType,
|
||||
provider: provider,
|
||||
isSelected: _selectedTileTypeId == tileType.id,
|
||||
onSelected: () {
|
||||
setState(() {
|
||||
_selectedTileTypeId = tileType.id;
|
||||
});
|
||||
appState.setSelectedTileType(tileType.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileTypeListItem extends StatelessWidget {
|
||||
final TileType tileType;
|
||||
final TileProvider provider;
|
||||
final bool isSelected;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
const _TileTypeListItem({
|
||||
required this.tileType,
|
||||
required this.provider,
|
||||
required this.isSelected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: tileType.previewTile != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: Image.memory(
|
||||
tileType.previewTile!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => _FallbackPreview(),
|
||||
),
|
||||
)
|
||||
: _FallbackPreview(),
|
||||
),
|
||||
title: Text(
|
||||
tileType.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : null,
|
||||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
tileType.attribution,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: onSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FallbackPreview extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.grey.shade200,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
size: 24,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,22 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../camera_icon.dart';
|
||||
import 'layer_selector_button.dart';
|
||||
|
||||
/// Widget that renders all map overlay UI elements
|
||||
class MapOverlays extends StatelessWidget {
|
||||
final MapController mapController;
|
||||
final UploadMode uploadMode;
|
||||
final AddCameraSession? session;
|
||||
final String? attribution; // Attribution for current tile provider
|
||||
|
||||
const MapOverlays({
|
||||
super.key,
|
||||
required this.mapController,
|
||||
required this.uploadMode,
|
||||
this.session,
|
||||
this.attribution,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -78,17 +82,52 @@ class MapOverlays extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Attribution overlay
|
||||
Positioned(
|
||||
bottom: kAttributionBottomOffset,
|
||||
left: 10,
|
||||
child: Container(
|
||||
color: Colors.white70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: const Text(
|
||||
'© OpenStreetMap and contributors',
|
||||
style: TextStyle(fontSize: 11),
|
||||
if (attribution != null)
|
||||
Positioned(
|
||||
bottom: kAttributionBottomOffset,
|
||||
left: 10,
|
||||
child: Container(
|
||||
color: Colors.white70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom and layer controls (bottom-right)
|
||||
Positioned(
|
||||
bottom: 80,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
// Layer selector button
|
||||
const LayerSelectorButton(),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom in button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_in",
|
||||
onPressed: () {
|
||||
final zoom = mapController.camera.zoom;
|
||||
mapController.move(mapController.camera.center, zoom + 1);
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom out button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_out",
|
||||
onPressed: () {
|
||||
final zoom = mapController.camera.zoom;
|
||||
mapController.move(mapController.camera.center, zoom - 1);
|
||||
},
|
||||
child: const Icon(Icons.remove),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Fixed pin when adding camera
|
||||
@@ -97,7 +136,7 @@ class MapOverlays extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, kAddPinYOffset),
|
||||
child: const Icon(Icons.place, size: 40, color: Colors.redAccent),
|
||||
child: const CameraIcon(type: CameraIconType.mock),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
@@ -53,6 +54,11 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
// Track zoom to clear queue on zoom changes
|
||||
double? _lastZoom;
|
||||
|
||||
// Track changes that require cache clearing
|
||||
String? _lastTileTypeId;
|
||||
bool? _lastOfflineMode;
|
||||
int _mapRebuildKey = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -122,6 +128,10 @@ class MapViewState extends State<MapView> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -170,38 +180,22 @@ 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 fake domain that SimpleTileHttpClient can parse
|
||||
Widget _buildTileLayer(AppState appState) {
|
||||
final providerConfig = TileProviders.getByType(appState.tileProvider);
|
||||
if (providerConfig == null) {
|
||||
// Fallback to OSM if somehow we have an invalid provider
|
||||
return TileLayer(
|
||||
urlTemplate: TileProviders.osmStreet.urlTemplate,
|
||||
userAgentPackageName: 'com.stopflock.flock_map_app',
|
||||
tileProvider: NetworkTileProvider(
|
||||
httpClient: _tileHttpClient,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// For OSM tiles, use our custom HTTP client for offline/online routing
|
||||
if (providerConfig.type == TileProviderType.osmStreet) {
|
||||
return TileLayer(
|
||||
urlTemplate: providerConfig.urlTemplate,
|
||||
userAgentPackageName: 'com.stopflock.flock_map_app',
|
||||
tileProvider: NetworkTileProvider(
|
||||
httpClient: _tileHttpClient,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// For other providers, use standard HTTP client (no offline support yet)
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
|
||||
// Use fake domain with standard HTTPS scheme: https://tiles.local/provider/type/z/x/y
|
||||
// This naturally separates cache entries by provider and type while being HTTP-compatible
|
||||
final urlTemplate = 'https://tiles.local/${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
|
||||
|
||||
return TileLayer(
|
||||
urlTemplate: providerConfig.urlTemplate,
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'com.stopflock.flock_map_app',
|
||||
additionalOptions: {
|
||||
'attribution': providerConfig.attribution,
|
||||
},
|
||||
tileProvider: NetworkTileProvider(
|
||||
httpClient: _tileHttpClient,
|
||||
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,6 +213,8 @@ class MapViewState extends State<MapView> {
|
||||
_lastEnabledProfiles = List.from(currentEnabledProfiles);
|
||||
// Refresh cameras when profiles change
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clear camera cache to ensure fresh data for new profile combination
|
||||
_cameraProvider.clearCache();
|
||||
// Force display refresh first (for immediate UI update)
|
||||
_cameraProvider.refreshDisplay();
|
||||
// Then fetch new cameras for newly enabled profiles
|
||||
@@ -226,6 +222,27 @@ class MapViewState extends State<MapView> {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if tile type OR offline mode changed and clear cache if needed
|
||||
final currentTileTypeId = appState.selectedTileType?.id;
|
||||
final currentOfflineMode = appState.offlineMode;
|
||||
|
||||
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId) ||
|
||||
(_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
|
||||
// Force map rebuild with new key to bust flutter_map cache
|
||||
_mapRebuildKey++;
|
||||
final reason = _lastTileTypeId != currentTileTypeId
|
||||
? 'tile type ($currentTileTypeId)'
|
||||
: 'offline mode ($currentOfflineMode)';
|
||||
debugPrint('[MapView] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
debugPrint('[MapView] Post-frame: Clearing tile request queue');
|
||||
_tileHttpClient.clearTileQueue();
|
||||
});
|
||||
}
|
||||
|
||||
_lastTileTypeId = currentTileTypeId;
|
||||
_lastOfflineMode = currentOfflineMode;
|
||||
|
||||
// Seed add‑mode target once, after first controller center is available.
|
||||
if (session != null && session.target == null) {
|
||||
try {
|
||||
@@ -274,7 +291,7 @@ class MapViewState extends State<MapView> {
|
||||
return Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: ValueKey('map_offline_${appState.offlineMode}_provider_${appState.tileProvider.name}'),
|
||||
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_$_mapRebuildKey'),
|
||||
mapController: _controller,
|
||||
options: MapOptions(
|
||||
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),
|
||||
@@ -296,7 +313,7 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
if (zoomChanged) {
|
||||
_tileDebounce(() {
|
||||
debugPrint('[MapView] Zoom change detected - clearing stale tile requests');
|
||||
// Clear stale tile requests on zoom change (quietly)
|
||||
_tileHttpClient.clearTileQueue();
|
||||
});
|
||||
}
|
||||
@@ -328,6 +345,7 @@ class MapViewState extends State<MapView> {
|
||||
mapController: _controller,
|
||||
uploadMode: appState.uploadMode,
|
||||
session: session,
|
||||
attribution: appState.selectedTileType?.attribution,
|
||||
),
|
||||
|
||||
// Network status indicator (top-left)
|
||||
|
||||
@@ -29,10 +29,16 @@ class NetworkStatusIndicator extends StatelessWidget {
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
message = 'No offline data';
|
||||
message = 'No tiles here';
|
||||
icon = Icons.cloud_off;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.success:
|
||||
message = 'Tiles loaded';
|
||||
icon = Icons.check_circle;
|
||||
color = Colors.green;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.issues:
|
||||
switch (networkStatus.currentIssueType) {
|
||||
|
||||
Reference in New Issue
Block a user