Compare commits

..

30 Commits

Author SHA1 Message Date
stopflock
024d3f09c3 fix arcgis satellite source 2025-08-23 21:51:22 -05:00
stopflock
e65b9f58a6 holy crap tile types 2025-08-23 21:40:36 -05:00
stopflock
7bd6f68a99 add no offline data status indicator 2025-08-23 20:06:46 -05:00
stopflock
f11bd6e238 add timed out status indicator 2025-08-23 19:49:19 -05:00
stopflock
f45279ecfe retry for location permission when follow-me enabled 2025-08-23 18:46:21 -05:00
stopflock
d6625ccc23 holy crap I think this is working. 2025-08-23 18:23:18 -05:00
stopflock
722e640a72 tile fetching and caching much improved 2025-08-23 18:08:25 -05:00
stopflock
a21e807d88 lot of changes, got rid of custom cache stuff, now stepping in the way of http fetch instead of screwing with flutter map. 2025-08-23 17:42:53 -05:00
stopflock
a2bc3309c0 chasing excess tile fetching and lack of correct cache clearing - NOT WORKING 2025-08-23 12:27:04 -05:00
stopflock
f6adffc84e fixes tile re-rendering after long rate limit periods without having to zoom/pan 2025-08-22 23:44:49 -05:00
stopflock
01f73322c7 fixes offline -> online transition for tile cache re-fetching, also display of camera profiles only when enabled 2025-08-22 23:27:01 -05:00
stopflock
257aefb2fc cleanup 2025-08-22 21:16:59 -05:00
stopflock
63ebc2b682 ho lee shet 2025-08-22 21:04:30 -05:00
stopflock
1f3849cd84 break up offlin areas 2025-08-21 21:50:30 -05:00
stopflock
e35266c160 better offline area max calculation, and added guardrails 2025-08-21 20:22:13 -05:00
stopflock
05de16b2e2 cleanup round 2025-08-21 19:42:02 -05:00
stopflock
32507e1646 fix offlines cameras loading on zoom out 2025-08-21 19:20:45 -05:00
stopflock
1272eb9409 break up map_view 2025-08-21 19:15:59 -05:00
stopflock
4cc8929378 break up home screen - separate out download dialog 2025-08-21 19:04:16 -05:00
stopflock
44707bf064 fix download dialog 2025-08-21 18:54:07 -05:00
stopflock
ff9a052d3f broke download dialog, cleaned up debug stuff 2025-08-21 18:52:53 -05:00
stopflock
df5e26f78d breakup app_state 2025-08-21 18:39:09 -05:00
stopflock
865f91ea55 bump version 2025-08-20 00:35:49 -05:00
stopflock
268c9ebb3a min zoom for offline areas is now max world zoom + 1 2025-08-19 17:54:27 -05:00
stopflock
7875fd0d58 fix loading cameras from offline areas 2025-08-19 17:29:32 -05:00
stopflock
4bb57580cd pending uploads in purple 2025-08-18 23:49:10 -05:00
stopflock
5521da28c4 add submitted cameras to cache immediately 2025-08-18 23:41:29 -05:00
stopflock
e5d00803f7 add zoom in/out buttons 2025-08-18 23:12:16 -05:00
stopflock
a73605cc53 disable follow me when adding a camera 2025-08-18 23:07:34 -05:00
stopflock
7aa0c9dff4 fix macos builds - 10.15+ only 2025-08-17 23:09:16 -05:00
35 changed files with 3378 additions and 1081 deletions

View File

@@ -1,324 +1,136 @@
import 'dart:convert';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/camera_profile.dart';
import 'models/pending_upload.dart';
import 'services/auth_service.dart';
import 'services/uploader.dart';
import 'services/profile_service.dart';
import 'widgets/tile_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 for backward compatibility
export 'state/settings_state.dart' show UploadMode;
export 'state/session_state.dart' show AddCameraSession;
export 'models/tile_provider.dart' show TileProviderType;
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
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;
TileProviderType get tileProvider => _settingsState.tileProvider;
// Upload queue state
int get pendingCount => _uploadQueueState.pendingCount;
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
final List<CameraProfile> _profiles = [];
final Set<CameraProfile> _enabled = {};
static const String _enabledPrefsKey = 'enabled_profiles';
static const String _maxCamerasPrefsKey = 'max_cameras';
// 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(),
);
}
// ---------- Addcamera session ----------
// ---------- Session Methods ----------
void startAddSession() {
_session = AddCameraSession(profile: enabledProfiles.first);
notifyListeners();
_sessionState.startAddSession(enabledProfiles);
}
void updateSession({
@@ -326,148 +138,83 @@ 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;
_queue.add(
PendingUpload(
coord: _session!.target!,
direction: _session!.directionDegrees,
profile: _session!.profile,
),
);
_saveQueue();
_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 ----------
Future<void> setTileProvider(TileProviderType provider) async {
await _settingsState.setTileProvider(provider);
}
// ---------- 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();
}
}

