Compare commits

..

13 Commits

Author SHA1 Message Date
stopflock
ebf7f93dd5 deflock-ify icons 2025-08-26 20:52:03 -05:00
stopflock
d56a6e8e7c more status indicator improvements 2025-08-26 19:37:27 -05:00
stopflock
84e057c986 fix camera caching and filtering from offline areas - in theory 2025-08-26 19:17:45 -05:00
stopflock
c1e25ec5b1 improve network status indicator 2025-08-26 18:35:52 -05:00
stopflock
a3edcfc2de finalize code paths for offline areas, caching, in light of multiple tile providers 2025-08-26 17:52:14 -05:00
stopflock
17c9ee0c5c idk but it's better - cache busting works. 2025-08-24 19:38:42 -05:00
stopflock
9e620ef9e4 Consolidate / dedupe some code 2025-08-24 17:46:58 -05:00
stopflock
bedfdcca6e fetch tiles from selected provider 2025-08-24 16:13:50 -05:00
stopflock
f1c73a5e55 settings area metadata, offline areas support for tile types 2025-08-24 15:31:02 -05:00
stopflock
4ee783793f genericize tiles_from submodule, simpletileservice. 2025-08-24 15:08:36 -05:00
stopflock
aada97295b move layer selection to map page, improve settings, dynamic attribution 2025-08-24 14:41:29 -05:00
stopflock
813f4f69ea cusstom providers settings 2025-08-24 14:18:04 -05:00
stopflock
2d615128aa generic providers 2025-08-24 14:08:15 -05:00
28 changed files with 1683 additions and 359 deletions

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'models/camera_profile.dart';
@@ -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();

View File

@@ -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

View File

@@ -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;
}
}
}

View File

@@ -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(

View File

@@ -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(() {});
},

View File

@@ -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'),
),
),
],
);
}

View 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();
}
}

View 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'),
),
],
),
);
}
}

View File

@@ -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();
}
}

View File

@@ -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(

View File

@@ -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();

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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',
);
}
}

View File

@@ -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',
);
}
}

View File

@@ -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();
}
}

View 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,
),
),
);
}
}

View File

@@ -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 {
),
],
),
),
),
);
}

View File

@@ -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(

View File

@@ -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),
)),

View File

@@ -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,
);
}

View 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,
),
),
);
}
}

View File

@@ -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),
),
),
),

View File

@@ -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 addmode 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)

View File

@@ -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) {