mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-13 17:23:04 +00:00
Compare commits
35 Commits
v0.8.3-bet
...
v0.8.10-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf7f93dd5 | ||
|
|
d56a6e8e7c | ||
|
|
84e057c986 | ||
|
|
c1e25ec5b1 | ||
|
|
a3edcfc2de | ||
|
|
17c9ee0c5c | ||
|
|
9e620ef9e4 | ||
|
|
bedfdcca6e | ||
|
|
f1c73a5e55 | ||
|
|
4ee783793f | ||
|
|
aada97295b | ||
|
|
813f4f69ea | ||
|
|
2d615128aa | ||
|
|
024d3f09c3 | ||
|
|
e65b9f58a6 | ||
|
|
7bd6f68a99 | ||
|
|
f11bd6e238 | ||
|
|
f45279ecfe | ||
|
|
d6625ccc23 | ||
|
|
722e640a72 | ||
|
|
a21e807d88 | ||
|
|
a2bc3309c0 | ||
|
|
f6adffc84e | ||
|
|
01f73322c7 | ||
|
|
257aefb2fc | ||
|
|
63ebc2b682 | ||
|
|
1f3849cd84 | ||
|
|
e35266c160 | ||
|
|
05de16b2e2 | ||
|
|
32507e1646 | ||
|
|
1272eb9409 | ||
|
|
4cc8929378 | ||
|
|
44707bf064 | ||
|
|
ff9a052d3f | ||
|
|
df5e26f78d |
@@ -1,327 +1,142 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models/camera_profile.dart';
|
||||
import 'models/pending_upload.dart';
|
||||
import 'models/osm_camera_node.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/uploader.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'services/camera_cache.dart';
|
||||
import 'widgets/tile_provider_with_cache.dart';
|
||||
import 'widgets/camera_provider_with_cache.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
|
||||
// ------------------ AddCameraSession ------------------
|
||||
class AddCameraSession {
|
||||
AddCameraSession({required this.profile, this.directionDegrees = 0});
|
||||
CameraProfile profile;
|
||||
double directionDegrees;
|
||||
LatLng? target;
|
||||
}
|
||||
import 'models/tile_provider.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'state/auth_state.dart';
|
||||
import 'state/profile_state.dart';
|
||||
import 'state/session_state.dart';
|
||||
import 'state/settings_state.dart';
|
||||
import 'state/upload_queue_state.dart';
|
||||
|
||||
// Re-export types
|
||||
export 'state/settings_state.dart' show UploadMode;
|
||||
export 'state/session_state.dart' show AddCameraSession;
|
||||
|
||||
// ------------------ AppState ------------------
|
||||
class AppState extends ChangeNotifier {
|
||||
static late AppState instance;
|
||||
|
||||
// State modules
|
||||
late final AuthState _authState;
|
||||
late final ProfileState _profileState;
|
||||
late final SessionState _sessionState;
|
||||
late final SettingsState _settingsState;
|
||||
late final UploadQueueState _uploadQueueState;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
AppState() {
|
||||
instance = this;
|
||||
_authState = AuthState();
|
||||
_profileState = ProfileState();
|
||||
_sessionState = SessionState();
|
||||
_settingsState = SettingsState();
|
||||
_uploadQueueState = UploadQueueState();
|
||||
|
||||
// Set up state change listeners
|
||||
_authState.addListener(_onStateChanged);
|
||||
_profileState.addListener(_onStateChanged);
|
||||
_sessionState.addListener(_onStateChanged);
|
||||
_settingsState.addListener(_onStateChanged);
|
||||
_uploadQueueState.addListener(_onStateChanged);
|
||||
|
||||
_init();
|
||||
}
|
||||
|
||||
// ------------------- Offline Mode -------------------
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
bool _offlineMode = false;
|
||||
bool get offlineMode => _offlineMode;
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
final wasOffline = _offlineMode;
|
||||
_offlineMode = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_offlineModePrefsKey, enabled);
|
||||
if (wasOffline && !enabled) {
|
||||
// Transitioning from offline to online: clear tile cache!
|
||||
TileProviderWithCache.clearCache();
|
||||
_startUploader(); // Resume upload queue processing as we leave offline mode
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final _auth = AuthService();
|
||||
String? _username;
|
||||
|
||||
bool _isInitialized = false;
|
||||
// Getters that delegate to individual state modules
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
// Auth state
|
||||
bool get isLoggedIn => _authState.isLoggedIn;
|
||||
String get username => _authState.username;
|
||||
|
||||
// Profile state
|
||||
List<CameraProfile> get profiles => _profileState.profiles;
|
||||
List<CameraProfile> get enabledProfiles => _profileState.enabledProfiles;
|
||||
bool isEnabled(CameraProfile p) => _profileState.isEnabled(p);
|
||||
|
||||
// Session state
|
||||
AddCameraSession? get session => _sessionState.session;
|
||||
|
||||
// Settings state
|
||||
bool get offlineMode => _settingsState.offlineMode;
|
||||
int get maxCameras => _settingsState.maxCameras;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
|
||||
// Tile provider state
|
||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||
TileType? get selectedTileType => _settingsState.selectedTileType;
|
||||
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
|
||||
|
||||
|
||||
final List<CameraProfile> _profiles = [];
|
||||
final Set<CameraProfile> _enabled = {};
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
static const String _maxCamerasPrefsKey = 'max_cameras';
|
||||
|
||||
// Upload queue state
|
||||
int get pendingCount => _uploadQueueState.pendingCount;
|
||||
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||
|
||||
// Maximum number of cameras fetched/drawn
|
||||
int _maxCameras = 250;
|
||||
int get maxCameras => _maxCameras;
|
||||
set maxCameras(int n) {
|
||||
if (n < 10) n = 10; // minimum
|
||||
_maxCameras = n;
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setInt(_maxCamerasPrefsKey, n);
|
||||
});
|
||||
void _onStateChanged() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Upload mode: production, sandbox, or simulate (in-memory, no uploads)
|
||||
UploadMode _uploadMode = UploadMode.simulate;
|
||||
static const String _uploadModePrefsKey = 'upload_mode';
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
_uploadMode = mode;
|
||||
// Update AuthService to match new mode
|
||||
_auth.setUploadMode(mode);
|
||||
// Refresh user display for active mode, validating token
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: Switching mode, token exists; validating...');
|
||||
final isValid = await validateToken();
|
||||
if (isValid) {
|
||||
print("AppState: Switching mode; fetching username for $mode...");
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Switched mode, now logged in as $_username");
|
||||
} else {
|
||||
print('AppState: Switched mode but failed to retrieve username');
|
||||
}
|
||||
} else {
|
||||
print('AppState: Switching mode, token invalid—auto-logout.');
|
||||
await logout(); // This clears _username also.
|
||||
}
|
||||
} else {
|
||||
_username = null;
|
||||
print("AppState: Mode change: not logged in in $mode");
|
||||
}
|
||||
} catch (e) {
|
||||
_username = null;
|
||||
print("AppState: Mode change user restoration error: $e");
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_uploadModePrefsKey, mode.index);
|
||||
print("AppState: Upload mode set to $mode");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// For legacy bool test mode
|
||||
static const String _legacyTestModePrefsKey = 'test_mode';
|
||||
|
||||
AddCameraSession? _session;
|
||||
AddCameraSession? get session => _session;
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
bool get isLoggedIn => _username != null;
|
||||
String get username => _username ?? '';
|
||||
|
||||
// ---------- Init ----------
|
||||
Future<void> _init() async {
|
||||
// Initialize profiles: built-in + custom
|
||||
_profiles.add(CameraProfile.alpr());
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Load enabled profile IDs and upload/test mode from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
// Restore enabled profiles by id
|
||||
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
|
||||
} else {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
// Upload mode loading (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||
if (idx >= 0 && idx < UploadMode.values.length) {
|
||||
_uploadMode = UploadMode.values[idx];
|
||||
}
|
||||
} else if (prefs.containsKey(_legacyTestModePrefsKey)) {
|
||||
// migrate legacy test_mode (true->simulate, false->prod)
|
||||
final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false;
|
||||
_uploadMode = legacy ? UploadMode.simulate : UploadMode.production;
|
||||
await prefs.remove(_legacyTestModePrefsKey);
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
// Max cameras
|
||||
if (prefs.containsKey(_maxCamerasPrefsKey)) {
|
||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||
}
|
||||
// Offline mode loading
|
||||
if (prefs.containsKey(_offlineModePrefsKey)) {
|
||||
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
|
||||
}
|
||||
// Ensure AuthService follows loaded mode
|
||||
_auth.setUploadMode(_uploadMode);
|
||||
print('AppState: AuthService mode now updated to $_uploadMode');
|
||||
|
||||
await _loadQueue();
|
||||
// Initialize all state modules
|
||||
await _settingsState.init();
|
||||
await _profileState.init();
|
||||
await _uploadQueueState.init();
|
||||
await _authState.init(_settingsState.uploadMode);
|
||||
|
||||
// Check if we're already logged in and get username
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: User appears to be logged in, fetching username...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Successfully retrieved username: $_username");
|
||||
} else {
|
||||
print('AppState: Failed to retrieve username despite being logged in');
|
||||
}
|
||||
} else {
|
||||
print('AppState: User is not logged in');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Error during auth initialization: $e");
|
||||
}
|
||||
// Initialize OfflineAreaService to ensure offline areas are loaded
|
||||
await OfflineAreaService().ensureInitialized();
|
||||
|
||||
// Start uploader if conditions are met
|
||||
_startUploader();
|
||||
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------- Auth ----------
|
||||
// ---------- Auth Methods ----------
|
||||
Future<void> login() async {
|
||||
try {
|
||||
print('AppState: Starting login process...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Login successful for user: $_username");
|
||||
} else {
|
||||
print('AppState: Login failed - no username returned');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.login();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
await _authState.logout();
|
||||
}
|
||||
|
||||
// Add method to refresh auth state
|
||||
Future<void> refreshAuthState() async {
|
||||
try {
|
||||
print('AppState: Refreshing auth state...');
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: Token exists, fetching username...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Auth refresh successful: $_username");
|
||||
} else {
|
||||
print('AppState: Auth refresh failed - no username');
|
||||
}
|
||||
} else {
|
||||
print('AppState: No valid token found');
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Auth refresh error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.refreshAuthState();
|
||||
}
|
||||
|
||||
// Force a completely fresh login (clears stored tokens)
|
||||
Future<void> forceLogin() async {
|
||||
try {
|
||||
print('AppState: Starting forced fresh login...');
|
||||
_username = await _auth.forceLogin();
|
||||
if (_username != null) {
|
||||
print("AppState: Forced login successful: $_username");
|
||||
} else {
|
||||
print('AppState: Forced login failed - no username returned');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Forced login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.forceLogin();
|
||||
}
|
||||
|
||||
// Validate current token/credentials
|
||||
Future<bool> validateToken() async {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AppState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
return await _authState.validateToken();
|
||||
}
|
||||
|
||||
// ---------- Profiles ----------
|
||||
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(CameraProfile p) => _enabled.contains(p);
|
||||
List<CameraProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
// ---------- Profile Methods ----------
|
||||
void toggleProfile(CameraProfile p, bool e) {
|
||||
if (e) {
|
||||
_enabled.add(p);
|
||||
} else {
|
||||
_enabled.remove(p);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
notifyListeners();
|
||||
_profileState.toggleProfile(p, e);
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(CameraProfile p) {
|
||||
final idx = _profiles.indexWhere((x) => x.id == p.id);
|
||||
if (idx >= 0) {
|
||||
_profiles[idx] = p;
|
||||
} else {
|
||||
_profiles.add(p);
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
_profileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
void deleteProfile(CameraProfile p) {
|
||||
if (p.builtin) return;
|
||||
_enabled.remove(p);
|
||||
_profiles.removeWhere((x) => x.id == p.id);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
// Save enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Add‑camera session ----------
|
||||
// ---------- Session Methods ----------
|
||||
void startAddSession() {
|
||||
_session = AddCameraSession(profile: enabledProfiles.first);
|
||||
notifyListeners();
|
||||
_sessionState.startAddSession(enabledProfiles);
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
@@ -329,169 +144,96 @@ class AppState extends ChangeNotifier {
|
||||
CameraProfile? profile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
if (_session == null) return;
|
||||
|
||||
bool dirty = false;
|
||||
if (directionDeg != null && directionDeg != _session!.directionDegrees) {
|
||||
_session!.directionDegrees = directionDeg;
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _session!.profile) {
|
||||
_session!.profile = profile;
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null) {
|
||||
_session!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners(); // <-- slider & map update
|
||||
_sessionState.updateSession(
|
||||
directionDeg: directionDeg,
|
||||
profile: profile,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
void cancelSession() {
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
_sessionState.cancelSession();
|
||||
}
|
||||
|
||||
void commitSession() {
|
||||
if (_session?.target == null) return;
|
||||
|
||||
// Create the pending upload
|
||||
final upload = PendingUpload(
|
||||
coord: _session!.target!,
|
||||
direction: _session!.directionDegrees,
|
||||
profile: _session!.profile,
|
||||
);
|
||||
|
||||
_queue.add(upload);
|
||||
_saveQueue();
|
||||
|
||||
// Add to camera cache immediately so it shows on the map
|
||||
// Create a temporary node with a negative ID (to distinguish from real OSM nodes)
|
||||
// Using timestamp as negative ID to ensure uniqueness
|
||||
final tempId = -DateTime.now().millisecondsSinceEpoch;
|
||||
final tags = Map<String, String>.from(upload.profile.tags);
|
||||
tags['direction'] = upload.direction.toStringAsFixed(0);
|
||||
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
||||
|
||||
final tempNode = OsmCameraNode(
|
||||
id: tempId,
|
||||
coord: upload.coord,
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
CameraCache.instance.addOrUpdate([tempNode]);
|
||||
// Notify camera provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
|
||||
_session = null;
|
||||
|
||||
// Restart uploader when new items are added
|
||||
_startUploader();
|
||||
|
||||
notifyListeners();
|
||||
final session = _sessionState.commitSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromSession(session);
|
||||
_startUploader();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
// ---------- Settings Methods ----------
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
await _settingsState.setOfflineMode(enabled);
|
||||
if (!enabled) {
|
||||
_startUploader(); // Resume upload queue processing as we leave offline mode
|
||||
} else {
|
||||
_uploadQueueState.stopUploader(); // Stop uploader in offline mode
|
||||
// Cancel any active area downloads
|
||||
await OfflineAreaService().cancelActiveDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
set maxCameras(int n) {
|
||||
_settingsState.maxCameras = n;
|
||||
}
|
||||
|
||||
// ---------- Uploader ----------
|
||||
void _startUploader() {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
// No uploads without auth or queue, or if offline mode is enabled.
|
||||
if (_queue.isEmpty || _offlineMode) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty || _offlineMode) {
|
||||
_uploadTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first queue item that is NOT in error state and act on that
|
||||
final item = _queue.where((pu) => !pu.error).cast<PendingUpload?>().firstOrNull;
|
||||
if (item == null) return;
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
final access = await _auth.getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
bool ok;
|
||||
if (_uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful upload without calling real API
|
||||
print("AppState: UploadMode.simulate - simulating upload for ${item.coord}");
|
||||
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
|
||||
ok = true;
|
||||
print('AppState: Simulated upload successful');
|
||||
} else {
|
||||
// Real upload -- pass uploadMode so uploader can switch between prod and sandbox
|
||||
final up = Uploader(access, () {
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}, uploadMode: _uploadMode);
|
||||
ok = await up.upload(item);
|
||||
}
|
||||
|
||||
if (ok && _uploadMode == UploadMode.simulate) {
|
||||
// Remove manually for simulate mode
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
if (!ok) {
|
||||
item.attempts++;
|
||||
if (item.attempts >= 3) {
|
||||
// Mark as error and stop the uploader. User can manually retry.
|
||||
item.error = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadTimer?.cancel();
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
});
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
await _settingsState.setUploadMode(mode);
|
||||
await _authState.onUploadModeChanged(mode);
|
||||
_startUploader(); // Restart uploader with new mode
|
||||
}
|
||||
|
||||
// ---------- Exposed getters ----------
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
|
||||
// ---------- Queue management ----------
|
||||
/// 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() {
|
||||
print("AppState: Clearing upload queue (${_queue.length} items)");
|
||||
_queue.clear();
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.clearQueue();
|
||||
}
|
||||
|
||||
void removeFromQueue(PendingUpload upload) {
|
||||
print("AppState: Removing upload from queue: ${upload.coord}");
|
||||
_queue.remove(upload);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.removeFromQueue(upload);
|
||||
}
|
||||
|
||||
// Retry a failed upload (clear error and attempts, then try uploading again)
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.retryUpload(upload);
|
||||
_startUploader(); // resume uploader if not busy
|
||||
}
|
||||
|
||||
// ---------- Private Methods ----------
|
||||
void _startUploader() {
|
||||
_uploadQueueState.startUploader(
|
||||
offlineMode: offlineMode,
|
||||
uploadMode: uploadMode,
|
||||
getAccessToken: _authState.getAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authState.removeListener(_onStateChanged);
|
||||
_profileState.removeListener(_onStateChanged);
|
||||
_sessionState.removeListener(_onStateChanged);
|
||||
_settingsState.removeListener(_onStateChanged);
|
||||
_uploadQueueState.removeListener(_onStateChanged);
|
||||
|
||||
_uploadQueueState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -41,3 +44,15 @@ const int kTileFetchJitter3Ms = 5000;
|
||||
|
||||
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
|
||||
// Download area limits and constants
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'app_state.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
|
||||
import 'widgets/tile_provider_with_cache.dart';
|
||||
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
214
lib/models/tile_provider.dart
Normal file
214
lib/models/tile_provider.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// A specific tile type within a provider
|
||||
class TileType {
|
||||
final String id;
|
||||
final String name;
|
||||
final String urlTemplate;
|
||||
final String attribution;
|
||||
final Uint8List? previewTile; // Single tile image data for preview
|
||||
|
||||
const TileType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
this.previewTile,
|
||||
});
|
||||
|
||||
/// 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 url;
|
||||
}
|
||||
|
||||
/// 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
import '../app_state.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
import '../widgets/tile_provider_with_cache.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
|
||||
import '../widgets/add_camera_sheet.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
import '../widgets/download_area_dialog.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -21,6 +19,7 @@ class HomeScreen extends StatefulWidget {
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
|
||||
final MapController _mapController = MapController();
|
||||
bool _followMe = true;
|
||||
|
||||
@@ -43,7 +42,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<TileProviderWithCache>(create: (_) => TileProviderWithCache()),
|
||||
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
|
||||
],
|
||||
child: Scaffold(
|
||||
@@ -54,7 +52,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
IconButton(
|
||||
tooltip: _followMe ? 'Disable follow‑me' : 'Enable follow‑me',
|
||||
icon: Icon(_followMe ? Icons.gps_fixed : Icons.gps_off),
|
||||
onPressed: () => setState(() => _followMe = !_followMe),
|
||||
onPressed: () {
|
||||
setState(() => _followMe = !_followMe);
|
||||
// If enabling follow-me, retry location init in case permission was granted
|
||||
if (_followMe) {
|
||||
_mapViewKey.currentState?.retryLocationInit();
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
@@ -65,40 +69,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
body: Stack(
|
||||
children: [
|
||||
MapView(
|
||||
key: _mapViewKey,
|
||||
controller: _mapController,
|
||||
followMe: _followMe,
|
||||
onUserGesture: () {
|
||||
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(
|
||||
@@ -155,184 +132,3 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Download area dialog ---
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
const DownloadAreaDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
|
||||
}
|
||||
|
||||
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
double _zoom = 15;
|
||||
int? _minZoom;
|
||||
int? _tileCount;
|
||||
double? _mbEstimate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
}
|
||||
|
||||
void _recomputeEstimates() {
|
||||
var bounds = widget.controller.camera.visibleBounds;
|
||||
// If the visible area is nearly zero, nudge the bounds for estimation
|
||||
const double epsilon = 0.0002;
|
||||
final latSpan = (bounds.north - bounds.south).abs();
|
||||
final lngSpan = (bounds.east - bounds.west).abs();
|
||||
if (latSpan < epsilon && lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
} else if (latSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
|
||||
);
|
||||
} else if (lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
}
|
||||
final minZoom = kWorldMaxZoom + 1; // Use world max zoom + 1 for seamless zoom experience
|
||||
final maxZoom = _zoom.toInt();
|
||||
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
|
||||
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
|
||||
setState(() {
|
||||
_minZoom = minZoom;
|
||||
_tileCount = nTiles;
|
||||
_mbEstimate = totalMb;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
double sliderMin;
|
||||
double sliderMax;
|
||||
int sliderDivisions;
|
||||
double sliderValue;
|
||||
// Generate slider min/max/divisions with clarity
|
||||
if (_minZoom != null) {
|
||||
sliderMin = _minZoom!.toDouble();
|
||||
} else {
|
||||
sliderMin = 12.0; //fallback
|
||||
}
|
||||
if (_minZoom != null) {
|
||||
final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan;
|
||||
sliderMax = candidateMax > 19 ? 19.0 : candidateMax.toDouble();
|
||||
} else {
|
||||
sliderMax = 19.0; //fallback
|
||||
}
|
||||
if (_minZoom != null) {
|
||||
final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan;
|
||||
int diff = (candidateMax > 19 ? 19 : candidateMax) - _minZoom!;
|
||||
sliderDivisions = diff > 0 ? diff : 1;
|
||||
} else {
|
||||
sliderDivisions = 7; //fallback
|
||||
}
|
||||
sliderValue = _zoom.clamp(sliderMin, sliderMax);
|
||||
// We recompute estimates when the zoom slider changes
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.download_for_offline),
|
||||
SizedBox(width: 10),
|
||||
Text("Download Map Area"),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Max zoom level'),
|
||||
Text('Z${_zoom.toStringAsFixed(0)}'),
|
||||
],
|
||||
),
|
||||
|
||||
Slider(
|
||||
min: sliderMin,
|
||||
max: sliderMax,
|
||||
divisions: sliderDivisions,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: sliderValue,
|
||||
onChanged: (v) {
|
||||
setState(() => _zoom = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Storage estimate:'),
|
||||
Text(_mbEstimate == null
|
||||
? '…'
|
||||
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB'),
|
||||
],
|
||||
),
|
||||
if (_minZoom != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Min zoom:'),
|
||||
Text('Z$_minZoom'),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
id: id,
|
||||
bounds: bounds,
|
||||
minZoom: _minZoom ?? 12,
|
||||
maxZoom: maxZoom,
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download started!'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start download: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'settings_screen_sections/offline_areas_section.dart';
|
||||
import 'settings_screen_sections/offline_mode_section.dart';
|
||||
import 'settings_screen_sections/about_section.dart';
|
||||
import 'settings_screen_sections/max_cameras_section.dart';
|
||||
import 'settings_screen_sections/tile_provider_section.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -28,6 +29,8 @@ class SettingsScreen extends StatelessWidget {
|
||||
Divider(),
|
||||
MaxCamerasSection(),
|
||||
Divider(),
|
||||
TileProviderSection(),
|
||||
Divider(),
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
|
||||
@@ -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(() {});
|
||||
},
|
||||
|
||||
@@ -1,10 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../services/offline_area_service.dart';
|
||||
|
||||
class OfflineModeSection extends StatelessWidget {
|
||||
const OfflineModeSection({super.key});
|
||||
|
||||
Future<void> _handleOfflineModeChange(BuildContext context, AppState appState, bool value) async {
|
||||
// If enabling offline mode, check for active downloads
|
||||
if (value && !appState.offlineMode) {
|
||||
final offlineService = OfflineAreaService();
|
||||
if (offlineService.hasActiveDownloads) {
|
||||
// Show confirmation dialog
|
||||
final shouldProceed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.warning, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Active Downloads'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Enabling offline mode will cancel any active area downloads. Do you want to continue?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Enable Offline Mode'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldProceed != true) {
|
||||
return; // User cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with the change
|
||||
await appState.setOfflineMode(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
@@ -14,7 +61,7 @@ class OfflineModeSection extends StatelessWidget {
|
||||
subtitle: const Text('Disable all network requests except for local/offline areas.'),
|
||||
trailing: Switch(
|
||||
value: appState.offlineMode,
|
||||
onChanged: (value) async => await appState.setOfflineMode(value),
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Map Tiles',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,8 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Handles PKCE OAuth login with OpenStreetMap.
|
||||
import '../app_state.dart';
|
||||
|
||||
import '../keys.dart';
|
||||
import '../app_state.dart' show UploadMode;
|
||||
|
||||
class AuthService {
|
||||
// Both client IDs from keys.dart
|
||||
@@ -56,7 +55,6 @@ class AuthService {
|
||||
enablePKCE: true,
|
||||
// tokenStorageKey: _tokenKey, // not supported by this package version
|
||||
);
|
||||
print('AuthService: Initialized for $mode with $authBase, clientId $clientId [manual token storage as needed]');
|
||||
}
|
||||
|
||||
Future<bool> isLoggedIn() async {
|
||||
@@ -81,17 +79,14 @@ class AuthService {
|
||||
|
||||
Future<String?> login() async {
|
||||
if (_mode == UploadMode.simulate) {
|
||||
print('AuthService: Simulate login (no OAuth)');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_displayName = 'Demo User';
|
||||
await prefs.setBool('sim_user_logged_in', true);
|
||||
return _displayName;
|
||||
}
|
||||
try {
|
||||
print('AuthService: Starting OAuth login...');
|
||||
final token = await _helper.getToken();
|
||||
if (token?.accessToken == null) {
|
||||
print('AuthService: OAuth error - token null or missing accessToken');
|
||||
log('OAuth error: token null or missing accessToken');
|
||||
return null;
|
||||
}
|
||||
@@ -102,13 +97,7 @@ class AuthService {
|
||||
final tokenJson = jsonEncode(tokenMap);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, tokenJson); // Save token for current mode
|
||||
print('AuthService: Got access token, fetching username...');
|
||||
_displayName = await _fetchUsername(token!.accessToken!);
|
||||
if (_displayName != null) {
|
||||
print('AuthService: Successfully fetched username: $_displayName');
|
||||
} else {
|
||||
print('AuthService: Failed to fetch username from OSM API');
|
||||
}
|
||||
return _displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: OAuth login failed: $e');
|
||||
@@ -132,7 +121,6 @@ class AuthService {
|
||||
|
||||
// Force a fresh login by clearing stored tokens
|
||||
Future<String?> forceLogin() async {
|
||||
print('AuthService: Forcing fresh login by clearing stored tokens...');
|
||||
await _helper.removeAllTokens();
|
||||
_displayName = null;
|
||||
return await login();
|
||||
@@ -163,37 +151,17 @@ class AuthService {
|
||||
|
||||
Future<String?> _fetchUsername(String accessToken) async {
|
||||
try {
|
||||
print('AuthService: Fetching username from OSM API ($_apiHost) ...');
|
||||
print('AuthService: Access token (first 20 chars): ${accessToken.substring(0, math.min(20, accessToken.length))}...');
|
||||
|
||||
final resp = await http.get(
|
||||
Uri.parse('$_apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
print('AuthService: OSM API response status: ${resp.statusCode}');
|
||||
print('AuthService: Response headers: ${resp.headers}');
|
||||
|
||||
if (resp.statusCode != 200) {
|
||||
print('AuthService: fetchUsername failed with ${resp.statusCode}: ${resp.body}');
|
||||
log('fetchUsername response ${resp.statusCode}: ${resp.body}');
|
||||
|
||||
// Try to get more info about the token by checking permissions endpoint
|
||||
try {
|
||||
print('AuthService: Checking token permissions...');
|
||||
final permResp = await http.get(
|
||||
Uri.parse('$_apiHost/api/0.6/permissions.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
print('AuthService: Permissions response ${permResp.statusCode}: ${permResp.body}');
|
||||
} catch (e) {
|
||||
print('AuthService: Error checking permissions: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
final userData = jsonDecode(resp.body);
|
||||
final displayName = userData['user']?['display_name'];
|
||||
print('AuthService: Extracted display name: $displayName');
|
||||
return displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: Error fetching username: $e');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -39,12 +40,10 @@ class MapDataProvider {
|
||||
MapSource source = MapSource.auto,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
print('[MapDataProvider] getCameras called, source=$source, offlineMode=$offline');
|
||||
|
||||
// Explicit remote request: error if offline, else always remote
|
||||
if (source == MapSource.remote) {
|
||||
if (offline) {
|
||||
print('[MapDataProvider] Overpass request BLOCKED because we are in offlineMode');
|
||||
throw OfflineModeException("Cannot fetch remote cameras in offline mode.");
|
||||
}
|
||||
return camerasFromOverpass(
|
||||
@@ -80,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,
|
||||
@@ -121,15 +120,13 @@ class MapDataProvider {
|
||||
MapSource source = MapSource.auto,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
print('[MapDataProvider] getTile called for $z/$x/$y, source=$source, offlineMode=$offline');
|
||||
|
||||
// Explicitly remote
|
||||
if (source == MapSource.remote) {
|
||||
if (offline) {
|
||||
print('[MapDataProvider] BLOCKED by offlineMode for remote tile fetch');
|
||||
throw OfflineModeException("Cannot fetch remote tiles in offline mode.");
|
||||
}
|
||||
return fetchOSMTile(z: z, x: x, y: y);
|
||||
return _fetchRemoteTileFromCurrentProvider(z, x, y);
|
||||
}
|
||||
|
||||
// Explicitly local
|
||||
@@ -142,10 +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() {
|
||||
clearRemoteTileQueue();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
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';
|
||||
|
||||
import '../../models/camera_profile.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Fetches cameras from the Overpass OSM API for the given bounds and profiles.
|
||||
/// If fetchAllPages is true, returns all possible cameras using multiple API calls (paging with pageSize).
|
||||
@@ -42,14 +44,20 @@ 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(
|
||||
id: e['id'],
|
||||
@@ -59,6 +67,14 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
print('[camerasFromOverpass] Overpass exception: $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.reportOverpassIssue();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,25 @@ 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 areas = OfflineAreaService().offlineAreas;
|
||||
final appState = AppState.instance;
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
final areas = offlineService.offlineAreas;
|
||||
final List<_AreaTileMatch> candidates = [];
|
||||
|
||||
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);
|
||||
@@ -26,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();
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
|
||||
|
||||
/// Fetches a tile from OSM, with in-memory retries/backoff, and global concurrency limit.
|
||||
/// Returns tile image bytes, or throws on persistent failure.
|
||||
Future<List<int>> fetchOSMTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
}) async {
|
||||
final url = 'https://tile.openstreetmap.org/$z/$x/$y.png';
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final delays = [
|
||||
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
|
||||
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
|
||||
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
|
||||
];
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire();
|
||||
try {
|
||||
print('[fetchOSMTile] FETCH $z/$x/$y');
|
||||
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');
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[fetchOSMTile] Exception $z/$x/$y: $e');
|
||||
if (attempt >= maxAttempts) {
|
||||
print("[fetchOSMTile] Failed for $z/$x/$y 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.");
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
_tileFetchSemaphore.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
int _current = 0;
|
||||
final List<VoidCallback> _queue = [];
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
final c = Completer<void>();
|
||||
_queue.add(() => c.complete());
|
||||
await c.future;
|
||||
}
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_queue.isNotEmpty) {
|
||||
final callback = _queue.removeAt(0);
|
||||
callback();
|
||||
} else {
|
||||
_current--;
|
||||
}
|
||||
}
|
||||
}
|
||||
133
lib/services/map_data_submodules/tiles_from_remote.dart
Normal file
133
lib/services/map_data_submodules/tiles_from_remote.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
|
||||
|
||||
/// Clear queued tile requests when map view changes significantly
|
||||
void clearRemoteTileQueue() {
|
||||
final clearedCount = _tileFetchSemaphore.clearQueue();
|
||||
// Only log if we actually cleared something significant
|
||||
if (clearedCount > 5) {
|
||||
debugPrint('[RemoteTiles] Cleared $clearedCount queued tile requests');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// 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>> fetchRemoteTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
const int maxAttempts = kTileFetchMaxAttempts;
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final delays = [
|
||||
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
|
||||
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
|
||||
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
|
||||
];
|
||||
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire();
|
||||
try {
|
||||
// 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));
|
||||
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
// Success - no logging for normal operation
|
||||
NetworkStatus.instance.reportOsmTileSuccess(); // Generic tile server reporting
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
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) {
|
||||
// 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(); // Generic tile server reporting
|
||||
}
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
debugPrint("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e");
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final delay = delays[attempt - 1].clamp(0, 60000);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
int _current = 0;
|
||||
final List<VoidCallback> _queue = [];
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
final c = Completer<void>();
|
||||
_queue.add(() => c.complete());
|
||||
await c.future;
|
||||
}
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_queue.isNotEmpty) {
|
||||
final callback = _queue.removeAt(0);
|
||||
callback();
|
||||
} else {
|
||||
_current--;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all queued requests (call when view changes significantly)
|
||||
int clearQueue() {
|
||||
final clearedCount = _queue.length;
|
||||
_queue.clear();
|
||||
return clearedCount;
|
||||
}
|
||||
}
|
||||
217
lib/services/network_status.dart
Normal file
217
lib/services/network_status.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
enum NetworkIssueType { osmTiles, overpassApi, both }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
|
||||
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
static final NetworkStatus instance = NetworkStatus._();
|
||||
NetworkStatus._();
|
||||
|
||||
bool _osmTilesHaveIssues = false;
|
||||
bool _overpassHasIssues = false;
|
||||
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;
|
||||
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
|
||||
bool get overpassHasIssues => _overpassHasIssues;
|
||||
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;
|
||||
}
|
||||
|
||||
NetworkIssueType? get currentIssueType {
|
||||
if (_osmTilesHaveIssues && _overpassHasIssues) return NetworkIssueType.both;
|
||||
if (_osmTilesHaveIssues) return NetworkIssueType.osmTiles;
|
||||
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Report tile server issues (for any provider)
|
||||
void reportOsmTileIssue() {
|
||||
if (!_osmTilesHaveIssues) {
|
||||
_osmTilesHaveIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Tile server issues detected');
|
||||
}
|
||||
|
||||
// Reset recovery timer - if we keep getting errors, keep showing indicator
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_osmRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_osmTilesHaveIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Tile server issues cleared');
|
||||
});
|
||||
}
|
||||
|
||||
/// Report Overpass API issues
|
||||
void reportOverpassIssue() {
|
||||
if (!_overpassHasIssues) {
|
||||
_overpassHasIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues detected');
|
||||
}
|
||||
|
||||
// Reset recovery timer
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_overpassHasIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues cleared');
|
||||
});
|
||||
}
|
||||
|
||||
/// Report successful operations to potentially clear issues faster
|
||||
void reportOsmTileSuccess() {
|
||||
// Clear issues immediately on success (they were likely temporary)
|
||||
if (_osmTilesHaveIssues) {
|
||||
// Quietly clear - don't log routine success
|
||||
_osmTilesHaveIssues = false;
|
||||
_osmRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void reportOverpassSuccess() {
|
||||
if (_overpassHasIssues) {
|
||||
// Quietly clear - don't log routine success
|
||||
_overpassHasIssues = false;
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set waiting status (show when loading tiles/cameras)
|
||||
void setWaiting() {
|
||||
// Clear any previous timeout/no-data state when starting new wait
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_noDataResetTimer?.cancel();
|
||||
|
||||
if (!_isWaitingForData) {
|
||||
_isWaitingForData = true;
|
||||
notifyListeners();
|
||||
// Don't log routine waiting - only log if we stay waiting too long
|
||||
}
|
||||
|
||||
// Set timeout for genuine network issues (not 404s)
|
||||
_waitingTimer?.cancel();
|
||||
_waitingTimer = Timer(const Duration(seconds: 8), () {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = true;
|
||||
debugPrint('[NetworkStatus] Request timed out - likely network issues');
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/// 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 || _hasSuccess) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Report that a tile was not available offline
|
||||
void reportOfflineMiss() {
|
||||
_recentOfflineMisses++;
|
||||
debugPrint('[NetworkStatus] Offline miss #$_recentOfflineMisses');
|
||||
|
||||
// If we get several misses in a short time, show "no data" status
|
||||
if (_recentOfflineMisses >= 3 && !_hasNoData) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] No offline data available for this area');
|
||||
}
|
||||
|
||||
// Reset the miss counter after some time
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 5), () {
|
||||
_recentOfflineMisses = 0;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_osmRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,63 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'offline_areas/offline_area_models.dart';
|
||||
import 'offline_areas/offline_tile_utils.dart';
|
||||
import 'offline_areas/offline_area_service_tile_fetch.dart'; // Only used for file IO during area downloads.
|
||||
import 'offline_areas/offline_area_downloader.dart';
|
||||
import 'offline_areas/world_area_manager.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'map_data_submodules/cameras_from_overpass.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
|
||||
class OfflineAreaService {
|
||||
static final OfflineAreaService _instance = OfflineAreaService._();
|
||||
factory OfflineAreaService() => _instance;
|
||||
OfflineAreaService._() {
|
||||
_loadAreasFromDisk().then((_) => _ensureAndAutoDownloadWorldArea());
|
||||
}
|
||||
|
||||
bool _initialized = false;
|
||||
Future<void>? _initializationFuture;
|
||||
|
||||
OfflineAreaService._();
|
||||
|
||||
final List<OfflineArea> _areas = [];
|
||||
List<OfflineArea> get offlineAreas => List.unmodifiable(_areas);
|
||||
|
||||
/// Check if any areas are currently downloading
|
||||
bool get hasActiveDownloads => _areas.any((area) => area.status == OfflineAreaStatus.downloading);
|
||||
|
||||
/// Cancel all active downloads (used when enabling offline mode)
|
||||
Future<void> cancelActiveDownloads() async {
|
||||
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();
|
||||
for (final area in activeAreas) {
|
||||
area.status = OfflineAreaStatus.cancelled;
|
||||
if (!area.isPermanent) {
|
||||
// Clean up non-permanent areas
|
||||
final dir = Directory(area.directory);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
_areas.remove(area);
|
||||
}
|
||||
}
|
||||
await saveAreasToDisk();
|
||||
debugPrint('OfflineAreaService: Cancelled ${activeAreas.length} active downloads due to offline mode');
|
||||
}
|
||||
|
||||
/// Ensure the service is initialized (areas loaded from disk)
|
||||
Future<void> ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
|
||||
_initializationFuture ??= _initialize();
|
||||
await _initializationFuture;
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
await _loadAreasFromDisk();
|
||||
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
|
||||
await saveAreasToDisk(); // Save any world area updates
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<Directory> getOfflineAreaDir() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
@@ -56,7 +96,20 @@ class OfflineAreaService {
|
||||
Future<void> saveAreasToDisk() async {
|
||||
try {
|
||||
final file = await _getMetadataPath();
|
||||
final content = jsonEncode(_areas.map((a) => a.toJson()).toList());
|
||||
final offlineDir = await getOfflineAreaDir();
|
||||
|
||||
// Convert areas to JSON with relative paths for portability
|
||||
final areaJsonList = _areas.map((area) {
|
||||
final json = area.toJson();
|
||||
// Convert absolute path to relative path for storage
|
||||
if (json['directory'].toString().startsWith(offlineDir.path)) {
|
||||
final relativePath = json['directory'].toString().replaceFirst('${offlineDir.path}/', '');
|
||||
json['directory'] = relativePath;
|
||||
}
|
||||
return json;
|
||||
}).toList();
|
||||
|
||||
final content = jsonEncode(areaJsonList);
|
||||
await file.writeAsString(content);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to save offline areas: $e');
|
||||
@@ -77,11 +130,39 @@ class OfflineAreaService {
|
||||
return;
|
||||
}
|
||||
_areas.clear();
|
||||
|
||||
for (final areaJson in data) {
|
||||
// Migrate stored directory paths to be relative for portability
|
||||
String storedDir = areaJson['directory'];
|
||||
String relativePath = storedDir;
|
||||
|
||||
// If it's an absolute path, extract just the folder name
|
||||
if (storedDir.startsWith('/')) {
|
||||
if (storedDir.contains('/offline_areas/')) {
|
||||
final parts = storedDir.split('/offline_areas/');
|
||||
if (parts.length == 2) {
|
||||
relativePath = parts[1]; // Just the folder name (e.g., "world" or "2025-08-19...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always construct absolute path at runtime
|
||||
final offlineDir = await getOfflineAreaDir();
|
||||
final fullPath = '${offlineDir.path}/$relativePath';
|
||||
|
||||
// Update the JSON to use the full path for this session
|
||||
areaJson['directory'] = fullPath;
|
||||
|
||||
final area = OfflineArea.fromJson(areaJson);
|
||||
|
||||
if (!Directory(area.directory).existsSync()) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
} else {
|
||||
// Reset error status if directory now exists (fixes areas that were previously broken due to path issues)
|
||||
if (area.status == OfflineAreaStatus.error) {
|
||||
area.status = OfflineAreaStatus.complete;
|
||||
}
|
||||
|
||||
getAreaSizeBytes(area);
|
||||
}
|
||||
_areas.add(area);
|
||||
@@ -91,77 +172,7 @@ class OfflineAreaService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureAndAutoDownloadWorldArea() async {
|
||||
final dir = await getOfflineAreaDir();
|
||||
final worldDir = "${dir.path}/world";
|
||||
final LatLngBounds worldBounds = globalWorldBounds();
|
||||
OfflineArea? world;
|
||||
for (final a in _areas) {
|
||||
if (a.isPermanent) { world = a; break; }
|
||||
}
|
||||
final Set<List<int>> expectedTiles = computeTileList(worldBounds, kWorldMinZoom, kWorldMaxZoom);
|
||||
if (world != null) {
|
||||
int filesFound = 0;
|
||||
List<List<int>> missingTiles = [];
|
||||
for (final tile in expectedTiles) {
|
||||
final f = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (f.existsSync()) {
|
||||
filesFound++;
|
||||
} else if (missingTiles.length < 10) {
|
||||
missingTiles.add(tile);
|
||||
}
|
||||
}
|
||||
if (filesFound != expectedTiles.length) {
|
||||
debugPrint('World area: missing ${expectedTiles.length - filesFound} tiles. First few: $missingTiles');
|
||||
} else {
|
||||
debugPrint('World area: all tiles accounted for.');
|
||||
}
|
||||
world.tilesTotal = expectedTiles.length;
|
||||
world.tilesDownloaded = filesFound;
|
||||
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
|
||||
if (filesFound == world.tilesTotal) {
|
||||
world.status = OfflineAreaStatus.complete;
|
||||
await saveAreasToDisk();
|
||||
return;
|
||||
} else {
|
||||
world.status = OfflineAreaStatus.downloading;
|
||||
await saveAreasToDisk();
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If not present, create and start download
|
||||
world = OfflineArea(
|
||||
id: 'permanent_world',
|
||||
name: 'World (required)',
|
||||
bounds: worldBounds,
|
||||
minZoom: kWorldMinZoom,
|
||||
maxZoom: kWorldMaxZoom,
|
||||
directory: worldDir,
|
||||
status: OfflineAreaStatus.downloading,
|
||||
progress: 0.0,
|
||||
isPermanent: true,
|
||||
tilesTotal: expectedTiles.length,
|
||||
tilesDownloaded: 0,
|
||||
);
|
||||
_areas.insert(0, world);
|
||||
await saveAreasToDisk();
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> downloadArea({
|
||||
required String id,
|
||||
@@ -172,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) {
|
||||
@@ -192,78 +207,35 @@ class OfflineAreaService {
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
isPermanent: area?.isPermanent ?? false,
|
||||
tileProviderId: tileProviderId,
|
||||
tileProviderName: tileProviderName,
|
||||
tileTypeId: tileTypeId,
|
||||
tileTypeName: tileTypeName,
|
||||
);
|
||||
_areas.add(area);
|
||||
await saveAreasToDisk();
|
||||
|
||||
try {
|
||||
Set<List<int>> allTiles;
|
||||
if (area.isPermanent) {
|
||||
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
|
||||
} else {
|
||||
allTiles = computeTileList(bounds, minZoom, maxZoom);
|
||||
}
|
||||
area.tilesTotal = allTiles.length;
|
||||
const int maxPasses = 3;
|
||||
int pass = 0;
|
||||
Set<List<int>> allTilesSet = allTiles.toSet();
|
||||
Set<List<int>> tilesToFetch = allTilesSet;
|
||||
bool success = false;
|
||||
int totalDone = 0;
|
||||
while (pass < maxPasses && tilesToFetch.isNotEmpty) {
|
||||
pass++;
|
||||
int doneThisPass = 0;
|
||||
debugPrint('DownloadArea: pass #$pass for area $id. Need ${tilesToFetch.length} tiles.');
|
||||
for (final tile in tilesToFetch) {
|
||||
if (area.status == OfflineAreaStatus.cancelled) break;
|
||||
try {
|
||||
final bytes = await MapDataProvider().getTile(
|
||||
z: tile[0], x: tile[1], y: tile[2], source: MapSource.remote);
|
||||
if (bytes.isNotEmpty) {
|
||||
await saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
|
||||
}
|
||||
totalDone++;
|
||||
doneThisPass++;
|
||||
area.tilesDownloaded = totalDone;
|
||||
area.progress = area.tilesTotal == 0 ? 0.0 : ((area.tilesDownloaded) / area.tilesTotal);
|
||||
} catch (e) {
|
||||
debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e");
|
||||
}
|
||||
if (onProgress != null) onProgress(area.progress);
|
||||
}
|
||||
await getAreaSizeBytes(area);
|
||||
await saveAreasToDisk();
|
||||
Set<List<int>> missingTiles = {};
|
||||
for (final tile in allTilesSet) {
|
||||
final f = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (!f.existsSync()) missingTiles.add(tile);
|
||||
}
|
||||
if (missingTiles.isEmpty) {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
tilesToFetch = missingTiles;
|
||||
}
|
||||
final success = await OfflineAreaDownloader.downloadArea(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
|
||||
if (!area.isPermanent) {
|
||||
final cameras = await MapDataProvider().getAllCamerasForDownload(
|
||||
bounds: bounds,
|
||||
profiles: AppState.instance.enabledProfiles,
|
||||
);
|
||||
area.cameras = cameras;
|
||||
await saveCameras(cameras, directory);
|
||||
} else {
|
||||
area.cameras = [];
|
||||
}
|
||||
await getAreaSizeBytes(area);
|
||||
|
||||
if (success) {
|
||||
area.status = OfflineAreaStatus.complete;
|
||||
area.progress = 1.0;
|
||||
debugPrint('Area $id: all tiles accounted for and area marked complete.');
|
||||
debugPrint('Area $id: download completed successfully.');
|
||||
} else {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
debugPrint('Area $id: MISSING tiles after $maxPasses passes. First 10: ${tilesToFetch.toList().take(10)}');
|
||||
debugPrint('Area $id: download failed after maximum retry attempts.');
|
||||
if (!area.isPermanent) {
|
||||
final dirObj = Directory(area.directory);
|
||||
if (await dirObj.exists()) {
|
||||
@@ -273,11 +245,11 @@ class OfflineAreaService {
|
||||
}
|
||||
}
|
||||
await saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
onComplete?.call(area.status);
|
||||
} catch (e) {
|
||||
area.status = OfflineAreaStatus.error;
|
||||
await saveAreasToDisk();
|
||||
if (onComplete != null) onComplete(area.status);
|
||||
onComplete?.call(area.status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +263,7 @@ class OfflineAreaService {
|
||||
_areas.remove(area);
|
||||
await saveAreasToDisk();
|
||||
if (area.isPermanent) {
|
||||
_ensureAndAutoDownloadWorldArea();
|
||||
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,4 +276,6 @@ class OfflineAreaService {
|
||||
_areas.remove(area);
|
||||
await saveAreasToDisk();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
196
lib/services/offline_areas/offline_area_downloader.dart
Normal file
196
lib/services/offline_areas/offline_area_downloader.dart
Normal file
@@ -0,0 +1,196 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../map_data_provider.dart';
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
/// Handles the actual downloading process for offline areas
|
||||
class OfflineAreaDownloader {
|
||||
static const int _maxRetryPasses = 3;
|
||||
|
||||
/// Download tiles and cameras for an offline area
|
||||
static Future<bool> downloadArea({
|
||||
required OfflineArea area,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
void Function(double progress)? onProgress,
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
Set<List<int>> allTiles;
|
||||
if (area.isPermanent) {
|
||||
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
|
||||
} else {
|
||||
allTiles = computeTileList(bounds, minZoom, maxZoom);
|
||||
}
|
||||
area.tilesTotal = allTiles.length;
|
||||
|
||||
// Download tiles with retry logic
|
||||
final success = await _downloadTilesWithRetry(
|
||||
area: area,
|
||||
allTiles: allTiles,
|
||||
directory: directory,
|
||||
onProgress: onProgress,
|
||||
saveAreasToDisk: saveAreasToDisk,
|
||||
getAreaSizeBytes: getAreaSizeBytes,
|
||||
);
|
||||
|
||||
// Download cameras for non-permanent areas
|
||||
if (!area.isPermanent) {
|
||||
await _downloadCameras(
|
||||
area: area,
|
||||
bounds: bounds,
|
||||
minZoom: minZoom,
|
||||
directory: directory,
|
||||
);
|
||||
} else {
|
||||
area.cameras = [];
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Download tiles with retry logic
|
||||
static Future<bool> _downloadTilesWithRetry({
|
||||
required OfflineArea area,
|
||||
required Set<List<int>> allTiles,
|
||||
required String directory,
|
||||
void Function(double progress)? onProgress,
|
||||
required Future<void> Function() saveAreasToDisk,
|
||||
required Future<void> Function(OfflineArea) getAreaSizeBytes,
|
||||
}) async {
|
||||
int pass = 0;
|
||||
Set<List<int>> tilesToFetch = allTiles;
|
||||
int totalDone = 0;
|
||||
|
||||
while (pass < _maxRetryPasses && tilesToFetch.isNotEmpty) {
|
||||
pass++;
|
||||
debugPrint('DownloadArea: pass #$pass for area ${area.id}. Need ${tilesToFetch.length} tiles.');
|
||||
|
||||
for (final tile in tilesToFetch) {
|
||||
if (area.status == OfflineAreaStatus.cancelled) break;
|
||||
|
||||
if (await _downloadSingleTile(tile, directory, area)) {
|
||||
totalDone++;
|
||||
area.tilesDownloaded = totalDone;
|
||||
area.progress = area.tilesTotal == 0 ? 0.0 : (totalDone / area.tilesTotal);
|
||||
onProgress?.call(area.progress);
|
||||
}
|
||||
}
|
||||
|
||||
await getAreaSizeBytes(area);
|
||||
await saveAreasToDisk();
|
||||
|
||||
// Check for missing tiles
|
||||
tilesToFetch = _findMissingTiles(allTiles, directory);
|
||||
if (tilesToFetch.isEmpty) {
|
||||
return true; // Success!
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Failed after max retries
|
||||
}
|
||||
|
||||
/// 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, // Force remote fetch for downloads
|
||||
);
|
||||
if (bytes.isNotEmpty) {
|
||||
await OfflineAreaDownloader.saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Find tiles that are missing from disk
|
||||
static Set<List<int>> _findMissingTiles(Set<List<int>> allTiles, String directory) {
|
||||
final missingTiles = <List<int>>{};
|
||||
for (final tile in allTiles) {
|
||||
final file = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (!file.existsSync()) {
|
||||
missingTiles.add(tile);
|
||||
}
|
||||
}
|
||||
return missingTiles;
|
||||
}
|
||||
|
||||
/// Download cameras for the area with expanded bounds
|
||||
static Future<void> _downloadCameras({
|
||||
required OfflineArea area,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required String directory,
|
||||
}) async {
|
||||
// Calculate expanded camera bounds that cover the entire tile area at minimum zoom
|
||||
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
|
||||
final cameras = await MapDataProvider().getAllCamerasForDownload(
|
||||
bounds: cameraBounds,
|
||||
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 (all profiles)');
|
||||
}
|
||||
|
||||
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
|
||||
static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) {
|
||||
final tiles = computeTileList(visibleBounds, minZoom, minZoom);
|
||||
if (tiles.isEmpty) return visibleBounds;
|
||||
|
||||
// Find the bounding box of all these tiles
|
||||
double minLat = 90.0, maxLat = -90.0;
|
||||
double minLon = 180.0, maxLon = -180.0;
|
||||
|
||||
for (final tile in tiles) {
|
||||
final tileBounds = tileToLatLngBounds(tile[1], tile[2], tile[0]);
|
||||
|
||||
minLat = math.min(minLat, tileBounds.south);
|
||||
maxLat = math.max(maxLat, tileBounds.north);
|
||||
minLon = math.min(minLon, tileBounds.west);
|
||||
maxLon = math.max(maxLon, tileBounds.east);
|
||||
}
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(minLat, minLon),
|
||||
LatLng(maxLat, maxLon),
|
||||
);
|
||||
}
|
||||
|
||||
/// Save tile bytes to disk
|
||||
static Future<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> bytes) async {
|
||||
final dir = Directory('$baseDir/tiles/$z/$x');
|
||||
await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/$y.png');
|
||||
await file.writeAsBytes(bytes);
|
||||
}
|
||||
|
||||
/// Save cameras to disk as JSON
|
||||
static Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
|
||||
/// Disk IO utilities for offline area file management ONLY. No network requests should occur here.
|
||||
|
||||
/// Save-to-disk for a tile that has already been fetched elsewhere.
|
||||
Future<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> bytes) async {
|
||||
final dir = Directory('$baseDir/tiles/$z/$x');
|
||||
await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/$y.png');
|
||||
await file.writeAsBytes(bytes);
|
||||
}
|
||||
|
||||
/// Save-to-disk for cameras.json; called only by OfflineAreaService during area download
|
||||
Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
|
||||
final file = File('$dir/cameras.json');
|
||||
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
|
||||
}
|
||||
@@ -56,6 +56,32 @@ List<int> latLonToTile(double lat, double lon, int zoom) {
|
||||
return [xtile, ytile];
|
||||
}
|
||||
|
||||
/// Convert tile coordinates back to LatLng bounds
|
||||
LatLngBounds tileToLatLngBounds(int x, int y, int z) {
|
||||
final n = pow(2, z);
|
||||
|
||||
// Calculate bounds for this tile
|
||||
final lonWest = x / n * 360.0 - 180.0;
|
||||
final lonEast = (x + 1) / n * 360.0 - 180.0;
|
||||
|
||||
// For latitude, we need to invert the mercator projection
|
||||
final latNorthRad = atan(sinh(pi * (1 - 2 * y / n)));
|
||||
final latSouthRad = atan(sinh(pi * (1 - 2 * (y + 1) / n)));
|
||||
|
||||
final latNorth = latNorthRad * 180.0 / pi;
|
||||
final latSouth = latSouthRad * 180.0 / pi;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(latSouth, lonWest), // SW corner
|
||||
LatLng(latNorth, lonEast), // NE corner
|
||||
);
|
||||
}
|
||||
|
||||
/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2
|
||||
double sinh(double x) {
|
||||
return (exp(x) - exp(-x)) / 2;
|
||||
}
|
||||
|
||||
LatLngBounds globalWorldBounds() {
|
||||
// Use slightly shrunken bounds to avoid tile index overflow at extreme coordinates
|
||||
return LatLngBounds(LatLng(-85.0, -179.9), LatLng(85.0, 179.9));
|
||||
|
||||
153
lib/services/offline_areas/world_area_manager.dart
Normal file
153
lib/services/offline_areas/world_area_manager.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
/// Manages the world area (permanent offline area for base map)
|
||||
class WorldAreaManager {
|
||||
static const String _worldAreaId = 'world';
|
||||
static const String _worldAreaName = 'World Base Map';
|
||||
|
||||
/// Ensure world area exists and check if download is needed
|
||||
static Future<OfflineArea> ensureWorldArea(
|
||||
List<OfflineArea> areas,
|
||||
Future<Directory> Function() getOfflineAreaDir,
|
||||
Future<void> Function({
|
||||
required String id,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
// Find existing world area
|
||||
OfflineArea? world;
|
||||
for (final area in areas) {
|
||||
if (area.isPermanent) {
|
||||
world = area;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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";
|
||||
world = OfflineArea(
|
||||
id: _worldAreaId,
|
||||
name: _worldAreaName,
|
||||
bounds: globalWorldBounds(),
|
||||
minZoom: kWorldMinZoom,
|
||||
maxZoom: kWorldMaxZoom,
|
||||
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
|
||||
await _checkAndStartWorldDownload(world, downloadArea);
|
||||
return world;
|
||||
}
|
||||
|
||||
/// Check world area download status and start if needed
|
||||
static Future<void> _checkAndStartWorldDownload(
|
||||
OfflineArea world,
|
||||
Future<void> Function({
|
||||
required String id,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String directory,
|
||||
String? name,
|
||||
String? tileProviderId,
|
||||
String? tileProviderName,
|
||||
String? tileTypeId,
|
||||
String? tileTypeName,
|
||||
}) downloadArea,
|
||||
) async {
|
||||
if (world.status == OfflineAreaStatus.complete) return;
|
||||
|
||||
// Count existing tiles
|
||||
final expectedTiles = computeTileList(
|
||||
globalWorldBounds(),
|
||||
kWorldMinZoom,
|
||||
kWorldMaxZoom,
|
||||
);
|
||||
|
||||
int filesFound = 0;
|
||||
for (final tile in expectedTiles) {
|
||||
final file = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
|
||||
if (file.existsSync()) {
|
||||
filesFound++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update world area stats
|
||||
world.tilesTotal = expectedTiles.length;
|
||||
world.tilesDownloaded = filesFound;
|
||||
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
|
||||
|
||||
if (filesFound == world.tilesTotal) {
|
||||
world.status = OfflineAreaStatus.complete;
|
||||
debugPrint('WorldAreaManager: World area download already complete.');
|
||||
} else {
|
||||
world.status = OfflineAreaStatus.downloading;
|
||||
debugPrint('WorldAreaManager: Starting world area download. ${world.tilesDownloaded}/${world.tilesTotal} tiles found.');
|
||||
|
||||
// Start download (fire and forget) - use OSM for world areas
|
||||
downloadArea(
|
||||
id: world.id,
|
||||
bounds: world.bounds,
|
||||
minZoom: world.minZoom,
|
||||
maxZoom: world.maxZoom,
|
||||
directory: world.directory,
|
||||
name: world.name,
|
||||
tileProviderId: 'openstreetmap',
|
||||
tileProviderName: 'OpenStreetMap',
|
||||
tileTypeId: 'osm_street',
|
||||
tileTypeName: 'Street Map',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
lib/services/simple_tile_service.dart
Normal file
114
lib/services/simple_tile_service.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'network_status.dart';
|
||||
|
||||
/// Simple HTTP client that routes tile requests through the centralized MapDataProvider.
|
||||
/// This ensures all tile fetching (offline/online routing, retries, etc.) is in one place.
|
||||
class SimpleTileHttpClient extends http.BaseClient {
|
||||
final http.Client _inner = http.Client();
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
// 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 non-tile requests
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
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};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
|
||||
try {
|
||||
// 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);
|
||||
|
||||
// Show success status briefly
|
||||
NetworkStatus.instance.setSuccess();
|
||||
|
||||
// Serve tile with proper cache headers
|
||||
return http.StreamedResponse(
|
||||
Stream.value(tileBytes),
|
||||
200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
'Expires': _httpDateFormat(DateTime.now().add(Duration(days: 7))),
|
||||
'Last-Modified': _httpDateFormat(DateTime.now().subtract(Duration(hours: 1))),
|
||||
},
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
|
||||
|
||||
// 404 means no tiles available - show "no data" status briefly
|
||||
NetworkStatus.instance.setNoData();
|
||||
|
||||
// Return 404 and let flutter_map handle it gracefully
|
||||
return http.StreamedResponse(
|
||||
Stream.value(<int>[]),
|
||||
404,
|
||||
reasonPhrase: 'Tile unavailable: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests when map view changes
|
||||
void clearTileQueue() {
|
||||
_mapDataProvider.clearTileQueue();
|
||||
}
|
||||
|
||||
/// Format date for HTTP headers (RFC 7231)
|
||||
String _httpDateFormat(DateTime date) {
|
||||
final utc = date.toUtc();
|
||||
final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
final weekday = weekdays[utc.weekday - 1];
|
||||
final day = utc.day.toString().padLeft(2, '0');
|
||||
final month = months[utc.month - 1];
|
||||
final year = utc.year;
|
||||
final hour = utc.hour.toString().padLeft(2, '0');
|
||||
final minute = utc.minute.toString().padLeft(2, '0');
|
||||
final second = utc.second.toString().padLeft(2, '0');
|
||||
|
||||
return '$weekday, $day $month $year $hour:$minute:$second GMT';
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
103
lib/state/auth_state.dart
Normal file
103
lib/state/auth_state.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/auth_service.dart';
|
||||
import 'settings_state.dart';
|
||||
|
||||
class AuthState extends ChangeNotifier {
|
||||
final AuthService _auth = AuthService();
|
||||
String? _username;
|
||||
|
||||
// Getters
|
||||
bool get isLoggedIn => _username != null;
|
||||
String get username => _username ?? '';
|
||||
AuthService get authService => _auth;
|
||||
|
||||
// Initialize auth state and check existing login
|
||||
Future<void> init(UploadMode uploadMode) async {
|
||||
_auth.setUploadMode(uploadMode);
|
||||
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
_username = await _auth.login();
|
||||
}
|
||||
} catch (e) {
|
||||
print("AuthState: Error during auth initialization: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login() async {
|
||||
try {
|
||||
_username = await _auth.login();
|
||||
} catch (e) {
|
||||
print("AuthState: Login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refreshAuthState() async {
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
_username = await _auth.login();
|
||||
} else {
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
print("AuthState: Auth refresh error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> forceLogin() async {
|
||||
try {
|
||||
_username = await _auth.forceLogin();
|
||||
} catch (e) {
|
||||
print("AuthState: Forced login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> validateToken() async {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AuthState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle upload mode changes
|
||||
Future<void> onUploadModeChanged(UploadMode mode) async {
|
||||
_auth.setUploadMode(mode);
|
||||
|
||||
// Refresh user display for active mode, validating token
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
final isValid = await validateToken();
|
||||
if (isValid) {
|
||||
_username = await _auth.login();
|
||||
} else {
|
||||
await logout(); // This clears _username also.
|
||||
}
|
||||
} else {
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
_username = null;
|
||||
print("AuthState: Mode change user restoration error: $e");
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> getAccessToken() async {
|
||||
return await _auth.getAccessToken();
|
||||
}
|
||||
}
|
||||
87
lib/state/profile_state.dart
Normal file
87
lib/state/profile_state.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/camera_profile.dart';
|
||||
import '../services/profile_service.dart';
|
||||
|
||||
class ProfileState extends ChangeNotifier {
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
|
||||
final List<CameraProfile> _profiles = [];
|
||||
final Set<CameraProfile> _enabled = {};
|
||||
|
||||
// Getters
|
||||
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(CameraProfile p) => _enabled.contains(p);
|
||||
List<CameraProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
|
||||
// Initialize profiles from built-in and custom sources
|
||||
Future<void> init() async {
|
||||
// Initialize profiles: built-in + custom
|
||||
_profiles.add(CameraProfile.alpr());
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Load enabled profile IDs from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
// Restore enabled profiles by id
|
||||
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
|
||||
} else {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleProfile(CameraProfile p, bool e) {
|
||||
if (e) {
|
||||
_enabled.add(p);
|
||||
} else {
|
||||
_enabled.remove(p);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(CameraProfile p) {
|
||||
final idx = _profiles.indexWhere((x) => x.id == p.id);
|
||||
if (idx >= 0) {
|
||||
_profiles[idx] = p;
|
||||
} else {
|
||||
_profiles.add(p);
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteProfile(CameraProfile p) {
|
||||
if (p.builtin) return;
|
||||
_enabled.remove(p);
|
||||
_profiles.removeWhere((x) => x.id == p.id);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Save enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/state/session_state.dart
Normal file
61
lib/state/session_state.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/camera_profile.dart';
|
||||
|
||||
// ------------------ AddCameraSession ------------------
|
||||
class AddCameraSession {
|
||||
AddCameraSession({required this.profile, this.directionDegrees = 0});
|
||||
CameraProfile profile;
|
||||
double directionDegrees;
|
||||
LatLng? target;
|
||||
}
|
||||
|
||||
class SessionState extends ChangeNotifier {
|
||||
AddCameraSession? _session;
|
||||
|
||||
// Getter
|
||||
AddCameraSession? get session => _session;
|
||||
|
||||
void startAddSession(List<CameraProfile> enabledProfiles) {
|
||||
_session = AddCameraSession(profile: enabledProfiles.first);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
double? directionDeg,
|
||||
CameraProfile? profile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
if (_session == null) return;
|
||||
|
||||
bool dirty = false;
|
||||
if (directionDeg != null && directionDeg != _session!.directionDegrees) {
|
||||
_session!.directionDegrees = directionDeg;
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _session!.profile) {
|
||||
_session!.profile = profile;
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null) {
|
||||
_session!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners();
|
||||
}
|
||||
|
||||
void cancelSession() {
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
AddCameraSession? commitSession() {
|
||||
if (_session?.target == null) return null;
|
||||
|
||||
final session = _session!;
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
return session;
|
||||
}
|
||||
}
|
||||
215
lib/state/settings_state.dart
Normal file
215
lib/state/settings_state.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
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';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
|
||||
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 _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;
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
|
||||
// Getters
|
||||
bool get offlineMode => _offlineMode;
|
||||
int get maxCameras => _maxCameras;
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Load offline mode
|
||||
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
|
||||
|
||||
// Load max cameras
|
||||
if (prefs.containsKey(_maxCamerasPrefsKey)) {
|
||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||
}
|
||||
|
||||
// Load upload mode (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||
if (idx >= 0 && idx < UploadMode.values.length) {
|
||||
_uploadMode = UploadMode.values[idx];
|
||||
}
|
||||
} else if (prefs.containsKey(_legacyTestModePrefsKey)) {
|
||||
// migrate legacy test_mode (true->simulate, false->prod)
|
||||
final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false;
|
||||
_uploadMode = legacy ? UploadMode.simulate : UploadMode.production;
|
||||
await prefs.remove(_legacyTestModePrefsKey);
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
|
||||
// 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();
|
||||
await prefs.setBool(_offlineModePrefsKey, enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
if (n < 10) n = 10; // minimum
|
||||
_maxCameras = n;
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setInt(_maxCamerasPrefsKey, n);
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
_uploadMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_uploadModePrefsKey, mode.index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 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 _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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
165
lib/state/upload_queue_state.dart
Normal file
165
lib/state/upload_queue_state.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/pending_upload.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../services/camera_cache.dart';
|
||||
import '../services/uploader.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import 'settings_state.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
class UploadQueueState extends ChangeNotifier {
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
// Getters
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
|
||||
// Initialize by loading queue from storage
|
||||
Future<void> init() async {
|
||||
await _loadQueue();
|
||||
}
|
||||
|
||||
// Add a completed session to the upload queue
|
||||
void addFromSession(AddCameraSession session) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target!,
|
||||
direction: session.directionDegrees,
|
||||
profile: session.profile,
|
||||
);
|
||||
|
||||
_queue.add(upload);
|
||||
_saveQueue();
|
||||
|
||||
// Add to camera cache immediately so it shows on the map
|
||||
// Create a temporary node with a negative ID (to distinguish from real OSM nodes)
|
||||
// Using timestamp as negative ID to ensure uniqueness
|
||||
final tempId = -DateTime.now().millisecondsSinceEpoch;
|
||||
final tags = Map<String, String>.from(upload.profile.tags);
|
||||
tags['direction'] = upload.direction.toStringAsFixed(0);
|
||||
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
||||
|
||||
final tempNode = OsmCameraNode(
|
||||
id: tempId,
|
||||
coord: upload.coord,
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
CameraCache.instance.addOrUpdate([tempNode]);
|
||||
// Notify camera provider to update the map
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
_queue.clear();
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeFromQueue(PendingUpload upload) {
|
||||
_queue.remove(upload);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Start the upload processing loop
|
||||
void startUploader({
|
||||
required bool offlineMode,
|
||||
required UploadMode uploadMode,
|
||||
required Future<String?> Function() getAccessToken,
|
||||
}) {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
// No uploads without queue, or if offline mode is enabled.
|
||||
if (_queue.isEmpty || offlineMode) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty || offlineMode) {
|
||||
_uploadTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first queue item that is NOT in error state and act on that
|
||||
final item = _queue.where((pu) => !pu.error).cast<PendingUpload?>().firstOrNull;
|
||||
if (item == null) return;
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
final access = await getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
bool ok;
|
||||
if (uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful upload without calling real API
|
||||
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
|
||||
ok = true;
|
||||
} else {
|
||||
// Real upload -- pass uploadMode so uploader can switch between prod and sandbox
|
||||
final up = Uploader(access, () {
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}, uploadMode: uploadMode);
|
||||
ok = await up.upload(item);
|
||||
}
|
||||
|
||||
if (ok && uploadMode == UploadMode.simulate) {
|
||||
// Remove manually for simulate mode
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
if (!ok) {
|
||||
item.attempts++;
|
||||
if (item.attempts >= 3) {
|
||||
// Mark as error and stop the uploader. User can manually retry.
|
||||
item.error = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadTimer?.cancel();
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stopUploader() {
|
||||
_uploadTimer?.cancel();
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uploadTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../services/camera_cache.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../app_state.dart';
|
||||
@@ -19,8 +20,18 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
Timer? _debounceTimer;
|
||||
|
||||
/// Call this to get (quickly) all cached overlays for the given view.
|
||||
/// Filters by currently enabled profiles.
|
||||
List<OsmCameraNode> getCachedCamerasForBounds(LatLngBounds bounds) {
|
||||
return CameraCache.instance.queryByBounds(bounds);
|
||||
final allCameras = CameraCache.instance.queryByBounds(bounds);
|
||||
final enabledProfiles = AppState.instance.enabledProfiles;
|
||||
|
||||
// If no profiles are enabled, show no cameras
|
||||
if (enabledProfiles.isEmpty) return [];
|
||||
|
||||
// Filter cameras to only show those matching enabled profiles
|
||||
return allCameras.where((camera) {
|
||||
return _matchesAnyProfile(camera, enabledProfiles);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Call this when the map view changes (bounds/profiles), triggers async fetch
|
||||
@@ -45,6 +56,8 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
);
|
||||
if (fresh.isNotEmpty) {
|
||||
CameraCache.instance.addOrUpdate(fresh);
|
||||
// Clear waiting status when camera data arrives
|
||||
NetworkStatus.instance.clearWaiting();
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -59,4 +72,25 @@ class CameraProviderWithCache extends ChangeNotifier {
|
||||
CameraCache.instance.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Force refresh the display (useful when filters change but cache doesn't)
|
||||
void refreshDisplay() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if a camera matches any of the provided profiles
|
||||
bool _matchesAnyProfile(OsmCameraNode camera, List<CameraProfile> profiles) {
|
||||
for (final profile in profiles) {
|
||||
if (_cameraMatchesProfile(camera, profile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if a camera matches a specific profile (all profile tags must match)
|
||||
bool _cameraMatchesProfile(OsmCameraNode camera, CameraProfile profile) {
|
||||
for (final entry in profile.tags.entries) {
|
||||
if (camera.tags[entry.key] != entry.value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
279
lib/widgets/download_area_dialog.dart
Normal file
279
lib/widgets/download_area_dialog.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.dart';
|
||||
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
const DownloadAreaDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
|
||||
}
|
||||
|
||||
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
double _zoom = 15;
|
||||
int? _minZoom;
|
||||
int? _maxPossibleZoom;
|
||||
int? _tileCount;
|
||||
double? _mbEstimate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
}
|
||||
|
||||
void _recomputeEstimates() {
|
||||
var bounds = widget.controller.camera.visibleBounds;
|
||||
|
||||
// If the visible area is nearly zero, nudge the bounds for estimation
|
||||
const double epsilon = 0.0002;
|
||||
final latSpan = (bounds.north - bounds.south).abs();
|
||||
final lngSpan = (bounds.east - bounds.west).abs();
|
||||
if (latSpan < epsilon && lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
} else if (latSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
|
||||
);
|
||||
} else if (lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
}
|
||||
|
||||
final minZoom = kWorldMaxZoom + 1;
|
||||
final maxZoom = _zoom.toInt();
|
||||
|
||||
// Calculate maximum possible zoom based on tile count limit
|
||||
final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom);
|
||||
|
||||
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
|
||||
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
|
||||
|
||||
setState(() {
|
||||
_minZoom = minZoom;
|
||||
_maxPossibleZoom = maxPossibleZoom;
|
||||
_tileCount = nTiles;
|
||||
_mbEstimate = totalMb;
|
||||
});
|
||||
}
|
||||
|
||||
/// Calculate the maximum zoom level that keeps tile count under the limit
|
||||
int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) {
|
||||
for (int zoom = minZoom; zoom <= kAbsoluteMaxZoom; zoom++) {
|
||||
final tileCount = computeTileList(bounds, minZoom, zoom).length;
|
||||
if (tileCount > kMaxReasonableTileCount) {
|
||||
// Return the previous zoom level that was still under the limit
|
||||
return math.max(minZoom, zoom - 1);
|
||||
}
|
||||
}
|
||||
return kAbsoluteMaxZoom;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
final isOfflineMode = appState.offlineMode;
|
||||
|
||||
// Use the calculated max possible zoom instead of fixed span
|
||||
final sliderMin = _minZoom?.toDouble() ?? 12.0;
|
||||
final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0;
|
||||
final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12));
|
||||
final sliderValue = _zoom.clamp(sliderMin, sliderMax);
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.download_for_offline),
|
||||
SizedBox(width: 10),
|
||||
Text("Download Map Area"),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Max zoom level'),
|
||||
Text('Z${_zoom.toStringAsFixed(0)}'),
|
||||
],
|
||||
),
|
||||
|
||||
Slider(
|
||||
min: sliderMin,
|
||||
max: sliderMax,
|
||||
divisions: sliderDivisions,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: sliderValue,
|
||||
onChanged: (v) {
|
||||
setState(() => _zoom = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Storage estimate:'),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_mbEstimate == null
|
||||
? '…'
|
||||
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB',
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_minZoom != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Min zoom:'),
|
||||
Text('Z$_minZoom'),
|
||||
],
|
||||
),
|
||||
if (_maxPossibleZoom != null && _tileCount != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange.withOpacity(0.1)
|
||||
: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Max recommended zoom: Z$_maxPossibleZoom',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[700]
|
||||
: Colors.green[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_tileCount! > kMaxReasonableTileCount
|
||||
? 'Current selection exceeds ${kMaxReasonableTileCount.toString()} tile limit'
|
||||
: 'Within ${kMaxReasonableTileCount.toString()} tile limit',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _tileCount! > kMaxReasonableTileCount
|
||||
? Colors.orange[600]
|
||||
: Colors.green[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOfflineMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.wifi_off, color: Colors.orange[700], size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Downloads disabled while in offline mode. Disable offline mode to download new areas.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: isOfflineMode ? null : () async {
|
||||
try {
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
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(
|
||||
id: id,
|
||||
bounds: bounds,
|
||||
minZoom: _minZoom ?? 12,
|
||||
maxZoom: maxZoom,
|
||||
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(
|
||||
const SnackBar(
|
||||
content: Text('Download started! Fetching tiles and cameras...'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start download: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/widgets/map/camera_markers.dart
Normal file
99
lib/widgets/map/camera_markers.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
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 {
|
||||
final OsmCameraNode node;
|
||||
final MapController mapController;
|
||||
const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CameraMapMarker> createState() => _CameraMapMarkerState();
|
||||
}
|
||||
|
||||
class _CameraMapMarkerState extends State<CameraMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => CameraTagSheet(node: widget.node),
|
||||
showDragHandle: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tapTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if this is a pending upload
|
||||
final isPending = widget.node.tags.containsKey('_pending_upload') &&
|
||||
widget.node.tags['_pending_upload'] == 'true';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
child: CameraIcon(
|
||||
type: isPending ? CameraIconType.pending : CameraIconType.real,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to build marker layers for cameras and user location
|
||||
class CameraMarkersBuilder {
|
||||
static List<Marker> buildCameraMarkers({
|
||||
required List<OsmCameraNode> cameras,
|
||||
required MapController mapController,
|
||||
LatLng? userLocation,
|
||||
}) {
|
||||
final markers = <Marker>[
|
||||
// Camera markers
|
||||
...cameras
|
||||
.where(_isValidCameraCoordinate)
|
||||
.map((n) => Marker(
|
||||
point: n.coord,
|
||||
width: kCameraIconDiameter,
|
||||
height: kCameraIconDiameter,
|
||||
child: CameraMapMarker(node: n, mapController: mapController),
|
||||
)),
|
||||
|
||||
// User location marker
|
||||
if (userLocation != null)
|
||||
Marker(
|
||||
point: userLocation,
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const Icon(Icons.my_location, color: Colors.blue),
|
||||
),
|
||||
];
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
static bool _isValidCameraCoordinate(OsmCameraNode node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}
|
||||
}
|
||||
95
lib/widgets/map/direction_cones.dart
Normal file
95
lib/widgets/map/direction_cones.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
|
||||
/// Helper class to build direction cone polygons for cameras
|
||||
class DirectionConesBuilder {
|
||||
static List<Polygon> buildDirectionCones({
|
||||
required List<OsmCameraNode> cameras,
|
||||
required double zoom,
|
||||
AddCameraSession? session,
|
||||
}) {
|
||||
final overlays = <Polygon>[];
|
||||
|
||||
// Add session cone if in add-camera mode
|
||||
if (session != null && session.target != null) {
|
||||
overlays.add(_buildCone(
|
||||
session.target!,
|
||||
session.directionDegrees,
|
||||
zoom,
|
||||
));
|
||||
}
|
||||
|
||||
// Add cones for cameras with direction
|
||||
overlays.addAll(
|
||||
cameras
|
||||
.where(_isValidCameraWithDirection)
|
||||
.map((n) => _buildCone(
|
||||
n.coord,
|
||||
n.directionDeg!,
|
||||
zoom,
|
||||
))
|
||||
);
|
||||
|
||||
return overlays;
|
||||
}
|
||||
|
||||
static bool _isValidCameraWithDirection(OsmCameraNode node) {
|
||||
return node.hasDirection &&
|
||||
node.directionDeg != null &&
|
||||
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}
|
||||
|
||||
static bool _isPendingUpload(OsmCameraNode node) {
|
||||
return node.tags.containsKey('_pending_upload') &&
|
||||
node.tags['_pending_upload'] == 'true';
|
||||
}
|
||||
|
||||
static Polygon _buildCone(
|
||||
LatLng origin,
|
||||
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;
|
||||
final dLat = length * math.cos(rad);
|
||||
final dLon =
|
||||
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||
}
|
||||
|
||||
// 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: 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/widgets/map/map_overlays.dart
Normal file
146
lib/widgets/map/map_overlays.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// MODE INDICATOR badge (top-right)
|
||||
if (uploadMode == UploadMode.sandbox || uploadMode == UploadMode.simulate)
|
||||
Positioned(
|
||||
top: 18,
|
||||
right: 14,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: uploadMode == UploadMode.sandbox
|
||||
? Colors.orange.withOpacity(0.90)
|
||||
: Colors.deepPurple.withOpacity(0.80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
uploadMode == UploadMode.sandbox
|
||||
? 'SANDBOX MODE'
|
||||
: 'SIMULATE',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned above scale bar
|
||||
Positioned(
|
||||
left: 10,
|
||||
bottom: kZoomIndicatorBottomOffset,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.52),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final zoom = mapController.camera.zoom;
|
||||
return Text(
|
||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Attribution overlay
|
||||
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
|
||||
if (session != null)
|
||||
IgnorePointer(
|
||||
child: Center(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, kAddPinYOffset),
|
||||
child: const CameraIcon(type: CameraIconType.mock),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,26 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
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/map_data_provider.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/simple_tile_service.dart';
|
||||
import '../services/network_status.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import 'debouncer.dart';
|
||||
import 'camera_tag_sheet.dart';
|
||||
import 'tile_provider_with_cache.dart';
|
||||
import 'camera_provider_with_cache.dart';
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
|
||||
// --- Smart marker widget for camera with single/double tap distinction
|
||||
class _CameraMapMarker extends StatefulWidget {
|
||||
final OsmCameraNode node;
|
||||
final MapController mapController;
|
||||
const _CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_CameraMapMarker> createState() => _CameraMapMarkerState();
|
||||
}
|
||||
|
||||
class _CameraMapMarkerState extends State<_CameraMapMarker> {
|
||||
Timer? _tapTimer;
|
||||
// From dev_config.dart for build-time parameters
|
||||
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||
|
||||
void _onTap() {
|
||||
_tapTimer = Timer(tapTimeout, () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => CameraTagSheet(node: widget.node),
|
||||
showDragHandle: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDoubleTap() {
|
||||
_tapTimer?.cancel();
|
||||
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tapTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if this is a pending upload
|
||||
final isPending = widget.node.tags.containsKey('_pending_upload') &&
|
||||
widget.node.tags['_pending_upload'] == 'true';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
child: Icon(
|
||||
Icons.videocam,
|
||||
color: isPending ? Colors.purple : Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'map/camera_markers.dart';
|
||||
import 'map/direction_cones.dart';
|
||||
import 'map/map_overlays.dart';
|
||||
import 'network_status_indicator.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class MapView extends StatefulWidget {
|
||||
final MapController controller;
|
||||
@@ -89,31 +35,44 @@ class MapView extends StatefulWidget {
|
||||
final VoidCallback onUserGesture;
|
||||
|
||||
@override
|
||||
State<MapView> createState() => _MapViewState();
|
||||
State<MapView> createState() => MapViewState();
|
||||
}
|
||||
|
||||
class _MapViewState extends State<MapView> {
|
||||
class MapViewState extends State<MapView> {
|
||||
late final MapController _controller;
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
final Debouncer _debounce = Debouncer(kDebounceCameraRefresh);
|
||||
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
|
||||
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
|
||||
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
LatLng? _currentLatLng;
|
||||
|
||||
late final CameraProviderWithCache _cameraProvider;
|
||||
late final SimpleTileHttpClient _tileHttpClient;
|
||||
|
||||
// Track profile changes to trigger camera refresh
|
||||
List<CameraProfile>? _lastEnabledProfiles;
|
||||
|
||||
// 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() {
|
||||
super.initState();
|
||||
// _debounceTileLayerUpdate removed
|
||||
OfflineAreaService();
|
||||
_controller = widget.controller;
|
||||
_tileHttpClient = SimpleTileHttpClient();
|
||||
_initLocation();
|
||||
|
||||
// Set up camera overlay caching
|
||||
_cameraProvider = CameraProviderWithCache.instance;
|
||||
_cameraProvider.addListener(_onCamerasUpdated);
|
||||
// Ensure initial overlays are fetched
|
||||
|
||||
// Fetch initial cameras
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_refreshCamerasFromProvider();
|
||||
});
|
||||
@@ -122,8 +81,10 @@ class _MapViewState extends State<MapView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_positionSub?.cancel();
|
||||
_debounce.dispose();
|
||||
_cameraDebounce.dispose();
|
||||
_tileDebounce.dispose();
|
||||
_cameraProvider.removeListener(_onCamerasUpdated);
|
||||
_tileHttpClient.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -131,6 +92,14 @@ class _MapViewState extends State<MapView> {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Public method to retry location initialization (e.g., after permission granted)
|
||||
void retryLocationInit() {
|
||||
debugPrint('[MapView] Retrying location initialization');
|
||||
_initLocation();
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _refreshCamerasFromProvider() {
|
||||
final appState = context.read<AppState>();
|
||||
LatLngBounds? bounds;
|
||||
@@ -159,6 +128,10 @@ class _MapViewState extends State<MapView> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -198,13 +171,77 @@ class _MapViewState extends State<MapView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to check if two profile lists are equal
|
||||
bool _profileListsEqual(List<CameraProfile> list1, List<CameraProfile> list2) {
|
||||
if (list1.length != list2.length) return false;
|
||||
// Compare by profile IDs since profiles are value objects
|
||||
final ids1 = list1.map((p) => p.id).toSet();
|
||||
final ids2 = list2.map((p) => p.id).toSet();
|
||||
return ids1.length == ids2.length && ids1.containsAll(ids2);
|
||||
}
|
||||
|
||||
/// Build tile layer - uses fake domain that SimpleTileHttpClient can parse
|
||||
Widget _buildTileLayer(AppState appState) {
|
||||
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: urlTemplate,
|
||||
userAgentPackageName: 'com.stopflock.flock_map_app',
|
||||
tileProvider: NetworkTileProvider(
|
||||
httpClient: _tileHttpClient,
|
||||
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final session = appState.session;
|
||||
|
||||
// Only update cameras when map moves or profiles/mode actually change (not every build!)
|
||||
// _refreshCamerasFromProvider() is now only called from map movement and relevant change handlers.
|
||||
// Check if enabled profiles changed and refresh cameras if needed
|
||||
final currentEnabledProfiles = appState.enabledProfiles;
|
||||
if (_lastEnabledProfiles == null ||
|
||||
!_profileListsEqual(_lastEnabledProfiles!, currentEnabledProfiles)) {
|
||||
_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
|
||||
_refreshCamerasFromProvider();
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -229,39 +266,19 @@ class _MapViewState extends State<MapView> {
|
||||
final cameras = (mapBounds != null)
|
||||
? cameraProvider.getCachedCamerasForBounds(mapBounds)
|
||||
: <OsmCameraNode>[];
|
||||
final markers = <Marker>[
|
||||
...cameras
|
||||
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
|
||||
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
|
||||
.map((n) => Marker(
|
||||
point: n.coord,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: _CameraMapMarker(node: n, mapController: _controller),
|
||||
)),
|
||||
if (_currentLatLng != null)
|
||||
Marker(
|
||||
point: _currentLatLng!,
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const Icon(Icons.my_location, color: Colors.blue),
|
||||
),
|
||||
];
|
||||
|
||||
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
||||
cameras: cameras,
|
||||
mapController: _controller,
|
||||
userLocation: _currentLatLng,
|
||||
);
|
||||
|
||||
final overlays = DirectionConesBuilder.buildDirectionCones(
|
||||
cameras: cameras,
|
||||
zoom: zoom,
|
||||
session: session,
|
||||
);
|
||||
|
||||
final overlays = <Polygon>[
|
||||
if (session != null && session.target != null)
|
||||
_buildCone(session.target!, session.directionDegrees, zoom),
|
||||
...cameras
|
||||
.where((n) => n.hasDirection && n.directionDeg != null)
|
||||
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
|
||||
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
|
||||
.map((n) => _buildCone(
|
||||
n.coord,
|
||||
n.directionDeg!,
|
||||
zoom,
|
||||
isPending: n.tags.containsKey('_pending_upload') && n.tags['_pending_upload'] == 'true',
|
||||
)),
|
||||
];
|
||||
return Stack(
|
||||
children: [
|
||||
PolygonLayer(polygons: overlays),
|
||||
@@ -274,7 +291,7 @@ class _MapViewState extends State<MapView> {
|
||||
return Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: ValueKey(appState.offlineMode),
|
||||
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_$_mapRebuildKey'),
|
||||
mapController: _controller,
|
||||
options: MapOptions(
|
||||
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),
|
||||
@@ -286,39 +303,30 @@ class _MapViewState extends State<MapView> {
|
||||
if (session != null) {
|
||||
appState.updateSession(target: pos.center);
|
||||
}
|
||||
// Only request more cameras if the user navigated the map (and at valid zoom)
|
||||
if (gesture && pos.zoom >= 10) {
|
||||
_debounce(_refreshCamerasFromProvider);
|
||||
|
||||
// Show waiting indicator when map moves (user is expecting new content)
|
||||
NetworkStatus.instance.setWaiting();
|
||||
|
||||
// Only clear tile queue on significant ZOOM changes (not panning)
|
||||
final currentZoom = pos.zoom;
|
||||
final zoomChanged = _lastZoom != null && (currentZoom - _lastZoom!).abs() > 0.5;
|
||||
|
||||
if (zoomChanged) {
|
||||
_tileDebounce(() {
|
||||
// Clear stale tile requests on zoom change (quietly)
|
||||
_tileHttpClient.clearTileQueue();
|
||||
});
|
||||
}
|
||||
_lastZoom = currentZoom;
|
||||
|
||||
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
|
||||
if (pos.zoom >= 10) {
|
||||
_cameraDebounce(_refreshCamerasFromProvider);
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
tileProvider: Provider.of<TileProviderWithCache>(context),
|
||||
urlTemplate: 'unused-{z}-{x}-{y}',
|
||||
tileSize: 256,
|
||||
tileBuilder: (ctx, tileWidget, tileImage) {
|
||||
try {
|
||||
final str = tileImage.toString();
|
||||
final regex = RegExp(r'TileCoordinate\((\d+), (\d+), (\d+)\)');
|
||||
final match = regex.firstMatch(str);
|
||||
if (match != null) {
|
||||
final x = match.group(1);
|
||||
final y = match.group(2);
|
||||
final z = match.group(3);
|
||||
final key = '$z/$x/$y';
|
||||
final bytes = TileProviderWithCache.tileCache[key];
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
return Image.memory(bytes, gaplessPlayback: true, fit: BoxFit.cover);
|
||||
}
|
||||
}
|
||||
return tileWidget;
|
||||
} catch (e) {
|
||||
print('tileBuilder error: $e for tileImage: ${tileImage.toString()}');
|
||||
return tileWidget;
|
||||
}
|
||||
}
|
||||
),
|
||||
_buildTileLayer(appState),
|
||||
cameraLayers,
|
||||
// Built-in scale bar from flutter_map
|
||||
Scalebar(
|
||||
@@ -332,113 +340,18 @@ class _MapViewState extends State<MapView> {
|
||||
],
|
||||
),
|
||||
|
||||
// MODE INDICATOR badge (top-right)
|
||||
if (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate)
|
||||
Positioned(
|
||||
top: 18,
|
||||
right: 14,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: appState.uploadMode == UploadMode.sandbox
|
||||
? Colors.orange.withOpacity(0.90)
|
||||
: Colors.deepPurple.withOpacity(0.80),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
appState.uploadMode == UploadMode.sandbox
|
||||
? 'SANDBOX MODE'
|
||||
: 'SIMULATE',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned above scale bar
|
||||
Positioned(
|
||||
left: 10,
|
||||
bottom: kZoomIndicatorBottomOffset,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.52),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final zoom = _controller.camera.zoom;
|
||||
return Text(
|
||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
// All map overlays (mode indicator, zoom, attribution, add pin)
|
||||
MapOverlays(
|
||||
mapController: _controller,
|
||||
uploadMode: appState.uploadMode,
|
||||
session: session,
|
||||
attribution: appState.selectedTileType?.attribution,
|
||||
),
|
||||
|
||||
// Fixed pin when adding camera
|
||||
if (session != null)
|
||||
IgnorePointer(
|
||||
child: Center(
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, kAddPinYOffset),
|
||||
child: Icon(Icons.place, size: 40, color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Network status indicator (top-left)
|
||||
const NetworkStatusIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Polygon _buildCone(LatLng origin, double bearingDeg, double zoom, {bool isPending = false}) {
|
||||
final halfAngle = kDirectionConeHalfAngle;
|
||||
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
|
||||
|
||||
LatLng _project(double deg) {
|
||||
final rad = deg * math.pi / 180;
|
||||
final dLat = length * math.cos(rad);
|
||||
final dLon =
|
||||
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||
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;
|
||||
|
||||
return Polygon(
|
||||
points: [origin, left, right, origin],
|
||||
color: color.withOpacity(0.25),
|
||||
borderColor: color,
|
||||
borderStrokeWidth: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
104
lib/widgets/network_status_indicator.dart
Normal file
104
lib/widgets/network_status_indicator.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/network_status.dart';
|
||||
|
||||
class NetworkStatusIndicator extends StatelessWidget {
|
||||
const NetworkStatusIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: NetworkStatus.instance,
|
||||
child: Consumer<NetworkStatus>(
|
||||
builder: (context, networkStatus, child) {
|
||||
String message;
|
||||
IconData icon;
|
||||
Color color;
|
||||
|
||||
switch (networkStatus.currentStatus) {
|
||||
case NetworkStatusType.waiting:
|
||||
message = 'Loading...';
|
||||
icon = Icons.hourglass_empty;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.timedOut:
|
||||
message = 'Timed out';
|
||||
icon = Icons.hourglass_disabled;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
|
||||
case NetworkStatusType.noData:
|
||||
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) {
|
||||
case NetworkIssueType.osmTiles:
|
||||
message = 'OSM tiles slow';
|
||||
icon = Icons.map_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.overpassApi:
|
||||
message = 'Camera data slow';
|
||||
icon = Icons.camera_alt_outlined;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NetworkIssueType.both:
|
||||
message = 'Network issues';
|
||||
icon = Icons.cloud_off_outlined;
|
||||
color = Colors.red;
|
||||
break;
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
break;
|
||||
|
||||
case NetworkStatusType.ready:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../app_state.dart';
|
||||
|
||||
/// Singleton in-memory tile cache and async provider for custom tiles.
|
||||
class TileProviderWithCache extends TileProvider with ChangeNotifier {
|
||||
static final Map<String, Uint8List> _tileCache = {};
|
||||
static Map<String, Uint8List> get tileCache => _tileCache;
|
||||
|
||||
TileProviderWithCache();
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coords, TileLayer options, {MapSource source = MapSource.auto}) {
|
||||
final key = '${coords.z}/${coords.x}/${coords.y}';
|
||||
if (_tileCache.containsKey(key)) {
|
||||
final bytes = _tileCache[key]!;
|
||||
return MemoryImage(bytes);
|
||||
} else {
|
||||
_fetchAndCacheTile(coords, key, source: source);
|
||||
// Always return a placeholder until the real tile is cached
|
||||
return const AssetImage('assets/transparent_1x1.png');
|
||||
}
|
||||
}
|
||||
|
||||
static void clearCache() {
|
||||
_tileCache.clear();
|
||||
print('[TileProviderWithCache] Tile cache cleared');
|
||||
}
|
||||
|
||||
void _fetchAndCacheTile(TileCoordinates coords, String key, {MapSource source = MapSource.auto}) async {
|
||||
// Don't fire multiple fetches for the same tile simultaneously
|
||||
if (_tileCache.containsKey(key)) return;
|
||||
try {
|
||||
final bytes = await MapDataProvider().getTile(
|
||||
z: coords.z, x: coords.x, y: coords.y, source: source,
|
||||
);
|
||||
if (bytes.isNotEmpty) {
|
||||
_tileCache[key] = Uint8List.fromList(bytes);
|
||||
print('[TileProviderWithCache] Cached tile $key, bytes=${bytes.length}');
|
||||
notifyListeners(); // This updates any listening widgets
|
||||
}
|
||||
// If bytes were empty, don't cache (will re-attempt next time)
|
||||
} catch (e) {
|
||||
print('[TileProviderWithCache] Error fetching tile $key: $e');
|
||||
// Do NOT cache a failed or empty tile! Placeholder tiles will be evicted on online transition.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user