View File

@@ -23,7 +23,7 @@ const double kAddPinYOffset = -16.0;
// Client name and version for OSM uploads ("created_by" tag)
const String kClientName = 'FlockMap';
const String kClientVersion = '0.8.2';
const String kClientVersion = '0.8.3';
// Marker/camera interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning
@@ -41,3 +41,7 @@ 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 = 10000;
const int kAbsoluteMaxZoom = 19;

View File

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

View File

@@ -0,0 +1,100 @@
enum TileProviderType {
osmStreet,
googleHybrid,
arcgisSatellite,
mapboxSatellite,
}
class TileProviderConfig {
final TileProviderType type;
final String name;
final String urlTemplate;
final String attribution;
final bool requiresApiKey;
final String? description;
const TileProviderConfig({
required this.type,
required this.name,
required this.urlTemplate,
required this.attribution,
this.requiresApiKey = false,
this.description,
});
/// Returns the URL template with API key inserted if needed
String getUrlTemplate({String? apiKey}) {
if (requiresApiKey && apiKey != null) {
return urlTemplate.replaceAll('{api_key}', apiKey);
}
return urlTemplate;
}
/// Check if this provider is available (has required API key if needed)
bool isAvailable({String? apiKey}) {
if (requiresApiKey) {
return apiKey != null && apiKey.isNotEmpty;
}
return true;
}
}
/// Built-in tile provider configurations
class TileProviders {
static const osmStreet = TileProviderConfig(
type: TileProviderType.osmStreet,
name: 'Street Map',
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
description: 'Standard street map with roads, buildings, and labels',
);
static const googleHybrid = TileProviderConfig(
type: TileProviderType.googleHybrid,
name: 'Satellite + Roads',
urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
attribution: '© Google',
description: 'Satellite imagery with road and label overlays',
);
static const arcgisSatellite = TileProviderConfig(
type: TileProviderType.arcgisSatellite,
name: 'Pure Satellite',
urlTemplate: 'https://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png',
attribution: '© Esri © Maxar',
description: 'High-resolution satellite imagery without overlays',
);
static const mapboxSatellite = TileProviderConfig(
type: TileProviderType.mapboxSatellite,
name: 'Pure Satellite (Mapbox)',
urlTemplate: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
attribution: '© Mapbox © Maxar',
requiresApiKey: true,
description: 'High-resolution satellite imagery without overlays',
);
/// Get all available tile providers (those with API keys if required)
static List<TileProviderConfig> getAvailable({String? mapboxApiKey}) {
return [
osmStreet,
googleHybrid,
arcgisSatellite,
if (mapboxSatellite.isAvailable(apiKey: mapboxApiKey)) mapboxSatellite,
];
}
/// Get provider config by type
static TileProviderConfig? getByType(TileProviderType type) {
switch (type) {
case TileProviderType.osmStreet:
return osmStreet;
case TileProviderType.googleHybrid:
return googleHybrid;
case TileProviderType.arcgisSatellite:
return arcgisSatellite;
case TileProviderType.mapboxSatellite:
return mapboxSatellite;
}
}
}

View File

@@ -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,10 +19,14 @@ 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;
void _openAddCameraSheet() {
// Disable follow-me when adding a camera so the map doesn't jump around
setState(() => _followMe = false);
final appState = context.read<AppState>();
appState.startAddSession();
final session = appState.session!; // guaranteed nonnull now
@@ -40,7 +42,6 @@ class _HomeScreenState extends State<HomeScreen> {
return MultiProvider(
providers: [
ChangeNotifierProvider<TileProviderWithCache>(create: (_) => TileProviderWithCache()),
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
],
child: Scaffold(
@@ -51,7 +52,13 @@ class _HomeScreenState extends State<HomeScreen> {
IconButton(
tooltip: _followMe ? 'Disable followme' : 'Enable followme',
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),
@@ -62,12 +69,41 @@ 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(
@@ -124,184 +160,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 = findDynamicMinZoom(bounds);
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'),
),
],
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/tile_provider.dart';
class TileProviderSection extends StatelessWidget {
const TileProviderSection({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final currentProvider = appState.tileProvider;
// Get available providers (for now, all free ones are available)
final availableProviders = [
TileProviders.osmStreet,
TileProviders.googleHybrid,
TileProviders.arcgisSatellite,
// Don't include Mapbox for now since we don't have API key handling
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Map Type',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...availableProviders.map((config) {
final isSelected = config.type == currentProvider;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Radio<TileProviderType>(
value: config.type,
groupValue: currentProvider,
onChanged: (TileProviderType? value) {
if (value != null) {
appState.setTileProvider(value);
}
},
),
title: Text(config.name),
subtitle: config.description != null
? Text(
config.description!,
style: Theme.of(context).textTheme.bodySmall,
)
: null,
onTap: () {
appState.setTileProvider(config.type);
},
);
}),
],
);
}
}

View File

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

View File

@@ -39,12 +39,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(
@@ -121,12 +119,10 @@ 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);
@@ -148,4 +144,9 @@ class MapDataProvider {
}
}
}
/// Clear any queued tile requests (call when map view changes significantly)
void clearTileQueue() {
clearOSMTileQueue();
}
}

View File

@@ -6,6 +6,7 @@ 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).
@@ -45,11 +46,13 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
print('[camerasFromOverpass] Status: ${resp.statusCode}, Length: ${resp.body.length}');
if (resp.statusCode != 200) {
print('[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}');
NetworkStatus.instance.reportOverpassSuccess();
return elements.whereType<Map<String, dynamic>>().map((e) {
return OsmCameraNode(
id: e['id'],
@@ -59,6 +62,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 [];
}
}

View File

@@ -6,7 +6,9 @@ import '../offline_areas/offline_tile_utils.dart';
/// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found.
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
final areas = OfflineAreaService().offlineAreas;
final offlineService = OfflineAreaService();
await offlineService.ensureInitialized();
final areas = offlineService.offlineAreas;
final List<_AreaTileMatch> candidates = [];
for (final area in areas) {

View File

@@ -4,10 +4,17 @@ 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 clearOSMTileQueue() {
final clearedCount = _tileFetchSemaphore.clearQueue();
debugPrint('[OSMTiles] Cleared $clearedCount queued tile requests');
}
/// 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({
@@ -24,6 +31,7 @@ Future<List<int>> fetchOSMTile({
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
];
while (true) {
await _tileFetchSemaphore.acquire();
try {
@@ -31,19 +39,31 @@ Future<List<int>> fetchOSMTile({
attempt++;
final resp = await http.get(Uri.parse(url));
print('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}');
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
print('[fetchOSMTile] SUCCESS $z/$x/$y');
NetworkStatus.instance.reportOsmTileSuccess();
return resp.bodyBytes;
} else {
print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
NetworkStatus.instance.reportOsmTileIssue();
throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
}
} catch (e) {
print('[fetchOSMTile] Exception $z/$x/$y: $e');
// Report network issues on connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOsmTileIssue();
}
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));
@@ -79,4 +99,11 @@ class _SimpleSemaphore {
_current--;
}
}
/// Clear all queued requests (call when view changes significantly)
int clearQueue() {
final clearedCount = _queue.length;
_queue.clear();
return clearedCount;
}
}

View File

@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'dart:async';
import '../app_state.dart';
enum NetworkIssueType { osmTiles, overpassApi, both }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready }
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;
int _recentOfflineMisses = 0;
Timer? _osmRecoveryTimer;
Timer? _overpassRecoveryTimer;
Timer? _waitingTimer;
Timer? _noDataResetTimer;
// 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;
NetworkStatusType get currentStatus {
if (hasAnyIssues) return NetworkStatusType.issues;
if (_isWaitingForData) return NetworkStatusType.waiting;
if (_isTimedOut) return NetworkStatusType.timedOut;
if (_hasNoData) return NetworkStatusType.noData;
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 OSM tile server issues
void reportOsmTileIssue() {
if (!_osmTilesHaveIssues) {
_osmTilesHaveIssues = true;
notifyListeners();
debugPrint('[NetworkStatus] OSM 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] OSM 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) {
debugPrint('[NetworkStatus] OSM tile server issues cleared after success');
_osmTilesHaveIssues = false;
_osmRecoveryTimer?.cancel();
notifyListeners();
}
}
void reportOverpassSuccess() {
if (_overpassHasIssues) {
debugPrint('[NetworkStatus] Overpass API issues cleared after 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();
debugPrint('[NetworkStatus] Waiting for data...');
}
// Set timeout to show appropriate status after reasonable time
_waitingTimer?.cancel();
_waitingTimer = Timer(const Duration(seconds: 10), () {
_isWaitingForData = false;
// If in offline mode, this is "no data" not "timed out"
if (AppState.instance.offlineMode) {
_hasNoData = true;
debugPrint('[NetworkStatus] No offline data available (timeout in offline mode)');
} else {
_isTimedOut = true;
debugPrint('[NetworkStatus] Data request timed out (online mode)');
}
notifyListeners();
});
}
/// Clear waiting/timeout/no-data status when data arrives
void clearWaiting() {
if (_isWaitingForData || _isTimedOut || _hasNoData) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
notifyListeners();
debugPrint('[NetworkStatus] Waiting/timeout/no-data status cleared - data arrived');
}
}
/// 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();
}
}

View File

@@ -6,23 +6,62 @@ 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);
_initialized = true;
}
Future<Directory> getOfflineAreaDir() async {
final dir = await getApplicationDocumentsDirectory();
@@ -56,7 +95,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 +129,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 +171,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,
@@ -197,73 +207,26 @@ class OfflineAreaService {
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 +236,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 +254,7 @@ class OfflineAreaService {
_areas.remove(area);
await saveAreasToDisk();
if (area.isPermanent) {
_ensureAndAutoDownloadWorldArea();
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
}
}
@@ -304,4 +267,6 @@ class OfflineAreaService {
_areas.remove(area);
await saveAreasToDisk();
}
}

View File

@@ -0,0 +1,191 @@
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 {
// Calculate tiles to download
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)) {
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
static Future<bool> _downloadSingleTile(List<int> tile, String directory) async {
try {
final bytes = await MapDataProvider().getTile(
z: tile[0],
x: tile[1],
y: tile[2],
source: MapSource.remote,
);
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.enabledProfiles,
);
area.cameras = cameras;
await OfflineAreaDownloader.saveCameras(cameras, directory);
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds');
}
/// 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()));
}
}

View File

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

View File

@@ -56,15 +56,30 @@ List<int> latLonToTile(double lat, double lon, int zoom) {
return [xtile, ytile];
}
int findDynamicMinZoom(LatLngBounds bounds, {int maxSearchZoom = 19}) {
for (int z = 1; z <= maxSearchZoom; z++) {
final swTile = latLonToTile(bounds.southWest.latitude, bounds.southWest.longitude, z);
final neTile = latLonToTile(bounds.northEast.latitude, bounds.northEast.longitude, z);
if (swTile[0] != neTile[0] || swTile[1] != neTile[1]) {
return z - 1 > 0 ? z - 1 : 1;
}
}
return maxSearchZoom;
/// 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() {

View File

@@ -0,0 +1,111 @@
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,
}) 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
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,
);
areas.insert(0, world);
}
// 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,
}) 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)
downloadArea(
id: world.id,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
name: world.name,
);
}
}
}

View File

@@ -0,0 +1,131 @@
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 {
// Only intercept tile requests to OSM (for now - other providers pass through)
if (request.url.host == 'tile.openstreetmap.org') {
return _handleTileRequest(request);
}
// Pass through all other requests (Google, Mapbox, etc.)
return _inner.send(request);
}
Future<http.StreamedResponse> _handleTileRequest(http.BaseRequest request) async {
final pathSegments = request.url.pathSegments;
// Parse z/x/y from URL like: /15/5242/12666.png
if (pathSegments.length == 3) {
final z = int.tryParse(pathSegments[0]);
final x = int.tryParse(pathSegments[1]);
final yPng = pathSegments[2];
final y = int.tryParse(yPng.replaceAll('.png', ''));
if (z != null && x != null && y != null) {
return _getTile(z, x, y);
}
}
// Malformed tile URL - pass through to OSM
return _inner.send(request);
}
Future<http.StreamedResponse> _getTile(int z, int x, int y) async {
try {
// First try to get tile from offline storage
final localTileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.local);
debugPrint('[SimpleTileService] Serving tile $z/$x/$y from offline storage');
// Clear waiting status - we got data
NetworkStatus.instance.clearWaiting();
// Serve offline tile with proper cache headers
return http.StreamedResponse(
Stream.value(localTileBytes),
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) {
// No offline tile available
debugPrint('[SimpleTileService] No offline tile for $z/$x/$y');
// Check if we're in offline mode
if (AppState.instance.offlineMode) {
debugPrint('[SimpleTileService] Offline mode - not attempting OSM fetch for $z/$x/$y');
// Report that we couldn't serve this tile offline
NetworkStatus.instance.reportOfflineMiss();
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Tile not available offline',
);
}
// We're online - try OSM with proper error handling
debugPrint('[SimpleTileService] Online mode - trying OSM for $z/$x/$y');
try {
final response = await _inner.send(http.Request('GET', Uri.parse('https://tile.openstreetmap.org/$z/$x/$y.png')));
// Clear waiting status on successful network tile
if (response.statusCode == 200) {
NetworkStatus.instance.clearWaiting();
}
return response;
} catch (networkError) {
debugPrint('[SimpleTileService] OSM request failed for $z/$x/$y: $networkError');
// Return 404 instead of throwing - let flutter_map handle gracefully
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Network tile unavailable: $networkError',
);
}
}
}
/// 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
View 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();
}
}

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

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

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.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 _tileProviderPrefsKey = 'tile_provider';
static const String _legacyTestModePrefsKey = 'test_mode';
bool _offlineMode = false;
int _maxCameras = 250;
UploadMode _uploadMode = UploadMode.simulate;
TileProviderType _tileProvider = TileProviderType.osmStreet;
// Getters
bool get offlineMode => _offlineMode;
int get maxCameras => _maxCameras;
UploadMode get uploadMode => _uploadMode;
TileProviderType get tileProvider => _tileProvider;
// 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 provider
if (prefs.containsKey(_tileProviderPrefsKey)) {
final idx = prefs.getInt(_tileProviderPrefsKey) ?? 0;
if (idx >= 0 && idx < TileProviderType.values.length) {
_tileProvider = TileProviderType.values[idx];
}
}
}
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();
}
Future<void> setTileProvider(TileProviderType provider) async {
_tileProvider = provider;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_tileProviderPrefsKey, provider.index);
notifyListeners();
}
}

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

View File

@@ -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
@@ -35,24 +46,24 @@ class CameraProviderWithCache extends ChangeNotifier {
// Debounce rapid panning/zooming
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
final isOffline = AppState.instance.offlineMode;
if (!isOffline) {
try {
final fresh = await MapDataProvider().getCameras(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
source: MapSource.remote,
);
if (fresh.isNotEmpty) {
CameraCache.instance.addOrUpdate(fresh);
notifyListeners();
}
} catch (e) {
debugPrint('[CameraProviderWithCache] Overpass fetch failed: $e');
// Cache already holds whatever is available for the view
try {
// Use MapSource.auto to handle both offline and online modes appropriately
final fresh = await MapDataProvider().getCameras(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
source: MapSource.auto,
);
if (fresh.isNotEmpty) {
CameraCache.instance.addOrUpdate(fresh);
// Clear waiting status when camera data arrives
NetworkStatus.instance.clearWaiting();
notifyListeners();
}
} // else, only cache is used
} catch (e) {
debugPrint('[CameraProviderWithCache] Camera fetch failed: $e');
// Cache already holds whatever is available for the view
}
});
}
@@ -61,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;
}
}

View File

@@ -0,0 +1,270 @@
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";
// 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! 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'),
),
],
);
}
}

View 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';
/// 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,
),
);
}
}
/// 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: 24,
height: 24,
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;
}
}

View File

@@ -0,0 +1,86 @@
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,
isPending: _isPendingUpload(n),
))
);
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,
}) {
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,
);
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
/// Widget that renders all map overlay UI elements
class MapOverlays extends StatelessWidget {
final MapController mapController;
final UploadMode uploadMode;
final AddCameraSession? session;
const MapOverlays({
super.key,
required this.mapController,
required this.uploadMode,
this.session,
});
@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
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),
),
),
),
// Fixed pin when adding camera
if (session != null)
IgnorePointer(
child: Center(
child: Transform.translate(
offset: const Offset(0, kAddPinYOffset),
child: const Icon(Icons.place, size: 40, color: Colors.redAccent),
),
),
),
],
);
}
}

View File

@@ -1,73 +1,25 @@
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 '../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) {
return GestureDetector(
onTap: _onTap,
onDoubleTap: _onDoubleTap,
child: const Icon(Icons.videocam, color: 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;
@@ -82,31 +34,39 @@ 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;
@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();
});
@@ -115,8 +75,10 @@ class _MapViewState extends State<MapView> {
@override
void dispose() {
_positionSub?.cancel();
_debounce.dispose();
_cameraDebounce.dispose();
_tileDebounce.dispose();
_cameraProvider.removeListener(_onCamerasUpdated);
_tileHttpClient.close();
super.dispose();
}
@@ -124,6 +86,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;
@@ -191,13 +161,70 @@ 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 based on selected tile provider
Widget _buildTileLayer(AppState appState) {
final providerConfig = TileProviders.getByType(appState.tileProvider);
if (providerConfig == null) {
// Fallback to OSM if somehow we have an invalid provider
return TileLayer(
urlTemplate: TileProviders.osmStreet.urlTemplate,
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
),
);
}
// For OSM tiles, use our custom HTTP client for offline/online routing
if (providerConfig.type == TileProviderType.osmStreet) {
return TileLayer(
urlTemplate: providerConfig.urlTemplate,
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
),
);
}
// For other providers, use standard HTTP client (no offline support yet)
return TileLayer(
urlTemplate: providerConfig.urlTemplate,
userAgentPackageName: 'com.stopflock.flock_map_app',
additionalOptions: {
'attribution': providerConfig.attribution,
},
);
}
@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((_) {
// Force display refresh first (for immediate UI update)
_cameraProvider.refreshDisplay();
// Then fetch new cameras for newly enabled profiles
_refreshCamerasFromProvider();
});
}
// Seed addmode target once, after first controller center is available.
if (session != null && session.target == null) {
@@ -222,34 +249,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)),
];
return Stack(
children: [
PolygonLayer(polygons: overlays),
@@ -262,7 +274,7 @@ class _MapViewState extends State<MapView> {
return Stack(
children: [
FlutterMap(
key: ValueKey(appState.offlineMode),
key: ValueKey('map_offline_${appState.offlineMode}_provider_${appState.tileProvider.name}'),
mapController: _controller,
options: MapOptions(
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),
@@ -274,39 +286,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(() {
debugPrint('[MapView] Zoom change detected - clearing stale tile requests');
_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(
@@ -320,110 +323,17 @@ 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,
),
// 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) {
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);
return Polygon(
points: [origin, left, right, origin],
color: Colors.redAccent.withOpacity(0.25),
borderColor: Colors.redAccent,
borderStrokeWidth: 1,
);
}
}

View File

@@ -0,0 +1,98 @@
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 offline data';
icon = Icons.cloud_off;
color = Colors.grey;
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,
),
),
],
),
),
);
},
),
);
}
}

View File

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

42
macos/Podfile Normal file
View File

@@ -0,0 +1,42 @@
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end

View File

@@ -0,0 +1,801 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXAggregateTarget section */
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
isa = PBXAggregateTarget;
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
buildPhases = (
33CC111E2044C6BF0003C045 /* ShellScript */,
);
dependencies = (
);
name = "Flutter Assemble";
productName = FLX;
};
/* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
4ABD443377DEEA0E6ABDF041 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14DE83B4CECC3B5785F26339 /* Pods_RunnerTests.framework */; };
6C9CBD6E8FB459527EFAC650 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 787E01B101B3B87713551F4B /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC10EC2044A3C60003C045;
remoteInfo = Runner;
};
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
remoteInfo = FLX;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
33CC110E2044A8840003C045 /* Bundle Framework */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Bundle Framework";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
14DE83B4CECC3B5785F26339 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* flock_map_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flock_map_app.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
56FF786478D8CA9C8C96AA65 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
787E01B101B3B87713551F4B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
A5843D2F351DECB4BC4BEAAB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
AB51B320061555571937E868 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
BD24094E8F1C40303547AFDE /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
D27A8C599345B70419381EFA /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
E7DC9C3D113BA5E9CC61AC3A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
331C80D2294CF70F00263BE5 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4ABD443377DEEA0E6ABDF041 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EA2044A3C60003C045 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6C9CBD6E8FB459527EFAC650 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C80D6294CF71000263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C80D7294CF71000263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
33BA886A226E78AF003329D5 /* Configs */ = {
isa = PBXGroup;
children = (
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
);
path = Configs;
sourceTree = "<group>";
};
33CC10E42044A3C60003C045 = {
isa = PBXGroup;
children = (
33FAB671232836740065AC1E /* Runner */,
33CEB47122A05771004F2AC0 /* Flutter */,
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
EDD70D25756DD7FE6827E9B4 /* Pods */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* flock_map_app.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
33CC11242044D66E0003C045 /* Resources */ = {
isa = PBXGroup;
children = (
33CC10F22044A3C60003C045 /* Assets.xcassets */,
33CC10F42044A3C60003C045 /* MainMenu.xib */,
33CC10F72044A3C60003C045 /* Info.plist */,
);
name = Resources;
path = ..;
sourceTree = "<group>";
};
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
);
path = Flutter;
sourceTree = "<group>";
};
33FAB671232836740065AC1E /* Runner */ = {
isa = PBXGroup;
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
33BA886A226E78AF003329D5 /* Configs */,
);
path = Runner;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
787E01B101B3B87713551F4B /* Pods_Runner.framework */,
14DE83B4CECC3B5785F26339 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
EDD70D25756DD7FE6827E9B4 /* Pods */ = {
isa = PBXGroup;
children = (
E7DC9C3D113BA5E9CC61AC3A /* Pods-Runner.debug.xcconfig */,
D27A8C599345B70419381EFA /* Pods-Runner.release.xcconfig */,
56FF786478D8CA9C8C96AA65 /* Pods-Runner.profile.xcconfig */,
AB51B320061555571937E868 /* Pods-RunnerTests.debug.xcconfig */,
BD24094E8F1C40303547AFDE /* Pods-RunnerTests.release.xcconfig */,
A5843D2F351DECB4BC4BEAAB /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C80D4294CF70F00263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
E7C7AB9BE4EB75C78762778A /* [CP] Check Pods Manifest.lock */,
331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C80DA294CF71000263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
33CC10EC2044A3C60003C045 /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
95B0D858ED0CCD2F613E2F77 /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
910E141E4DDEB328B0A70A97 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* flock_map_app.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C80D4294CF70F00263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 33CC10EC2044A3C60003C045;
};
33CC10EC2044A3C60003C045 = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 1;
};
};
};
33CC111A2044C6BA0003C045 = {
CreatedOnToolsVersion = 9.2;
ProvisioningStyle = Manual;
};
};
};
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
33CC10EC2044A3C60003C045 /* Runner */,
331C80D4294CF70F00263BE5 /* RunnerTests */,
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C80D3294CF70F00263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EB2044A3C60003C045 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
};
33CC111E2044C6BF0003C045 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
Flutter/ephemeral/FlutterInputs.xcfilelist,
);
inputPaths = (
Flutter/ephemeral/tripwire,
);
outputFileListPaths = (
Flutter/ephemeral/FlutterOutputs.xcfilelist,
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
910E141E4DDEB328B0A70A97 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
95B0D858ED0CCD2F613E2F77 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E7C7AB9BE4EB75C78762778A /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C80D1294CF70F00263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10E92044A3C60003C045 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC10EC2044A3C60003C045 /* Runner */;
targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
};
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
isa = PBXVariantGroup;
children = (
33CC10F52044A3C60003C045 /* Base */,
);
name = MainMenu.xib;
path = Runner;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AB51B320061555571937E868 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
};
name = Debug;
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BD24094E8F1C40303547AFDE /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
};
name = Release;
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = A5843D2F351DECB4BC4BEAAB /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flock_map_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flock_map_app";
};
name = Profile;
};
338D0CE9231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Profile;
};
338D0CEA231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Profile;
};
338D0CEB231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Profile;
};
33CC10F92044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
33CC10FA2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
33CC10FC2044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
33CC10FD2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Release;
};
33CC111C2044C6BA0003C045 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
};
33CC111D2044C6BA0003C045 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C80DB294CF71000263BE5 /* Debug */,
331C80DC294CF71000263BE5 /* Release */,
331C80DD294CF71000263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10F92044A3C60003C045 /* Debug */,
33CC10FA2044A3C60003C045 /* Release */,
338D0CE9231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10FC2044A3C60003C045 /* Debug */,
33CC10FD2044A3C60003C045 /* Release */,
338D0CEA231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC111C2044C6BA0003C045 /* Debug */,
33CC111D2044C6BA0003C045 /* Release */,
338D0CEB231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}