Compare commits

..

47 Commits

Author SHA1 Message Date
stopflock
ebf7f93dd5 deflock-ify icons 2025-08-26 20:52:03 -05:00
stopflock
d56a6e8e7c more status indicator improvements 2025-08-26 19:37:27 -05:00
stopflock
84e057c986 fix camera caching and filtering from offline areas - in theory 2025-08-26 19:17:45 -05:00
stopflock
c1e25ec5b1 improve network status indicator 2025-08-26 18:35:52 -05:00
stopflock
a3edcfc2de finalize code paths for offline areas, caching, in light of multiple tile providers 2025-08-26 17:52:14 -05:00
stopflock
17c9ee0c5c idk but it's better - cache busting works. 2025-08-24 19:38:42 -05:00
stopflock
9e620ef9e4 Consolidate / dedupe some code 2025-08-24 17:46:58 -05:00
stopflock
bedfdcca6e fetch tiles from selected provider 2025-08-24 16:13:50 -05:00
stopflock
f1c73a5e55 settings area metadata, offline areas support for tile types 2025-08-24 15:31:02 -05:00
stopflock
4ee783793f genericize tiles_from submodule, simpletileservice. 2025-08-24 15:08:36 -05:00
stopflock
aada97295b move layer selection to map page, improve settings, dynamic attribution 2025-08-24 14:41:29 -05:00
stopflock
813f4f69ea cusstom providers settings 2025-08-24 14:18:04 -05:00
stopflock
2d615128aa generic providers 2025-08-24 14:08:15 -05:00
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
stopflock
e2830a189b Merge pull request #11 from stopflock/camera-edits
Everything But Camera Edits
2025-08-17 12:16:42 -05:00
stopflock
d9beeb9d83 Revert "Move tag camera and download buttons to a bar instead of floating"
This reverts commit 6aaddb4fe2.
2025-08-17 12:13:57 -05:00
stopflock
446b70eaff move buttons to fake bottom bar 2025-08-17 11:57:51 -05:00
stopflock
2829730705 add a really dumb script to build apk and ipa locally 2025-08-17 11:57:43 -05:00
44 changed files with 4881 additions and 1233 deletions

16
do_builds.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
appver=$(cat lib/dev_config.dart | grep "kClientVersion" | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ")
echo
echo "Building app version ${appver}..."
flutter build ios --no-codesign
flutter build apk
echo
echo "Converting .app to .ipa..."
./app2ipa.sh build/ios/iphoneos/Runner.app
echo
echo "Moving files..."
cp build/app/outputs/flutter-apk/app-release.apk ../flockmap_v${appver}.apk
mv Runner.ipa ../flockmap_v${appver}.ipa
echo
echo "Done."

View File

@@ -1,324 +1,142 @@
import 'dart:convert';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/camera_profile.dart';
import 'models/pending_upload.dart';
import '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
export 'state/settings_state.dart' show UploadMode;
export 'state/session_state.dart' show AddCameraSession;
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
static late AppState instance;
// State modules
late final AuthState _authState;
late final ProfileState _profileState;
late final SessionState _sessionState;
late final SettingsState _settingsState;
late final UploadQueueState _uploadQueueState;
bool _isInitialized = false;
AppState() {
instance = this;
_authState = AuthState();
_profileState = ProfileState();
_sessionState = SessionState();
_settingsState = SettingsState();
_uploadQueueState = UploadQueueState();
// Set up state change listeners
_authState.addListener(_onStateChanged);
_profileState.addListener(_onStateChanged);
_sessionState.addListener(_onStateChanged);
_settingsState.addListener(_onStateChanged);
_uploadQueueState.addListener(_onStateChanged);
_init();
}
// ------------------- Offline Mode -------------------
static const String _offlineModePrefsKey = 'offline_mode';
bool _offlineMode = false;
bool get offlineMode => _offlineMode;
Future<void> setOfflineMode(bool enabled) async {
final wasOffline = _offlineMode;
_offlineMode = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_offlineModePrefsKey, enabled);
if (wasOffline && !enabled) {
// Transitioning from offline to online: clear tile cache!
TileProviderWithCache.clearCache();
_startUploader(); // Resume upload queue processing as we leave offline mode
}
notifyListeners();
}
final _auth = AuthService();
String? _username;
bool _isInitialized = false;
// Getters that delegate to individual state modules
bool get isInitialized => _isInitialized;
// Auth state
bool get isLoggedIn => _authState.isLoggedIn;
String get username => _authState.username;
// Profile state
List<CameraProfile> get profiles => _profileState.profiles;
List<CameraProfile> get enabledProfiles => _profileState.enabledProfiles;
bool isEnabled(CameraProfile p) => _profileState.isEnabled(p);
// Session state
AddCameraSession? get session => _sessionState.session;
// Settings state
bool get offlineMode => _settingsState.offlineMode;
int get maxCameras => _settingsState.maxCameras;
UploadMode get uploadMode => _settingsState.uploadMode;
// Tile provider state
List<TileProvider> get tileProviders => _settingsState.tileProviders;
TileType? get selectedTileType => _settingsState.selectedTileType;
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
final List<CameraProfile> _profiles = [];
final Set<CameraProfile> _enabled = {};
static const String _enabledPrefsKey = 'enabled_profiles';
static const String _maxCamerasPrefsKey = 'max_cameras';
// Upload queue state
int get pendingCount => _uploadQueueState.pendingCount;
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
// Maximum number of cameras fetched/drawn
int _maxCameras = 250;
int get maxCameras => _maxCameras;
set maxCameras(int n) {
if (n < 10) n = 10; // minimum
_maxCameras = n;
SharedPreferences.getInstance().then((prefs) {
prefs.setInt(_maxCamerasPrefsKey, n);
});
void _onStateChanged() {
notifyListeners();
}
// Upload mode: production, sandbox, or simulate (in-memory, no uploads)
UploadMode _uploadMode = UploadMode.simulate;
static const String _uploadModePrefsKey = 'upload_mode';
UploadMode get uploadMode => _uploadMode;
Future<void> setUploadMode(UploadMode mode) async {
_uploadMode = mode;
// Update AuthService to match new mode
_auth.setUploadMode(mode);
// Refresh user display for active mode, validating token
try {
if (await _auth.isLoggedIn()) {
print('AppState: Switching mode, token exists; validating...');
final isValid = await validateToken();
if (isValid) {
print("AppState: Switching mode; fetching username for $mode...");
_username = await _auth.login();
if (_username != null) {
print("AppState: Switched mode, now logged in as $_username");
} else {
print('AppState: Switched mode but failed to retrieve username');
}
} else {
print('AppState: Switching mode, token invalid—auto-logout.');
await logout(); // This clears _username also.
}
} else {
_username = null;
print("AppState: Mode change: not logged in in $mode");
}
} catch (e) {
_username = null;
print("AppState: Mode change user restoration error: $e");
}
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_uploadModePrefsKey, mode.index);
print("AppState: Upload mode set to $mode");
notifyListeners();
}
// For legacy bool test mode
static const String _legacyTestModePrefsKey = 'test_mode';
AddCameraSession? _session;
AddCameraSession? get session => _session;
final List<PendingUpload> _queue = [];
Timer? _uploadTimer;
bool get isLoggedIn => _username != null;
String get username => _username ?? '';
// ---------- Init ----------
Future<void> _init() async {
// Initialize profiles: built-in + custom
_profiles.add(CameraProfile.alpr());
_profiles.addAll(await ProfileService().load());
// Load enabled profile IDs and upload/test mode from prefs
final prefs = await SharedPreferences.getInstance();
final enabledIds = prefs.getStringList(_enabledPrefsKey);
if (enabledIds != null && enabledIds.isNotEmpty) {
// Restore enabled profiles by id
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
} else {
// By default, all are enabled
_enabled.addAll(_profiles);
}
// Upload mode loading (including migration from old test_mode bool)
if (prefs.containsKey(_uploadModePrefsKey)) {
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
if (idx >= 0 && idx < UploadMode.values.length) {
_uploadMode = UploadMode.values[idx];
}
} else if (prefs.containsKey(_legacyTestModePrefsKey)) {
// migrate legacy test_mode (true->simulate, false->prod)
final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false;
_uploadMode = legacy ? UploadMode.simulate : UploadMode.production;
await prefs.remove(_legacyTestModePrefsKey);
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
}
// Max cameras
if (prefs.containsKey(_maxCamerasPrefsKey)) {
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
}
// Offline mode loading
if (prefs.containsKey(_offlineModePrefsKey)) {
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
}
// Ensure AuthService follows loaded mode
_auth.setUploadMode(_uploadMode);
print('AppState: AuthService mode now updated to $_uploadMode');
await _loadQueue();
// Initialize all state modules
await _settingsState.init();
await _profileState.init();
await _uploadQueueState.init();
await _authState.init(_settingsState.uploadMode);
// Check if we're already logged in and get username
try {
if (await _auth.isLoggedIn()) {
print('AppState: User appears to be logged in, fetching username...');
_username = await _auth.login();
if (_username != null) {
print("AppState: Successfully retrieved username: $_username");
} else {
print('AppState: Failed to retrieve username despite being logged in');
}
} else {
print('AppState: User is not logged in');
}
} catch (e) {
print("AppState: Error during auth initialization: $e");
}
// Initialize OfflineAreaService to ensure offline areas are loaded
await OfflineAreaService().ensureInitialized();
// Start uploader if conditions are met
_startUploader();
_isInitialized = true;
notifyListeners();
}
// ---------- Auth ----------
// ---------- Auth Methods ----------
Future<void> login() async {
try {
print('AppState: Starting login process...');
_username = await _auth.login();
if (_username != null) {
print("AppState: Login successful for user: $_username");
} else {
print('AppState: Login failed - no username returned');
}
} catch (e) {
print("AppState: Login error: $e");
_username = null;
}
notifyListeners();
await _authState.login();
}
Future<void> logout() async {
await _auth.logout();
_username = null;
notifyListeners();
await _authState.logout();
}
// Add method to refresh auth state
Future<void> refreshAuthState() async {
try {
print('AppState: Refreshing auth state...');
if (await _auth.isLoggedIn()) {
print('AppState: Token exists, fetching username...');
_username = await _auth.login();
if (_username != null) {
print("AppState: Auth refresh successful: $_username");
} else {
print('AppState: Auth refresh failed - no username');
}
} else {
print('AppState: No valid token found');
_username = null;
}
} catch (e) {
print("AppState: Auth refresh error: $e");
_username = null;
}
notifyListeners();
await _authState.refreshAuthState();
}
// Force a completely fresh login (clears stored tokens)
Future<void> forceLogin() async {
try {
print('AppState: Starting forced fresh login...');
_username = await _auth.forceLogin();
if (_username != null) {
print("AppState: Forced login successful: $_username");
} else {
print('AppState: Forced login failed - no username returned');
}
} catch (e) {
print("AppState: Forced login error: $e");
_username = null;
}
notifyListeners();
await _authState.forceLogin();
}
// Validate current token/credentials
Future<bool> validateToken() async {
try {
return await _auth.isLoggedIn();
} catch (e) {
print("AppState: Token validation error: $e");
return false;
}
return await _authState.validateToken();
}
// ---------- Profiles ----------
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
bool isEnabled(CameraProfile p) => _enabled.contains(p);
List<CameraProfile> get enabledProfiles =>
_profiles.where(isEnabled).toList(growable: false);
// ---------- Profile Methods ----------
void toggleProfile(CameraProfile p, bool e) {
if (e) {
_enabled.add(p);
} else {
_enabled.remove(p);
// Safety: Always have at least one enabled profile
if (_enabled.isEmpty) {
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
_enabled.add(builtIn);
}
}
_saveEnabledProfiles();
notifyListeners();
_profileState.toggleProfile(p, e);
}
void addOrUpdateProfile(CameraProfile p) {
final idx = _profiles.indexWhere((x) => x.id == p.id);
if (idx >= 0) {
_profiles[idx] = p;
} else {
_profiles.add(p);
_enabled.add(p);
_saveEnabledProfiles();
}
ProfileService().save(_profiles);
notifyListeners();
_profileState.addOrUpdateProfile(p);
}
void deleteProfile(CameraProfile p) {
if (p.builtin) return;
_enabled.remove(p);
_profiles.removeWhere((x) => x.id == p.id);
// Safety: Always have at least one enabled profile
if (_enabled.isEmpty) {
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
_enabled.add(builtIn);
}
_saveEnabledProfiles();
ProfileService().save(_profiles);
notifyListeners();
_profileState.deleteProfile(p);
}
// Save enabled profile IDs to disk
Future<void> _saveEnabledProfiles() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_enabledPrefsKey,
_enabled.map((p) => p.id).toList(),
);
}
// ---------- Addcamera session ----------
// ---------- Session Methods ----------
void startAddSession() {
_session = AddCameraSession(profile: enabledProfiles.first);
notifyListeners();
_sessionState.startAddSession(enabledProfiles);
}
void updateSession({
@@ -326,148 +144,96 @@ class AppState extends ChangeNotifier {
CameraProfile? profile,
LatLng? target,
}) {
if (_session == null) return;
bool dirty = false;
if (directionDeg != null && directionDeg != _session!.directionDegrees) {
_session!.directionDegrees = directionDeg;
dirty = true;
}
if (profile != null && profile != _session!.profile) {
_session!.profile = profile;
dirty = true;
}
if (target != null) {
_session!.target = target;
dirty = true;
}
if (dirty) notifyListeners(); // <-- slider & map update
_sessionState.updateSession(
directionDeg: directionDeg,
profile: profile,
target: target,
);
}
void cancelSession() {
_session = null;
notifyListeners();
_sessionState.cancelSession();
}
void commitSession() {
if (_session?.target == null) return;
_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 ----------
/// Select a tile type by ID
Future<void> setSelectedTileType(String tileTypeId) async {
await _settingsState.setSelectedTileType(tileTypeId);
}
/// Add or update a tile provider
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
await _settingsState.addOrUpdateTileProvider(provider);
}
/// Delete a tile provider
Future<void> deleteTileProvider(String providerId) async {
await _settingsState.deleteTileProvider(providerId);
}
// ---------- Queue Methods ----------
void clearQueue() {
print("AppState: Clearing upload queue (${_queue.length} items)");
_queue.clear();
_saveQueue();
notifyListeners();
_uploadQueueState.clearQueue();
}
void removeFromQueue(PendingUpload upload) {
print("AppState: Removing upload from queue: ${upload.coord}");
_queue.remove(upload);
_saveQueue();
notifyListeners();
_uploadQueueState.removeFromQueue(upload);
}
// Retry a failed upload (clear error and attempts, then try uploading again)
void retryUpload(PendingUpload upload) {
upload.error = false;
upload.attempts = 0;
_saveQueue();
notifyListeners();
_uploadQueueState.retryUpload(upload);
_startUploader(); // resume uploader if not busy
}
// ---------- Private Methods ----------
void _startUploader() {
_uploadQueueState.startUploader(
offlineMode: offlineMode,
uploadMode: uploadMode,
getAccessToken: _authState.getAccessToken,
);
}
@override
void dispose() {
_authState.removeListener(_onStateChanged);
_profileState.removeListener(_onStateChanged);
_sessionState.removeListener(_onStateChanged);
_settingsState.removeListener(_onStateChanged);
_uploadQueueState.removeListener(_onStateChanged);
_uploadQueueState.dispose();
super.dispose();
}
}

View File

@@ -1,4 +1,6 @@
// lib/dev_config.dart
import 'package:flutter/material.dart';
/// Developer/build-time configuration for global/non-user-tunable constants.
const int kWorldMinZoom = 1;
const int kWorldMaxZoom = 5;
@@ -7,20 +9,24 @@ const int kWorldMaxZoom = 5;
const double kTileEstimateKb = 25.0;
// Direction cone for map view
const double kDirectionConeHalfAngle = 20.0; // degrees
const double kDirectionConeBaseLength = 0.0012; // multiplier
const double kDirectionConeHalfAngle = 30.0; // degrees
const double kDirectionConeBaseLength = 0.001; // multiplier
const Color kDirectionConeColor = Color(0xFF111111); // FOV cone color
// Add Camera pin vertical offset (for pin tip to match coordinate on map)
const double kAddPinYOffset = -16.0;
// Margin (bottom) for positioning the floating bottom button bar
const double kBottomButtonBarMargin = 4.0;
// Bottom overlay vertical positions (distance from bottom of screen)
const double kAttributionBottom = 5.0;
const double kScaleBarBottom = 40.0;
const double kZoomIndicatorBottom = 70.0;
// Map overlay (attribution, scale bar, zoom) vertical offset from bottom edge
const double kAttributionBottomOffset = 110.0;
const double kZoomIndicatorBottomOffset = 142.0;
const double kScaleBarBottomOffset = 170.0;
// Add Camera icon vertical offset (no offset needed since circle is centered)
const double kAddPinYOffset = 0.0;
// Client name and version for OSM uploads ("created_by" tag)
const String kClientName = 'FlockMap';
const String kClientVersion = '0.8.1';
const String kClientVersion = '0.8.10';
// Marker/camera interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning
@@ -38,3 +44,15 @@ const int kTileFetchJitter3Ms = 5000;
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
const int kMaxUserDownloadZoomSpan = 7;
// Download area limits and constants
const int kMaxReasonableTileCount = 20000;
const int kAbsoluteMaxZoom = 19;
// Camera icon configuration
const double kCameraIconDiameter = 20.0;
const double kCameraRingThickness = 4.0;
const double kCameraDotOpacity = 0.4; // Opacity for the grey dot interior
const Color kCameraRingColorReal = Color(0xC43F55F3); // Real cameras from OSM - blue
const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add camera mock point - white
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending cameras - purple

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,214 @@
import 'dart:convert';
import 'dart:typed_data';
/// A specific tile type within a provider
class TileType {
final String id;
final String name;
final String urlTemplate;
final String attribution;
final Uint8List? previewTile; // Single tile image data for preview
const TileType({
required this.id,
required this.name,
required this.urlTemplate,
required this.attribution,
this.previewTile,
});
/// Create URL for a specific tile, replacing template variables
String getTileUrl(int z, int x, int y, {String? apiKey}) {
String url = urlTemplate
.replaceAll('{z}', z.toString())
.replaceAll('{x}', x.toString())
.replaceAll('{y}', y.toString());
if (apiKey != null && apiKey.isNotEmpty) {
url = url.replaceAll('{api_key}', apiKey);
}
return url;
}
/// Check if this tile type needs an API key
bool get requiresApiKey => urlTemplate.contains('{api_key}');
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'urlTemplate': urlTemplate,
'attribution': attribution,
'previewTile': previewTile != null ? base64Encode(previewTile!) : null,
};
static TileType fromJson(Map<String, dynamic> json) => TileType(
id: json['id'],
name: json['name'],
urlTemplate: json['urlTemplate'],
attribution: json['attribution'],
previewTile: json['previewTile'] != null
? base64Decode(json['previewTile'])
: null,
);
TileType copyWith({
String? id,
String? name,
String? urlTemplate,
String? attribution,
Uint8List? previewTile,
}) => TileType(
id: id ?? this.id,
name: name ?? this.name,
urlTemplate: urlTemplate ?? this.urlTemplate,
attribution: attribution ?? this.attribution,
previewTile: previewTile ?? this.previewTile,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TileType && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
/// A tile provider containing multiple tile types
class TileProvider {
final String id;
final String name;
final String? apiKey;
final List<TileType> tileTypes;
const TileProvider({
required this.id,
required this.name,
this.apiKey,
required this.tileTypes,
});
/// Check if this provider is usable (has API key if any tile types need it)
bool get isUsable {
final needsKey = tileTypes.any((type) => type.requiresApiKey);
return !needsKey || (apiKey != null && apiKey!.isNotEmpty);
}
/// Get available tile types (those that don't need API key or have one)
List<TileType> get availableTileTypes {
return tileTypes.where((type) => !type.requiresApiKey || isUsable).toList();
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'apiKey': apiKey,
'tileTypes': tileTypes.map((type) => type.toJson()).toList(),
};
static TileProvider fromJson(Map<String, dynamic> json) => TileProvider(
id: json['id'],
name: json['name'],
apiKey: json['apiKey'],
tileTypes: (json['tileTypes'] as List)
.map((typeJson) => TileType.fromJson(typeJson))
.toList(),
);
TileProvider copyWith({
String? id,
String? name,
String? apiKey,
List<TileType>? tileTypes,
}) => TileProvider(
id: id ?? this.id,
name: name ?? this.name,
apiKey: apiKey ?? this.apiKey,
tileTypes: tileTypes ?? this.tileTypes,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TileProvider && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
/// Factory for creating default tile providers
class DefaultTileProviders {
/// Create the default set of tile providers
static List<TileProvider> createDefaults() {
return [
TileProvider(
id: 'openstreetmap',
name: 'OpenStreetMap',
tileTypes: [
TileType(
id: 'osm_street',
name: 'Street Map',
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
),
],
),
TileProvider(
id: 'google',
name: 'Google',
tileTypes: [
TileType(
id: 'google_hybrid',
name: 'Satellite + Roads',
urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
attribution: '© Google',
),
TileType(
id: 'google_satellite',
name: 'Satellite Only',
urlTemplate: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
attribution: '© Google',
),
TileType(
id: 'google_roadmap',
name: 'Road Map',
urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
attribution: '© Google',
),
],
),
TileProvider(
id: 'esri',
name: 'Esri',
tileTypes: [
TileType(
id: 'esri_satellite',
name: 'Satellite Imagery',
urlTemplate: 'https://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png',
attribution: '© Esri © Maxar',
),
],
),
TileProvider(
id: 'mapbox',
name: 'Mapbox',
tileTypes: [
TileType(
id: 'mapbox_satellite',
name: 'Satellite',
urlTemplate: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
attribution: '© Mapbox © Maxar',
),
TileType(
id: 'mapbox_streets',
name: 'Streets',
urlTemplate: 'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/{z}/{x}/{y}?access_token={api_key}',
attribution: '© Mapbox © OpenStreetMap',
),
],
),
];
}
}

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,18 +19,20 @@ 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
final session = appState.session!; // guaranteed nonnull now
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) => AddCameraSheet(session: session),
_scaffoldKey.currentState!.showBottomSheet(
(ctx) => AddCameraSheet(session: session),
);
}
@@ -42,7 +42,6 @@ class _HomeScreenState extends State<HomeScreen> {
return MultiProvider(
providers: [
ChangeNotifierProvider<TileProviderWithCache>(create: (_) => TileProviderWithCache()),
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
],
child: Scaffold(
@@ -53,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),
@@ -61,238 +66,68 @@ class _HomeScreenState extends State<HomeScreen> {
),
],
),
body: MapView(
controller: _mapController,
followMe: _followMe,
onUserGesture: () {
if (_followMe) setState(() => _followMe = false);
},
),
bottomNavigationBar: BottomAppBar(
elevation: 10,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.add_location_alt),
label: const Text('Tag Camera'),
onPressed: () {
if (appState.session == null) {
_openAddCameraSheet();
}
},
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 48),
textStyle: const TextStyle(fontSize: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download_for_offline),
label: const Text('Download'),
onPressed: appState.session == null
? () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController),
)
: null, // Disabled while camera sheet active
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 48),
textStyle: const TextStyle(fontSize: 16),
),
),
),
],
),
),
),
),
);
}
}
// --- 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,
body: Stack(
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());
MapView(
key: _mapViewKey,
controller: _mapController,
followMe: _followMe,
onUserGesture: () {
if (_followMe) setState(() => _followMe = false);
},
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Storage estimate:'),
Text(_mbEstimate == null
? ''
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB'),
],
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarMargin,
left: 8,
right: 8,
),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
),
margin: EdgeInsets.only(bottom: kBottomButtonBarMargin),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text('Tag Camera'),
onPressed: _openAddCameraSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text('Download'),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
],
),
),
),
),
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

@@ -41,6 +41,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} KB"
: '--';
String subtitle =
'Provider: ${area.tileProviderDisplay}\n' +
'Z${area.minZoom}-${area.maxZoom}\n' +
'Lat: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
'Lat: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
@@ -121,6 +122,10 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
name: area.name,
onProgress: (progress) {},
onComplete: (status) {},
tileProviderId: area.tileProviderId,
tileProviderName: area.tileProviderName,
tileTypeId: area.tileTypeId,
tileTypeName: area.tileTypeName,
);
setState(() {});
},

View File

@@ -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,38 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/tile_provider.dart';
import '../tile_provider_management_screen.dart';
class TileProviderSection extends StatelessWidget {
const TileProviderSection({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Map Tiles',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderManagementScreen(),
),
);
},
icon: const Icon(Icons.settings),
label: const Text('Manage Providers'),
),
),
],
);
}
}

View File

@@ -0,0 +1,415 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import 'package:collection/collection.dart';
import '../app_state.dart';
import '../models/tile_provider.dart';
class TileProviderEditorScreen extends StatefulWidget {
final TileProvider? provider; // null for adding new provider
const TileProviderEditorScreen({super.key, this.provider});
@override
State<TileProviderEditorScreen> createState() => _TileProviderEditorScreenState();
}
class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _apiKeyController;
late List<TileType> _tileTypes;
bool get _isEditing => widget.provider != null;
@override
void initState() {
super.initState();
final provider = widget.provider;
_nameController = TextEditingController(text: provider?.name ?? '');
_apiKeyController = TextEditingController(text: provider?.apiKey ?? '');
_tileTypes = provider != null
? List.from(provider.tileTypes)
: <TileType>[];
}
@override
void dispose() {
_nameController.dispose();
_apiKeyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Edit Provider' : 'Add Provider'),
actions: [
TextButton(
onPressed: _saveProvider,
child: const Text('Save'),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Provider Name',
hintText: 'e.g., Custom Maps Inc.',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Provider name is required';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _apiKeyController,
decoration: const InputDecoration(
labelText: 'API Key (Optional)',
hintText: 'Enter API key if required by tile types',
),
obscureText: true,
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Tile Types',
style: Theme.of(context).textTheme.headlineSmall,
),
TextButton.icon(
onPressed: _addTileType,
icon: const Icon(Icons.add),
label: const Text('Add Type'),
),
],
),
const SizedBox(height: 16),
if (_tileTypes.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('No tile types configured'),
),
)
else
..._tileTypes.asMap().entries.map((entry) {
final index = entry.key;
final tileType = entry.value;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(tileType.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(tileType.urlTemplate),
Text(
tileType.attribution,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _editTileType(index),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: _tileTypes.length > 1
? () => _deleteTileType(index)
: null, // Can't delete last tile type
),
],
),
onTap: () => _editTileType(index),
),
);
}),
],
),
),
);
}
void _addTileType() {
_showTileTypeDialog();
}
void _editTileType(int index) {
_showTileTypeDialog(tileType: _tileTypes[index], index: index);
}
void _deleteTileType(int index) {
if (_tileTypes.length <= 1) return;
final tileTypeToDelete = _tileTypes[index];
final appState = context.read<AppState>();
setState(() {
_tileTypes.removeAt(index);
});
// If we're deleting the currently selected tile type, switch to another one
if (appState.selectedTileType?.id == tileTypeToDelete.id) {
// Find first remaining tile type in this provider or any other provider
TileType? replacement;
if (_tileTypes.isNotEmpty) {
replacement = _tileTypes.first;
} else {
// Look in other providers
for (final provider in appState.tileProviders) {
if (provider.availableTileTypes.isNotEmpty) {
replacement = provider.availableTileTypes.first;
break;
}
}
}
if (replacement != null) {
appState.setSelectedTileType(replacement.id);
}
}
}
void _showTileTypeDialog({TileType? tileType, int? index}) {
showDialog(
context: context,
builder: (context) => _TileTypeDialog(
tileType: tileType,
onSave: (newTileType) {
setState(() {
if (index != null) {
_tileTypes[index] = newTileType;
} else {
_tileTypes.add(newTileType);
}
});
},
),
);
}
void _saveProvider() {
if (!_formKey.currentState!.validate()) return;
if (_tileTypes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('At least one tile type is required')),
);
return;
}
final providerId = widget.provider?.id ?? DateTime.now().millisecondsSinceEpoch.toString();
final provider = TileProvider(
id: providerId,
name: _nameController.text.trim(),
apiKey: _apiKeyController.text.trim().isEmpty ? null : _apiKeyController.text.trim(),
tileTypes: _tileTypes,
);
context.read<AppState>().addOrUpdateTileProvider(provider);
Navigator.of(context).pop();
}
}
class _TileTypeDialog extends StatefulWidget {
final TileType? tileType;
final Function(TileType) onSave;
const _TileTypeDialog({
required this.onSave,
this.tileType,
});
@override
State<_TileTypeDialog> createState() => _TileTypeDialogState();
}
class _TileTypeDialogState extends State<_TileTypeDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _urlController;
late final TextEditingController _attributionController;
Uint8List? _previewTile;
bool _isLoadingPreview = false;
@override
void initState() {
super.initState();
final tileType = widget.tileType;
_nameController = TextEditingController(text: tileType?.name ?? '');
_urlController = TextEditingController(text: tileType?.urlTemplate ?? '');
_attributionController = TextEditingController(text: tileType?.attribution ?? '');
_previewTile = tileType?.previewTile;
}
@override
void dispose() {
_nameController.dispose();
_urlController.dispose();
_attributionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.tileType != null ? 'Edit Tile Type' : 'Add Tile Type'),
content: SizedBox(
width: double.maxFinite,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'e.g., Satellite',
),
validator: (value) => value?.trim().isEmpty == true ? 'Name is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'URL Template',
hintText: 'https://example.com/{z}/{x}/{y}.png',
),
validator: (value) {
if (value?.trim().isEmpty == true) return 'URL template is required';
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
return 'URL must contain {z}, {x}, and {y} placeholders';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _attributionController,
decoration: const InputDecoration(
labelText: 'Attribution',
hintText: '© Map Provider',
),
validator: (value) => value?.trim().isEmpty == true ? 'Attribution is required' : null,
),
const SizedBox(height: 16),
Row(
children: [
TextButton.icon(
onPressed: _isLoadingPreview ? null : _fetchPreviewTile,
icon: _isLoadingPreview
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.preview),
label: const Text('Fetch Preview'),
),
const SizedBox(width: 8),
if (_previewTile != null)
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
),
child: Image.memory(_previewTile!, fit: BoxFit.cover),
),
],
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: _saveTileType,
child: const Text('Save'),
),
],
);
}
Future<void> _fetchPreviewTile() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoadingPreview = true;
});
try {
// Use a sample tile (zoom 10, somewhere in the world)
final url = _urlController.text
.replaceAll('{z}', '10')
.replaceAll('{x}', '512')
.replaceAll('{y}', '384');
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
setState(() {
_previewTile = response.bodyBytes;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Preview tile loaded successfully')),
);
}
} else {
throw Exception('HTTP ${response.statusCode}');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to fetch preview: $e')),
);
}
} finally {
setState(() {
_isLoadingPreview = false;
});
}
}
void _saveTileType() {
if (!_formKey.currentState!.validate()) return;
final tileTypeId = widget.tileType?.id ??
'${_nameController.text.toLowerCase().replaceAll(' ', '_')}_${DateTime.now().millisecondsSinceEpoch}';
final tileType = TileType(
id: tileTypeId,
name: _nameController.text.trim(),
urlTemplate: _urlController.text.trim(),
attribution: _attributionController.text.trim(),
previewTile: _previewTile,
);
widget.onSave(tileType);
Navigator.of(context).pop();
}
}

View File

@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/tile_provider.dart';
import 'tile_provider_editor_screen.dart';
class TileProviderManagementScreen extends StatelessWidget {
const TileProviderManagementScreen({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final providers = appState.tileProviders;
return Scaffold(
appBar: AppBar(
title: const Text('Tile Providers'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _addProvider(context),
),
],
),
body: providers.isEmpty
? const Center(
child: Text('No tile providers configured'),
)
: ListView.builder(
itemCount: providers.length,
itemBuilder: (context, index) {
final provider = providers[index];
final isSelected = appState.selectedTileProvider?.id == provider.id;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
title: Text(
provider.name,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${provider.tileTypes.length} tile types'),
if (provider.apiKey?.isNotEmpty == true)
const Text(
'API Key configured',
style: TextStyle(
fontStyle: FontStyle.italic,
fontSize: 12,
),
),
if (!provider.isUsable)
Text(
'Needs API key',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
),
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceVariant,
child: Icon(
Icons.map,
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: providers.length > 1
? PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'edit':
_editProvider(context, provider);
break;
case 'delete':
_deleteProvider(context, provider);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit),
SizedBox(width: 8),
Text('Edit'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete),
SizedBox(width: 8),
Text('Delete'),
],
),
),
],
)
: const Icon(Icons.lock, size: 16), // Can't delete last provider
onTap: () => _editProvider(context, provider),
),
);
},
),
);
}
void _addProvider(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderEditorScreen(),
),
);
}
void _editProvider(BuildContext context, TileProvider provider) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TileProviderEditorScreen(provider: provider),
),
);
}
void _deleteProvider(BuildContext context, TileProvider provider) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Provider'),
content: Text('Are you sure you want to delete "${provider.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
context.read<AppState>().deleteTileProvider(provider.id);
Navigator.of(context).pop();
},
child: const Text('Delete'),
),
],
),
);
}
}

View File

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

@@ -1,11 +1,12 @@
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import '../models/camera_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
import 'map_data_submodules/cameras_from_overpass.dart';
import 'map_data_submodules/tiles_from_osm.dart';
import 'map_data_submodules/tiles_from_remote.dart';
import 'map_data_submodules/cameras_from_local.dart';
import 'map_data_submodules/tiles_from_local.dart';
@@ -39,12 +40,10 @@ class MapDataProvider {
MapSource source = MapSource.auto,
}) async {
final offline = AppState.instance.offlineMode;
print('[MapDataProvider] getCameras called, source=$source, offlineMode=$offline');
// Explicit remote request: error if offline, else always remote
if (source == MapSource.remote) {
if (offline) {
print('[MapDataProvider] Overpass request BLOCKED because we are in offlineMode');
throw OfflineModeException("Cannot fetch remote cameras in offline mode.");
}
return camerasFromOverpass(
@@ -80,7 +79,7 @@ class MapDataProvider {
pageSize: AppState.instance.maxCameras,
);
} catch (e) {
print('[MapDataProvider] Remote camera fetch failed, error: $e. Falling back to local.');
debugPrint('[MapDataProvider] Remote camera fetch failed, error: $e. Falling back to local.');
return fetchLocalCameras(
bounds: bounds,
profiles: profiles,
@@ -121,15 +120,13 @@ class MapDataProvider {
MapSource source = MapSource.auto,
}) async {
final offline = AppState.instance.offlineMode;
print('[MapDataProvider] getTile called for $z/$x/$y, source=$source, offlineMode=$offline');
// Explicitly remote
if (source == MapSource.remote) {
if (offline) {
print('[MapDataProvider] BLOCKED by offlineMode for remote tile fetch');
throw OfflineModeException("Cannot fetch remote tiles in offline mode.");
}
return fetchOSMTile(z: z, x: x, y: y);
return _fetchRemoteTileFromCurrentProvider(z, x, y);
}
// Explicitly local
@@ -142,10 +139,30 @@ class MapDataProvider {
return await fetchLocalTile(z: z, x: x, y: y);
} catch (_) {
if (!offline) {
return fetchOSMTile(z: z, x: x, y: y);
return _fetchRemoteTileFromCurrentProvider(z, x, y);
} else {
throw OfflineModeException("Tile $z/$x/$y not found in offline areas and offline mode is enabled.");
}
}
}
/// Fetch remote tile using current provider from AppState
Future<List<int>> _fetchRemoteTileFromCurrentProvider(int z, int x, int y) async {
final appState = AppState.instance;
final selectedTileType = appState.selectedTileType;
final selectedProvider = appState.selectedTileProvider;
// We guarantee that a provider and tile type are always selected
if (selectedTileType == null || selectedProvider == null) {
throw Exception('No tile provider selected - this should never happen');
}
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
return fetchRemoteTile(z: z, x: x, y: y, url: tileUrl);
}
/// Clear any queued tile requests (call when map view changes significantly)
void clearTileQueue() {
clearRemoteTileQueue();
}
}

View File

@@ -1,11 +1,13 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../models/camera_profile.dart';
import '../../models/osm_camera_node.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches cameras from the Overpass OSM API for the given bounds and profiles.
/// If fetchAllPages is true, returns all possible cameras using multiple API calls (paging with pageSize).
@@ -42,14 +44,20 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
print('[camerasFromOverpass] Querying Overpass...');
print('[camerasFromOverpass] Query:\n$query');
final resp = await http.post(Uri.parse(prodEndpoint), body: {'data': query.trim()});
print('[camerasFromOverpass] Status: ${resp.statusCode}, Length: ${resp.body.length}');
// Only log errors
if (resp.statusCode != 200) {
print('[camerasFromOverpass] Overpass failed: ${resp.body}');
debugPrint('[camerasFromOverpass] Overpass failed: ${resp.body}');
NetworkStatus.instance.reportOverpassIssue();
return [];
}
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
print('[camerasFromOverpass] Retrieved elements: ${elements.length}');
// Only log if many cameras found or if it's a bulk download
if (elements.length > 20 || fetchAllPages) {
debugPrint('[camerasFromOverpass] Retrieved ${elements.length} cameras');
}
NetworkStatus.instance.reportOverpassSuccess();
return elements.whereType<Map<String, dynamic>>().map((e) {
return OsmCameraNode(
id: e['id'],
@@ -59,6 +67,14 @@ Future<List<OsmCameraNode>> camerasFromOverpass({
}).toList();
} catch (e) {
print('[camerasFromOverpass] Overpass exception: $e');
// Report network issues on connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOverpassIssue();
}
return [];
}
}

View File

@@ -3,15 +3,25 @@ import 'package:latlong2/latlong.dart';
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
import '../offline_areas/offline_tile_utils.dart';
import '../../app_state.dart';
/// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found.
/// Fetch a tile from the newest offline area that matches the current provider, or throw if not found.
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
final areas = OfflineAreaService().offlineAreas;
final appState = AppState.instance;
final currentProvider = appState.selectedTileProvider;
final currentTileType = appState.selectedTileType;
final offlineService = OfflineAreaService();
await offlineService.ensureInitialized();
final areas = offlineService.offlineAreas;
final List<_AreaTileMatch> candidates = [];
for (final area in areas) {
if (area.status != OfflineAreaStatus.complete) continue;
if (z < area.minZoom || z > area.maxZoom) continue;
// Only consider areas that match the current provider/type
if (area.tileProviderId != currentProvider?.id || area.tileTypeId != currentTileType?.id) continue;
// Get tile coverage for area at this zoom only
final coveredTiles = computeTileList(area.bounds, z, z);
@@ -26,7 +36,7 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
}
}
if (candidates.isEmpty) {
throw Exception('Tile $z/$x/$y not found in any offline area');
throw Exception('Tile $z/$x/$y from current provider ${currentProvider?.id}/${currentTileType?.id} not found in any offline area');
}
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
return await candidates.first.file.readAsBytes();

View File

@@ -1,82 +0,0 @@
import 'dart:math';
import 'dart:io';
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:flock_map_app/dev_config.dart';
/// Global semaphore to limit simultaneous tile fetches
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
/// Fetches a tile from OSM, with in-memory retries/backoff, and global concurrency limit.
/// Returns tile image bytes, or throws on persistent failure.
Future<List<int>> fetchOSMTile({
required int z,
required int x,
required int y,
}) async {
final url = 'https://tile.openstreetmap.org/$z/$x/$y.png';
const int maxAttempts = kTileFetchMaxAttempts;
int attempt = 0;
final random = Random();
final delays = [
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
];
while (true) {
await _tileFetchSemaphore.acquire();
try {
print('[fetchOSMTile] FETCH $z/$x/$y');
attempt++;
final resp = await http.get(Uri.parse(url));
print('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}');
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
print('[fetchOSMTile] SUCCESS $z/$x/$y');
return resp.bodyBytes;
} else {
print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
}
} catch (e) {
print('[fetchOSMTile] Exception $z/$x/$y: $e');
if (attempt >= maxAttempts) {
print("[fetchOSMTile] Failed for $z/$x/$y after $attempt attempts: $e");
rethrow;
}
final delay = delays[attempt - 1].clamp(0, 60000);
print("[fetchOSMTile] Attempt $attempt for $z/$x/$y failed: $e. Retrying in ${delay}ms.");
await Future.delayed(Duration(milliseconds: delay));
} finally {
_tileFetchSemaphore.release();
}
}
}
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
class _SimpleSemaphore {
final int _max;
int _current = 0;
final List<VoidCallback> _queue = [];
_SimpleSemaphore(this._max);
Future<void> acquire() async {
if (_current < _max) {
_current++;
return;
} else {
final c = Completer<void>();
_queue.add(() => c.complete());
await c.future;
}
}
void release() {
if (_queue.isNotEmpty) {
final callback = _queue.removeAt(0);
callback();
} else {
_current--;
}
}
}

View File

@@ -0,0 +1,133 @@
import 'dart:math';
import 'dart:io';
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:flock_map_app/dev_config.dart';
import '../network_status.dart';
/// Global semaphore to limit simultaneous tile fetches
final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent
/// Clear queued tile requests when map view changes significantly
void clearRemoteTileQueue() {
final clearedCount = _tileFetchSemaphore.clearQueue();
// Only log if we actually cleared something significant
if (clearedCount > 5) {
debugPrint('[RemoteTiles] Cleared $clearedCount queued tile requests');
}
}
/// Fetches a tile from any remote provider, with in-memory retries/backoff, and global concurrency limit.
/// Returns tile image bytes, or throws on persistent failure.
Future<List<int>> fetchRemoteTile({
required int z,
required int x,
required int y,
required String url,
}) async {
const int maxAttempts = kTileFetchMaxAttempts;
int attempt = 0;
final random = Random();
final delays = [
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
];
final hostInfo = Uri.parse(url).host; // For logging
while (true) {
await _tileFetchSemaphore.acquire();
try {
// Only log on first attempt or errors
if (attempt == 1) {
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
}
attempt++;
final resp = await http.get(Uri.parse(url));
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
// Success - no logging for normal operation
NetworkStatus.instance.reportOsmTileSuccess(); // Generic tile server reporting
return resp.bodyBytes;
} else {
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
}
} catch (e) {
// Report network issues on connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOsmTileIssue(); // Generic tile server reporting
}
if (attempt >= maxAttempts) {
debugPrint("[fetchRemoteTile] Failed for $z/$x/$y from $hostInfo after $attempt attempts: $e");
rethrow;
}
final delay = delays[attempt - 1].clamp(0, 60000);
if (attempt == 1) {
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
}
await Future.delayed(Duration(milliseconds: delay));
} finally {
_tileFetchSemaphore.release();
}
}
}
/// Legacy function for backward compatibility
@Deprecated('Use fetchRemoteTile instead')
Future<List<int>> fetchOSMTile({
required int z,
required int x,
required int y,
}) async {
return fetchRemoteTile(
z: z,
x: x,
y: y,
url: 'https://tile.openstreetmap.org/$z/$x/$y.png',
);
}
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
class _SimpleSemaphore {
final int _max;
int _current = 0;
final List<VoidCallback> _queue = [];
_SimpleSemaphore(this._max);
Future<void> acquire() async {
if (_current < _max) {
_current++;
return;
} else {
final c = Completer<void>();
_queue.add(() => c.complete());
await c.future;
}
}
void release() {
if (_queue.isNotEmpty) {
final callback = _queue.removeAt(0);
callback();
} else {
_current--;
}
}
/// Clear all queued requests (call when view changes significantly)
int clearQueue() {
final clearedCount = _queue.length;
_queue.clear();
return clearedCount;
}
}

View File

@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'dart:async';
import '../app_state.dart';
enum NetworkIssueType { osmTiles, overpassApi, both }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
class NetworkStatus extends ChangeNotifier {
static final NetworkStatus instance = NetworkStatus._();
NetworkStatus._();
bool _osmTilesHaveIssues = false;
bool _overpassHasIssues = false;
bool _isWaitingForData = false;
bool _isTimedOut = false;
bool _hasNoData = false;
bool _hasSuccess = false;
int _recentOfflineMisses = 0;
Timer? _osmRecoveryTimer;
Timer? _overpassRecoveryTimer;
Timer? _waitingTimer;
Timer? _noDataResetTimer;
Timer? _successResetTimer;
// Getters
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
bool get overpassHasIssues => _overpassHasIssues;
bool get isWaitingForData => _isWaitingForData;
bool get isTimedOut => _isTimedOut;
bool get hasNoData => _hasNoData;
bool get hasSuccess => _hasSuccess;
NetworkStatusType get currentStatus {
if (hasAnyIssues) return NetworkStatusType.issues;
if (_isWaitingForData) return NetworkStatusType.waiting;
if (_isTimedOut) return NetworkStatusType.timedOut;
if (_hasNoData) return NetworkStatusType.noData;
if (_hasSuccess) return NetworkStatusType.success;
return NetworkStatusType.ready;
}
NetworkIssueType? get currentIssueType {
if (_osmTilesHaveIssues && _overpassHasIssues) return NetworkIssueType.both;
if (_osmTilesHaveIssues) return NetworkIssueType.osmTiles;
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
return null;
}
/// Report tile server issues (for any provider)
void reportOsmTileIssue() {
if (!_osmTilesHaveIssues) {
_osmTilesHaveIssues = true;
notifyListeners();
debugPrint('[NetworkStatus] Tile server issues detected');
}
// Reset recovery timer - if we keep getting errors, keep showing indicator
_osmRecoveryTimer?.cancel();
_osmRecoveryTimer = Timer(const Duration(minutes: 2), () {
_osmTilesHaveIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Tile server issues cleared');
});
}
/// Report Overpass API issues
void reportOverpassIssue() {
if (!_overpassHasIssues) {
_overpassHasIssues = true;
notifyListeners();
debugPrint('[NetworkStatus] Overpass API issues detected');
}
// Reset recovery timer
_overpassRecoveryTimer?.cancel();
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
_overpassHasIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Overpass API issues cleared');
});
}
/// Report successful operations to potentially clear issues faster
void reportOsmTileSuccess() {
// Clear issues immediately on success (they were likely temporary)
if (_osmTilesHaveIssues) {
// Quietly clear - don't log routine success
_osmTilesHaveIssues = false;
_osmRecoveryTimer?.cancel();
notifyListeners();
}
}
void reportOverpassSuccess() {
if (_overpassHasIssues) {
// Quietly clear - don't log routine success
_overpassHasIssues = false;
_overpassRecoveryTimer?.cancel();
notifyListeners();
}
}
/// Set waiting status (show when loading tiles/cameras)
void setWaiting() {
// Clear any previous timeout/no-data state when starting new wait
_isTimedOut = false;
_hasNoData = false;
_recentOfflineMisses = 0;
_noDataResetTimer?.cancel();
if (!_isWaitingForData) {
_isWaitingForData = true;
notifyListeners();
// Don't log routine waiting - only log if we stay waiting too long
}
// Set timeout for genuine network issues (not 404s)
_waitingTimer?.cancel();
_waitingTimer = Timer(const Duration(seconds: 8), () {
_isWaitingForData = false;
_isTimedOut = true;
debugPrint('[NetworkStatus] Request timed out - likely network issues');
notifyListeners();
});
}
/// Show success status briefly when data loads
void setSuccess() {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = true;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
notifyListeners();
// Auto-clear success status after 2 seconds
_successResetTimer?.cancel();
_successResetTimer = Timer(const Duration(seconds: 2), () {
if (_hasSuccess) {
_hasSuccess = false;
notifyListeners();
}
});
}
/// Show no-data status briefly when tiles aren't available
void setNoData() {
_isWaitingForData = false;
_isTimedOut = false;
_hasSuccess = false;
_hasNoData = true;
_waitingTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
// Auto-clear no-data status after 2 seconds
_noDataResetTimer?.cancel();
_noDataResetTimer = Timer(const Duration(seconds: 2), () {
if (_hasNoData) {
_hasNoData = false;
notifyListeners();
}
});
}
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
void clearWaiting() {
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
}
}
/// Report that a tile was not available offline
void reportOfflineMiss() {
_recentOfflineMisses++;
debugPrint('[NetworkStatus] Offline miss #$_recentOfflineMisses');
// If we get several misses in a short time, show "no data" status
if (_recentOfflineMisses >= 3 && !_hasNoData) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = true;
_waitingTimer?.cancel();
notifyListeners();
debugPrint('[NetworkStatus] No offline data available for this area');
}
// Reset the miss counter after some time
_noDataResetTimer?.cancel();
_noDataResetTimer = Timer(const Duration(seconds: 5), () {
_recentOfflineMisses = 0;
});
}
@override
void dispose() {
_osmRecoveryTimer?.cancel();
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
super.dispose();
}
}

View File

@@ -6,23 +6,63 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:path_provider/path_provider.dart';
import 'offline_areas/offline_area_models.dart';
import 'offline_areas/offline_tile_utils.dart';
import 'offline_areas/offline_area_service_tile_fetch.dart'; // Only used for file IO during area downloads.
import 'offline_areas/offline_area_downloader.dart';
import 'offline_areas/world_area_manager.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
import 'map_data_provider.dart';
import 'map_data_submodules/cameras_from_overpass.dart';
import 'package:flock_map_app/dev_config.dart';
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
class OfflineAreaService {
static final OfflineAreaService _instance = OfflineAreaService._();
factory OfflineAreaService() => _instance;
OfflineAreaService._() {
_loadAreasFromDisk().then((_) => _ensureAndAutoDownloadWorldArea());
}
bool _initialized = false;
Future<void>? _initializationFuture;
OfflineAreaService._();
final List<OfflineArea> _areas = [];
List<OfflineArea> get offlineAreas => List.unmodifiable(_areas);
/// Check if any areas are currently downloading
bool get hasActiveDownloads => _areas.any((area) => area.status == OfflineAreaStatus.downloading);
/// Cancel all active downloads (used when enabling offline mode)
Future<void> cancelActiveDownloads() async {
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();
for (final area in activeAreas) {
area.status = OfflineAreaStatus.cancelled;
if (!area.isPermanent) {
// Clean up non-permanent areas
final dir = Directory(area.directory);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
_areas.remove(area);
}
}
await saveAreasToDisk();
debugPrint('OfflineAreaService: Cancelled ${activeAreas.length} active downloads due to offline mode');
}
/// Ensure the service is initialized (areas loaded from disk)
Future<void> ensureInitialized() async {
if (_initialized) return;
_initializationFuture ??= _initialize();
await _initializationFuture;
}
Future<void> _initialize() async {
if (_initialized) return;
await _loadAreasFromDisk();
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
await saveAreasToDisk(); // Save any world area updates
_initialized = true;
}
Future<Directory> getOfflineAreaDir() async {
final dir = await getApplicationDocumentsDirectory();
@@ -56,7 +96,20 @@ class OfflineAreaService {
Future<void> saveAreasToDisk() async {
try {
final file = await _getMetadataPath();
final content = jsonEncode(_areas.map((a) => a.toJson()).toList());
final offlineDir = await getOfflineAreaDir();
// Convert areas to JSON with relative paths for portability
final areaJsonList = _areas.map((area) {
final json = area.toJson();
// Convert absolute path to relative path for storage
if (json['directory'].toString().startsWith(offlineDir.path)) {
final relativePath = json['directory'].toString().replaceFirst('${offlineDir.path}/', '');
json['directory'] = relativePath;
}
return json;
}).toList();
final content = jsonEncode(areaJsonList);
await file.writeAsString(content);
} catch (e) {
debugPrint('Failed to save offline areas: $e');
@@ -77,11 +130,39 @@ class OfflineAreaService {
return;
}
_areas.clear();
for (final areaJson in data) {
// Migrate stored directory paths to be relative for portability
String storedDir = areaJson['directory'];
String relativePath = storedDir;
// If it's an absolute path, extract just the folder name
if (storedDir.startsWith('/')) {
if (storedDir.contains('/offline_areas/')) {
final parts = storedDir.split('/offline_areas/');
if (parts.length == 2) {
relativePath = parts[1]; // Just the folder name (e.g., "world" or "2025-08-19...")
}
}
}
// Always construct absolute path at runtime
final offlineDir = await getOfflineAreaDir();
final fullPath = '${offlineDir.path}/$relativePath';
// Update the JSON to use the full path for this session
areaJson['directory'] = fullPath;
final area = OfflineArea.fromJson(areaJson);
if (!Directory(area.directory).existsSync()) {
area.status = OfflineAreaStatus.error;
} else {
// Reset error status if directory now exists (fixes areas that were previously broken due to path issues)
if (area.status == OfflineAreaStatus.error) {
area.status = OfflineAreaStatus.complete;
}
getAreaSizeBytes(area);
}
_areas.add(area);
@@ -91,77 +172,7 @@ class OfflineAreaService {
}
}
Future<void> _ensureAndAutoDownloadWorldArea() async {
final dir = await getOfflineAreaDir();
final worldDir = "${dir.path}/world";
final LatLngBounds worldBounds = globalWorldBounds();
OfflineArea? world;
for (final a in _areas) {
if (a.isPermanent) { world = a; break; }
}
final Set<List<int>> expectedTiles = computeTileList(worldBounds, kWorldMinZoom, kWorldMaxZoom);
if (world != null) {
int filesFound = 0;
List<List<int>> missingTiles = [];
for (final tile in expectedTiles) {
final f = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
if (f.existsSync()) {
filesFound++;
} else if (missingTiles.length < 10) {
missingTiles.add(tile);
}
}
if (filesFound != expectedTiles.length) {
debugPrint('World area: missing ${expectedTiles.length - filesFound} tiles. First few: $missingTiles');
} else {
debugPrint('World area: all tiles accounted for.');
}
world.tilesTotal = expectedTiles.length;
world.tilesDownloaded = filesFound;
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
if (filesFound == world.tilesTotal) {
world.status = OfflineAreaStatus.complete;
await saveAreasToDisk();
return;
} else {
world.status = OfflineAreaStatus.downloading;
await saveAreasToDisk();
downloadArea(
id: world.id,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
name: world.name,
);
return;
}
}
// If not present, create and start download
world = OfflineArea(
id: 'permanent_world',
name: 'World (required)',
bounds: worldBounds,
minZoom: kWorldMinZoom,
maxZoom: kWorldMaxZoom,
directory: worldDir,
status: OfflineAreaStatus.downloading,
progress: 0.0,
isPermanent: true,
tilesTotal: expectedTiles.length,
tilesDownloaded: 0,
);
_areas.insert(0, world);
await saveAreasToDisk();
downloadArea(
id: world.id,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
name: world.name,
);
}
Future<void> downloadArea({
required String id,
@@ -172,6 +183,10 @@ class OfflineAreaService {
void Function(double progress)? onProgress,
void Function(OfflineAreaStatus status)? onComplete,
String? name,
String? tileProviderId,
String? tileProviderName,
String? tileTypeId,
String? tileTypeName,
}) async {
OfflineArea? area;
for (final a in _areas) {
@@ -192,78 +207,35 @@ class OfflineAreaService {
maxZoom: maxZoom,
directory: directory,
isPermanent: area?.isPermanent ?? false,
tileProviderId: tileProviderId,
tileProviderName: tileProviderName,
tileTypeId: tileTypeId,
tileTypeName: tileTypeName,
);
_areas.add(area);
await saveAreasToDisk();
try {
Set<List<int>> allTiles;
if (area.isPermanent) {
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
} else {
allTiles = computeTileList(bounds, minZoom, maxZoom);
}
area.tilesTotal = allTiles.length;
const int maxPasses = 3;
int pass = 0;
Set<List<int>> allTilesSet = allTiles.toSet();
Set<List<int>> tilesToFetch = allTilesSet;
bool success = false;
int totalDone = 0;
while (pass < maxPasses && tilesToFetch.isNotEmpty) {
pass++;
int doneThisPass = 0;
debugPrint('DownloadArea: pass #$pass for area $id. Need ${tilesToFetch.length} tiles.');
for (final tile in tilesToFetch) {
if (area.status == OfflineAreaStatus.cancelled) break;
try {
final bytes = await MapDataProvider().getTile(
z: tile[0], x: tile[1], y: tile[2], source: MapSource.remote);
if (bytes.isNotEmpty) {
await saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
}
totalDone++;
doneThisPass++;
area.tilesDownloaded = totalDone;
area.progress = area.tilesTotal == 0 ? 0.0 : ((area.tilesDownloaded) / area.tilesTotal);
} catch (e) {
debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e");
}
if (onProgress != null) onProgress(area.progress);
}
await getAreaSizeBytes(area);
await saveAreasToDisk();
Set<List<int>> missingTiles = {};
for (final tile in allTilesSet) {
final f = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
if (!f.existsSync()) missingTiles.add(tile);
}
if (missingTiles.isEmpty) {
success = true;
break;
}
tilesToFetch = missingTiles;
}
final success = await OfflineAreaDownloader.downloadArea(
area: area,
bounds: bounds,
minZoom: minZoom,
maxZoom: maxZoom,
directory: directory,
onProgress: onProgress,
saveAreasToDisk: saveAreasToDisk,
getAreaSizeBytes: getAreaSizeBytes,
);
if (!area.isPermanent) {
final cameras = await MapDataProvider().getAllCamerasForDownload(
bounds: bounds,
profiles: AppState.instance.enabledProfiles,
);
area.cameras = cameras;
await saveCameras(cameras, directory);
} else {
area.cameras = [];
}
await getAreaSizeBytes(area);
if (success) {
area.status = OfflineAreaStatus.complete;
area.progress = 1.0;
debugPrint('Area $id: all tiles accounted for and area marked complete.');
debugPrint('Area $id: download completed successfully.');
} else {
area.status = OfflineAreaStatus.error;
debugPrint('Area $id: MISSING tiles after $maxPasses passes. First 10: ${tilesToFetch.toList().take(10)}');
debugPrint('Area $id: download failed after maximum retry attempts.');
if (!area.isPermanent) {
final dirObj = Directory(area.directory);
if (await dirObj.exists()) {
@@ -273,11 +245,11 @@ class OfflineAreaService {
}
}
await saveAreasToDisk();
if (onComplete != null) onComplete(area.status);
onComplete?.call(area.status);
} catch (e) {
area.status = OfflineAreaStatus.error;
await saveAreasToDisk();
if (onComplete != null) onComplete(area.status);
onComplete?.call(area.status);
}
}
@@ -291,7 +263,7 @@ class OfflineAreaService {
_areas.remove(area);
await saveAreasToDisk();
if (area.isPermanent) {
_ensureAndAutoDownloadWorldArea();
await WorldAreaManager.ensureWorldArea(_areas, getOfflineAreaDir, downloadArea);
}
}
@@ -304,4 +276,6 @@ class OfflineAreaService {
_areas.remove(area);
await saveAreasToDisk();
}
}

View File

@@ -0,0 +1,196 @@
import 'dart:io';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../../app_state.dart';
import '../../models/osm_camera_node.dart';
import '../map_data_provider.dart';
import 'offline_area_models.dart';
import 'offline_tile_utils.dart';
import 'package:flock_map_app/dev_config.dart';
/// Handles the actual downloading process for offline areas
class OfflineAreaDownloader {
static const int _maxRetryPasses = 3;
/// Download tiles and cameras for an offline area
static Future<bool> downloadArea({
required OfflineArea area,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
required String directory,
void Function(double progress)? onProgress,
required Future<void> Function() saveAreasToDisk,
required Future<void> Function(OfflineArea) getAreaSizeBytes,
}) async {
Set<List<int>> allTiles;
if (area.isPermanent) {
allTiles = computeTileList(globalWorldBounds(), kWorldMinZoom, kWorldMaxZoom);
} else {
allTiles = computeTileList(bounds, minZoom, maxZoom);
}
area.tilesTotal = allTiles.length;
// Download tiles with retry logic
final success = await _downloadTilesWithRetry(
area: area,
allTiles: allTiles,
directory: directory,
onProgress: onProgress,
saveAreasToDisk: saveAreasToDisk,
getAreaSizeBytes: getAreaSizeBytes,
);
// Download cameras for non-permanent areas
if (!area.isPermanent) {
await _downloadCameras(
area: area,
bounds: bounds,
minZoom: minZoom,
directory: directory,
);
} else {
area.cameras = [];
}
return success;
}
/// Download tiles with retry logic
static Future<bool> _downloadTilesWithRetry({
required OfflineArea area,
required Set<List<int>> allTiles,
required String directory,
void Function(double progress)? onProgress,
required Future<void> Function() saveAreasToDisk,
required Future<void> Function(OfflineArea) getAreaSizeBytes,
}) async {
int pass = 0;
Set<List<int>> tilesToFetch = allTiles;
int totalDone = 0;
while (pass < _maxRetryPasses && tilesToFetch.isNotEmpty) {
pass++;
debugPrint('DownloadArea: pass #$pass for area ${area.id}. Need ${tilesToFetch.length} tiles.');
for (final tile in tilesToFetch) {
if (area.status == OfflineAreaStatus.cancelled) break;
if (await _downloadSingleTile(tile, directory, area)) {
totalDone++;
area.tilesDownloaded = totalDone;
area.progress = area.tilesTotal == 0 ? 0.0 : (totalDone / area.tilesTotal);
onProgress?.call(area.progress);
}
}
await getAreaSizeBytes(area);
await saveAreasToDisk();
// Check for missing tiles
tilesToFetch = _findMissingTiles(allTiles, directory);
if (tilesToFetch.isEmpty) {
return true; // Success!
}
}
return false; // Failed after max retries
}
/// Download a single tile using the unified MapDataProvider path
static Future<bool> _downloadSingleTile(
List<int> tile,
String directory,
OfflineArea area,
) async {
try {
// Use the same unified path as live tiles: always go through MapDataProvider
// MapDataProvider will use current AppState provider for downloads
final bytes = await MapDataProvider().getTile(
z: tile[0],
x: tile[1],
y: tile[2],
source: MapSource.remote, // Force remote fetch for downloads
);
if (bytes.isNotEmpty) {
await OfflineAreaDownloader.saveTileBytes(tile[0], tile[1], tile[2], directory, bytes);
return true;
}
} catch (e) {
debugPrint("Tile download failed for z=${tile[0]}, x=${tile[1]}, y=${tile[2]}: $e");
}
return false;
}
/// Find tiles that are missing from disk
static Set<List<int>> _findMissingTiles(Set<List<int>> allTiles, String directory) {
final missingTiles = <List<int>>{};
for (final tile in allTiles) {
final file = File('$directory/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
if (!file.existsSync()) {
missingTiles.add(tile);
}
}
return missingTiles;
}
/// Download cameras for the area with expanded bounds
static Future<void> _downloadCameras({
required OfflineArea area,
required LatLngBounds bounds,
required int minZoom,
required String directory,
}) async {
// Calculate expanded camera bounds that cover the entire tile area at minimum zoom
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
final cameras = await MapDataProvider().getAllCamerasForDownload(
bounds: cameraBounds,
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
);
area.cameras = cameras;
await OfflineAreaDownloader.saveCameras(cameras, directory);
debugPrint('Area ${area.id}: Downloaded ${cameras.length} cameras from expanded bounds (all profiles)');
}
/// Calculate expanded bounds that cover the entire tile area at minimum zoom
static LatLngBounds _calculateCameraBounds(LatLngBounds visibleBounds, int minZoom) {
final tiles = computeTileList(visibleBounds, minZoom, minZoom);
if (tiles.isEmpty) return visibleBounds;
// Find the bounding box of all these tiles
double minLat = 90.0, maxLat = -90.0;
double minLon = 180.0, maxLon = -180.0;
for (final tile in tiles) {
final tileBounds = tileToLatLngBounds(tile[1], tile[2], tile[0]);
minLat = math.min(minLat, tileBounds.south);
maxLat = math.max(maxLat, tileBounds.north);
minLon = math.min(minLon, tileBounds.west);
maxLon = math.max(maxLon, tileBounds.east);
}
return LatLngBounds(
LatLng(minLat, minLon),
LatLng(maxLat, maxLon),
);
}
/// Save tile bytes to disk
static Future<void> saveTileBytes(int z, int x, int y, String baseDir, List<int> bytes) async {
final dir = Directory('$baseDir/tiles/$z/$x');
await dir.create(recursive: true);
final file = File('${dir.path}/$y.png');
await file.writeAsBytes(bytes);
}
/// Save cameras to disk as JSON
static Future<void> saveCameras(List<OsmCameraNode> cams, String dir) async {
final file = File('$dir/cameras.json');
await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList()));
}
}

View File

@@ -20,6 +20,12 @@ class OfflineArea {
List<OsmCameraNode> cameras;
int sizeBytes; // Disk size in bytes
final bool isPermanent; // Not user-deletable if true
// Tile provider metadata (null for legacy areas)
final String? tileProviderId;
final String? tileProviderName;
final String? tileTypeId;
final String? tileTypeName;
OfflineArea({
required this.id,
@@ -35,6 +41,10 @@ class OfflineArea {
this.cameras = const [],
this.sizeBytes = 0,
this.isPermanent = false,
this.tileProviderId,
this.tileProviderName,
this.tileTypeId,
this.tileTypeName,
});
Map<String, dynamic> toJson() => {
@@ -54,6 +64,10 @@ class OfflineArea {
'cameras': cameras.map((c) => c.toJson()).toList(),
'sizeBytes': sizeBytes,
'isPermanent': isPermanent,
'tileProviderId': tileProviderId,
'tileProviderName': tileProviderName,
'tileTypeId': tileTypeId,
'tileTypeName': tileTypeName,
};
static OfflineArea fromJson(Map<String, dynamic> json) {
@@ -77,6 +91,27 @@ class OfflineArea {
.map((e) => OsmCameraNode.fromJson(e)).toList(),
sizeBytes: json['sizeBytes'] ?? 0,
isPermanent: json['isPermanent'] ?? false,
tileProviderId: json['tileProviderId'],
tileProviderName: json['tileProviderName'],
tileTypeId: json['tileTypeId'],
tileTypeName: json['tileTypeName'],
);
}
/// Get display text for the tile provider used in this area
String get tileProviderDisplay {
if (tileProviderName != null && tileTypeName != null) {
return '$tileProviderName - $tileTypeName';
} else if (tileTypeName != null) {
return tileTypeName!;
} else if (tileProviderName != null) {
return tileProviderName!;
} else {
// Legacy area - assume OSM
return 'OpenStreetMap (Legacy)';
}
}
/// Check if this area has tile provider metadata
bool get hasTileProviderInfo => tileProviderId != null && tileTypeId != null;
}

View File

@@ -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,153 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:path_provider/path_provider.dart';
import 'offline_area_models.dart';
import 'offline_tile_utils.dart';
import 'package:flock_map_app/dev_config.dart';
/// Manages the world area (permanent offline area for base map)
class WorldAreaManager {
static const String _worldAreaId = 'world';
static const String _worldAreaName = 'World Base Map';
/// Ensure world area exists and check if download is needed
static Future<OfflineArea> ensureWorldArea(
List<OfflineArea> areas,
Future<Directory> Function() getOfflineAreaDir,
Future<void> Function({
required String id,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
required String directory,
String? name,
String? tileProviderId,
String? tileProviderName,
String? tileTypeId,
String? tileTypeName,
}) downloadArea,
) async {
// Find existing world area
OfflineArea? world;
for (final area in areas) {
if (area.isPermanent) {
world = area;
break;
}
}
// Create world area if it doesn't exist, or update existing area without provider info
if (world == null) {
final appDocDir = await getOfflineAreaDir();
final dir = "${appDocDir.path}/$_worldAreaId";
world = OfflineArea(
id: _worldAreaId,
name: _worldAreaName,
bounds: globalWorldBounds(),
minZoom: kWorldMinZoom,
maxZoom: kWorldMaxZoom,
directory: dir,
status: OfflineAreaStatus.downloading,
isPermanent: true,
// World area always uses OpenStreetMap
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
areas.insert(0, world);
} else if (world.tileProviderId == null || world.tileTypeId == null) {
// Update existing world area that lacks provider metadata
final updatedWorld = OfflineArea(
id: world.id,
name: world.name,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
status: world.status,
progress: world.progress,
tilesDownloaded: world.tilesDownloaded,
tilesTotal: world.tilesTotal,
cameras: world.cameras,
sizeBytes: world.sizeBytes,
isPermanent: world.isPermanent,
// Add missing provider metadata
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
final index = areas.indexOf(world);
areas[index] = updatedWorld;
world = updatedWorld;
}
// Check world area status and start download if needed
await _checkAndStartWorldDownload(world, downloadArea);
return world;
}
/// Check world area download status and start if needed
static Future<void> _checkAndStartWorldDownload(
OfflineArea world,
Future<void> Function({
required String id,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
required String directory,
String? name,
String? tileProviderId,
String? tileProviderName,
String? tileTypeId,
String? tileTypeName,
}) downloadArea,
) async {
if (world.status == OfflineAreaStatus.complete) return;
// Count existing tiles
final expectedTiles = computeTileList(
globalWorldBounds(),
kWorldMinZoom,
kWorldMaxZoom,
);
int filesFound = 0;
for (final tile in expectedTiles) {
final file = File('${world.directory}/tiles/${tile[0]}/${tile[1]}/${tile[2]}.png');
if (file.existsSync()) {
filesFound++;
}
}
// Update world area stats
world.tilesTotal = expectedTiles.length;
world.tilesDownloaded = filesFound;
world.progress = (world.tilesTotal == 0) ? 0.0 : (filesFound / world.tilesTotal);
if (filesFound == world.tilesTotal) {
world.status = OfflineAreaStatus.complete;
debugPrint('WorldAreaManager: World area download already complete.');
} else {
world.status = OfflineAreaStatus.downloading;
debugPrint('WorldAreaManager: Starting world area download. ${world.tilesDownloaded}/${world.tilesTotal} tiles found.');
// Start download (fire and forget) - use OSM for world areas
downloadArea(
id: world.id,
bounds: world.bounds,
minZoom: world.minZoom,
maxZoom: world.maxZoom,
directory: world.directory,
name: world.name,
tileProviderId: 'openstreetmap',
tileProviderName: 'OpenStreetMap',
tileTypeId: 'osm_street',
tileTypeName: 'Street Map',
);
}
}
}

View File

@@ -0,0 +1,114 @@
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import '../app_state.dart';
import 'map_data_provider.dart';
import 'network_status.dart';
/// Simple HTTP client that routes tile requests through the centralized MapDataProvider.
/// This ensures all tile fetching (offline/online routing, retries, etc.) is in one place.
class SimpleTileHttpClient extends http.BaseClient {
final http.Client _inner = http.Client();
final MapDataProvider _mapDataProvider = MapDataProvider();
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// Extract tile coordinates from our custom URL scheme
final tileCoords = _extractTileCoords(request.url);
if (tileCoords != null) {
final z = tileCoords['z']!;
final x = tileCoords['x']!;
final y = tileCoords['y']!;
return _handleTileRequest(z, x, y);
}
// Pass through non-tile requests
return _inner.send(request);
}
/// Extract z/x/y coordinates from our fake domain: https://tiles.local/provider/type/z/x/y
/// We ignore the provider/type in the URL since we use current AppState for actual fetching
Map<String, int>? _extractTileCoords(Uri url) {
if (url.host != 'tiles.local') return null;
final pathSegments = url.pathSegments;
if (pathSegments.length != 5) return null;
// pathSegments[0] = providerId (for cache separation only)
// pathSegments[1] = tileTypeId (for cache separation only)
final z = int.tryParse(pathSegments[2]);
final x = int.tryParse(pathSegments[3]);
final y = int.tryParse(pathSegments[4]);
if (z != null && x != null && y != null) {
return {'z': z, 'x': x, 'y': y};
}
return null;
}
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
try {
// Always go through MapDataProvider - it handles offline/online routing
// MapDataProvider will get current provider from AppState
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
// Show success status briefly
NetworkStatus.instance.setSuccess();
// Serve tile with proper cache headers
return http.StreamedResponse(
Stream.value(tileBytes),
200,
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800',
'Expires': _httpDateFormat(DateTime.now().add(Duration(days: 7))),
'Last-Modified': _httpDateFormat(DateTime.now().subtract(Duration(hours: 1))),
},
);
} catch (e) {
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
// 404 means no tiles available - show "no data" status briefly
NetworkStatus.instance.setNoData();
// Return 404 and let flutter_map handle it gracefully
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Tile unavailable: $e',
);
}
}
/// Clear any queued tile requests when map view changes
void clearTileQueue() {
_mapDataProvider.clearTileQueue();
}
/// Format date for HTTP headers (RFC 7231)
String _httpDateFormat(DateTime date) {
final utc = date.toUtc();
final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
final weekday = weekdays[utc.weekday - 1];
final day = utc.day.toString().padLeft(2, '0');
final month = months[utc.month - 1];
final year = utc.year;
final hour = utc.hour.toString().padLeft(2, '0');
final minute = utc.minute.toString().padLeft(2, '0');
final second = utc.second.toString().padLeft(2, '0');
return '$weekday, $day $month $year $hour:$minute:$second GMT';
}
@override
void close() {
_inner.close();
super.close();
}
}

103
lib/state/auth_state.dart Normal file
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,215 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:collection/collection.dart';
import '../models/tile_provider.dart';
// Enum for upload mode (Production, OSM Sandbox, Simulate)
enum UploadMode { production, sandbox, simulate }
class SettingsState extends ChangeNotifier {
static const String _offlineModePrefsKey = 'offline_mode';
static const String _maxCamerasPrefsKey = 'max_cameras';
static const String _uploadModePrefsKey = 'upload_mode';
static const String _tileProvidersPrefsKey = 'tile_providers';
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
static const String _legacyTestModePrefsKey = 'test_mode';
bool _offlineMode = false;
int _maxCameras = 250;
UploadMode _uploadMode = UploadMode.simulate;
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
// Getters
bool get offlineMode => _offlineMode;
int get maxCameras => _maxCameras;
UploadMode get uploadMode => _uploadMode;
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId;
/// Get the currently selected tile type
TileType? get selectedTileType {
for (final provider in _tileProviders) {
for (final tileType in provider.tileTypes) {
if (tileType.id == _selectedTileTypeId) {
return tileType;
}
}
}
return null;
}
/// Get the provider that contains the selected tile type
TileProvider? get selectedTileProvider {
for (final provider in _tileProviders) {
if (provider.tileTypes.any((type) => type.id == _selectedTileTypeId)) {
return provider;
}
}
return null;
}
/// Get all available tile types from all providers
List<TileType> get allAvailableTileTypes {
final types = <TileType>[];
for (final provider in _tileProviders) {
types.addAll(provider.availableTileTypes);
}
return types;
}
// Initialize settings from preferences
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
// Load offline mode
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
// Load max cameras
if (prefs.containsKey(_maxCamerasPrefsKey)) {
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
}
// Load upload mode (including migration from old test_mode bool)
if (prefs.containsKey(_uploadModePrefsKey)) {
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
if (idx >= 0 && idx < UploadMode.values.length) {
_uploadMode = UploadMode.values[idx];
}
} else if (prefs.containsKey(_legacyTestModePrefsKey)) {
// migrate legacy test_mode (true->simulate, false->prod)
final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false;
_uploadMode = legacy ? UploadMode.simulate : UploadMode.production;
await prefs.remove(_legacyTestModePrefsKey);
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
}
// Load tile providers (default to built-in providers if none saved)
await _loadTileProviders(prefs);
// Load selected tile type (default to first available)
_selectedTileTypeId = prefs.getString(_selectedTileTypePrefsKey) ?? '';
if (_selectedTileTypeId.isEmpty || selectedTileType == null) {
final firstType = allAvailableTileTypes.firstOrNull;
if (firstType != null) {
_selectedTileTypeId = firstType.id;
await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId);
}
}
}
Future<void> _loadTileProviders(SharedPreferences prefs) async {
if (prefs.containsKey(_tileProvidersPrefsKey)) {
try {
final providersJson = prefs.getString(_tileProvidersPrefsKey);
if (providersJson != null) {
final providersList = jsonDecode(providersJson) as List;
_tileProviders = providersList
.map((json) => TileProvider.fromJson(json))
.toList();
}
} catch (e) {
debugPrint('Error loading tile providers: $e');
// Fall back to defaults on error
_tileProviders = DefaultTileProviders.createDefaults();
}
} else {
// First time - use defaults
_tileProviders = DefaultTileProviders.createDefaults();
await _saveTileProviders(prefs);
}
}
Future<void> _saveTileProviders(SharedPreferences prefs) async {
try {
final providersJson = jsonEncode(
_tileProviders.map((provider) => provider.toJson()).toList(),
);
await prefs.setString(_tileProvidersPrefsKey, providersJson);
} catch (e) {
debugPrint('Error saving tile providers: $e');
}
}
Future<void> setOfflineMode(bool enabled) async {
_offlineMode = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_offlineModePrefsKey, enabled);
notifyListeners();
}
set maxCameras(int n) {
if (n < 10) n = 10; // minimum
_maxCameras = n;
SharedPreferences.getInstance().then((prefs) {
prefs.setInt(_maxCamerasPrefsKey, n);
});
notifyListeners();
}
Future<void> setUploadMode(UploadMode mode) async {
_uploadMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_uploadModePrefsKey, mode.index);
notifyListeners();
}
/// Select a tile type by ID
Future<void> setSelectedTileType(String tileTypeId) async {
if (_selectedTileTypeId != tileTypeId) {
_selectedTileTypeId = tileTypeId;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_selectedTileTypePrefsKey, tileTypeId);
notifyListeners();
}
}
/// Add or update a tile provider
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
final existingIndex = _tileProviders.indexWhere((p) => p.id == provider.id);
if (existingIndex >= 0) {
_tileProviders[existingIndex] = provider;
} else {
_tileProviders.add(provider);
}
final prefs = await SharedPreferences.getInstance();
await _saveTileProviders(prefs);
notifyListeners();
}
/// Delete a tile provider
Future<void> deleteTileProvider(String providerId) async {
// Don't allow deleting all providers
if (_tileProviders.length <= 1) return;
final providerToDelete = _tileProviders.firstWhereOrNull((p) => p.id == providerId);
if (providerToDelete == null) return;
// If selected tile type belongs to this provider, switch to another
if (providerToDelete.tileTypes.any((type) => type.id == _selectedTileTypeId)) {
// Find first available tile type from remaining providers
final remainingProviders = _tileProviders.where((p) => p.id != providerId).toList();
final firstAvailable = remainingProviders
.expand((p) => p.availableTileTypes)
.firstOrNull;
if (firstAvailable != null) {
_selectedTileTypeId = firstAvailable.id;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId);
}
}
_tileProviders.removeWhere((p) => p.id == providerId);
final prefs = await SharedPreferences.getInstance();
await _saveTileProviders(prefs);
notifyListeners();
}
}

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

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../dev_config.dart';
enum CameraIconType {
real, // Blue ring - real cameras from OSM
mock, // White ring - add camera mock point
pending, // Purple ring - submitted/pending cameras
}
/// Simple camera icon with grey dot and colored ring
class CameraIcon extends StatelessWidget {
final CameraIconType type;
const CameraIcon({super.key, required this.type});
Color get _ringColor {
switch (type) {
case CameraIconType.real:
return kCameraRingColorReal;
case CameraIconType.mock:
return kCameraRingColorMock;
case CameraIconType.pending:
return kCameraRingColorPending;
}
}
@override
Widget build(BuildContext context) {
return Container(
width: kCameraIconDiameter,
height: kCameraIconDiameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withOpacity(kCameraDotOpacity),
border: Border.all(
color: _ringColor,
width: kCameraRingThickness,
),
),
);
}
}

View File

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

@@ -11,10 +11,11 @@ class CameraTagSheet extends StatelessWidget {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Camera #${node.id}',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
@@ -55,6 +56,7 @@ class CameraTagSheet extends StatelessWidget {
),
],
),
),
),
);
}

View File

@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:provider/provider.dart';
import 'dart:math' as math;
import '../app_state.dart';
import '../dev_config.dart';
import '../services/offline_area_service.dart';
import '../services/offline_areas/offline_tile_utils.dart';
class DownloadAreaDialog extends StatefulWidget {
final MapController controller;
const DownloadAreaDialog({super.key, required this.controller});
@override
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
}
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
double _zoom = 15;
int? _minZoom;
int? _maxPossibleZoom;
int? _tileCount;
double? _mbEstimate;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
}
void _recomputeEstimates() {
var bounds = widget.controller.camera.visibleBounds;
// If the visible area is nearly zero, nudge the bounds for estimation
const double epsilon = 0.0002;
final latSpan = (bounds.north - bounds.south).abs();
final lngSpan = (bounds.east - bounds.west).abs();
if (latSpan < epsilon && lngSpan < epsilon) {
bounds = LatLngBounds(
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
);
} else if (latSpan < epsilon) {
bounds = LatLngBounds(
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
);
} else if (lngSpan < epsilon) {
bounds = LatLngBounds(
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
);
}
final minZoom = kWorldMaxZoom + 1;
final maxZoom = _zoom.toInt();
// Calculate maximum possible zoom based on tile count limit
final maxPossibleZoom = _calculateMaxZoomForTileLimit(bounds, minZoom);
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
setState(() {
_minZoom = minZoom;
_maxPossibleZoom = maxPossibleZoom;
_tileCount = nTiles;
_mbEstimate = totalMb;
});
}
/// Calculate the maximum zoom level that keeps tile count under the limit
int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) {
for (int zoom = minZoom; zoom <= kAbsoluteMaxZoom; zoom++) {
final tileCount = computeTileList(bounds, minZoom, zoom).length;
if (tileCount > kMaxReasonableTileCount) {
// Return the previous zoom level that was still under the limit
return math.max(minZoom, zoom - 1);
}
}
return kAbsoluteMaxZoom;
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final bounds = widget.controller.camera.visibleBounds;
final maxZoom = _zoom.toInt();
final isOfflineMode = appState.offlineMode;
// Use the calculated max possible zoom instead of fixed span
final sliderMin = _minZoom?.toDouble() ?? 12.0;
final sliderMax = _maxPossibleZoom?.toDouble() ?? 19.0;
final sliderDivisions = math.max(1, (_maxPossibleZoom ?? 19) - (_minZoom ?? 12));
final sliderValue = _zoom.clamp(sliderMin, sliderMax);
return AlertDialog(
title: Row(
children: const [
Icon(Icons.download_for_offline),
SizedBox(width: 10),
Text("Download Map Area"),
],
),
content: SizedBox(
width: 350,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Max zoom level'),
Text('Z${_zoom.toStringAsFixed(0)}'),
],
),
Slider(
min: sliderMin,
max: sliderMax,
divisions: sliderDivisions,
label: 'Z${_zoom.toStringAsFixed(0)}',
value: sliderValue,
onChanged: (v) {
setState(() => _zoom = v);
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
},
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Storage estimate:'),
Expanded(
child: Text(
_mbEstimate == null
? ''
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB',
textAlign: TextAlign.end,
style: const TextStyle(fontSize: 14),
),
),
],
),
if (_minZoom != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Min zoom:'),
Text('Z$_minZoom'),
],
),
if (_maxPossibleZoom != null && _tileCount != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange.withOpacity(0.1)
: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Max recommended zoom: Z$_maxPossibleZoom',
style: TextStyle(
fontSize: 12,
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange[700]
: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
_tileCount! > kMaxReasonableTileCount
? 'Current selection exceeds ${kMaxReasonableTileCount.toString()} tile limit'
: 'Within ${kMaxReasonableTileCount.toString()} tile limit',
style: TextStyle(
fontSize: 11,
color: _tileCount! > kMaxReasonableTileCount
? Colors.orange[600]
: Colors.green[600],
),
),
],
),
),
),
if (isOfflineMode)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Row(
children: [
Icon(Icons.wifi_off, color: Colors.orange[700], size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Downloads disabled while in offline mode. Disable offline mode to download new areas.',
style: TextStyle(
fontSize: 12,
color: Colors.orange[700],
),
),
),
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: isOfflineMode ? null : () async {
try {
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
final dir = "${appDocDir.path}/$id";
// Get current tile provider info
final appState = context.read<AppState>();
final selectedProvider = appState.selectedTileProvider;
final selectedTileType = appState.selectedTileType;
// Fire and forget: don't await download, so dialog closes immediately
// ignore: unawaited_futures
OfflineAreaService().downloadArea(
id: id,
bounds: bounds,
minZoom: _minZoom ?? 12,
maxZoom: maxZoom,
directory: dir,
onProgress: (progress) {},
onComplete: (status) {},
tileProviderId: selectedProvider?.id,
tileProviderName: selectedProvider?.name,
tileTypeId: selectedTileType?.id,
tileTypeName: selectedTileType?.name,
);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Download started! Fetching tiles and cameras...'),
),
);
} catch (e) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start download: $e'),
),
);
}
},
child: const Text('Download'),
),
],
);
}
}

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';
import '../camera_icon.dart';
/// Smart marker widget for camera with single/double tap distinction
class CameraMapMarker extends StatefulWidget {
final OsmCameraNode node;
final MapController mapController;
const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
@override
State<CameraMapMarker> createState() => _CameraMapMarkerState();
}
class _CameraMapMarkerState extends State<CameraMapMarker> {
Timer? _tapTimer;
// From dev_config.dart for build-time parameters
static const Duration tapTimeout = kMarkerTapTimeout;
void _onTap() {
_tapTimer = Timer(tapTimeout, () {
showModalBottomSheet(
context: context,
builder: (_) => CameraTagSheet(node: widget.node),
showDragHandle: true,
);
});
}
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
}
@override
void dispose() {
_tapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Check if this is a pending upload
final isPending = widget.node.tags.containsKey('_pending_upload') &&
widget.node.tags['_pending_upload'] == 'true';
return GestureDetector(
onTap: _onTap,
onDoubleTap: _onDoubleTap,
child: CameraIcon(
type: isPending ? CameraIconType.pending : CameraIconType.real,
),
);
}
}
/// Helper class to build marker layers for cameras and user location
class CameraMarkersBuilder {
static List<Marker> buildCameraMarkers({
required List<OsmCameraNode> cameras,
required MapController mapController,
LatLng? userLocation,
}) {
final markers = <Marker>[
// Camera markers
...cameras
.where(_isValidCameraCoordinate)
.map((n) => Marker(
point: n.coord,
width: kCameraIconDiameter,
height: kCameraIconDiameter,
child: CameraMapMarker(node: n, mapController: mapController),
)),
// User location marker
if (userLocation != null)
Marker(
point: userLocation,
width: 16,
height: 16,
child: const Icon(Icons.my_location, color: Colors.blue),
),
];
return markers;
}
static bool _isValidCameraCoordinate(OsmCameraNode node) {
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
}
}

View File

@@ -0,0 +1,95 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
import '../../models/osm_camera_node.dart';
/// Helper class to build direction cone polygons for cameras
class DirectionConesBuilder {
static List<Polygon> buildDirectionCones({
required List<OsmCameraNode> cameras,
required double zoom,
AddCameraSession? session,
}) {
final overlays = <Polygon>[];
// Add session cone if in add-camera mode
if (session != null && session.target != null) {
overlays.add(_buildCone(
session.target!,
session.directionDegrees,
zoom,
));
}
// Add cones for cameras with direction
overlays.addAll(
cameras
.where(_isValidCameraWithDirection)
.map((n) => _buildCone(
n.coord,
n.directionDeg!,
zoom,
))
);
return overlays;
}
static bool _isValidCameraWithDirection(OsmCameraNode node) {
return node.hasDirection &&
node.directionDeg != null &&
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
}
static bool _isPendingUpload(OsmCameraNode node) {
return node.tags.containsKey('_pending_upload') &&
node.tags['_pending_upload'] == 'true';
}
static Polygon _buildCone(
LatLng origin,
double bearingDeg,
double zoom, {
bool isPending = false,
bool isSession = false,
}) {
final halfAngle = kDirectionConeHalfAngle;
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
// Number of points to create the arc (more = smoother curve)
const int arcPoints = 12;
LatLng project(double deg) {
final rad = deg * math.pi / 180;
final dLat = length * math.cos(rad);
final dLon =
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
}
// Build pizza slice with curved edge
final points = <LatLng>[origin];
// Add arc points from left to right
for (int i = 0; i <= arcPoints; i++) {
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
points.add(project(angle));
}
// Close the shape back to origin
points.add(origin);
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(0.25),
borderColor: kDirectionConeColor,
borderStrokeWidth: 1,
);
}
}

View File

@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/tile_provider.dart';
import '../../services/offline_area_service.dart';
class LayerSelectorButton extends StatelessWidget {
const LayerSelectorButton({super.key});
@override
Widget build(BuildContext context) {
return FloatingActionButton(
mini: true,
onPressed: () => _showLayerSelector(context),
child: const Icon(Icons.layers),
);
}
void _showLayerSelector(BuildContext context) {
// Check if any downloads are active
final offlineService = OfflineAreaService();
if (offlineService.hasActiveDownloads) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cannot change tile types while downloading offline areas'),
duration: Duration(seconds: 3),
),
);
return;
}
showDialog(
context: context,
builder: (context) => const _LayerSelectorDialog(),
);
}
}
class _LayerSelectorDialog extends StatefulWidget {
const _LayerSelectorDialog();
@override
State<_LayerSelectorDialog> createState() => _LayerSelectorDialogState();
}
class _LayerSelectorDialogState extends State<_LayerSelectorDialog> {
String? _selectedTileTypeId;
@override
void initState() {
super.initState();
final appState = context.read<AppState>();
_selectedTileTypeId = appState.selectedTileType?.id;
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final providers = appState.tileProviders;
// Group tile types by provider for display
final providerGroups = <TileProvider, List<TileType>>{};
for (final provider in providers) {
final availableTypes = provider.availableTileTypes;
if (availableTypes.isNotEmpty) {
providerGroups[provider] = availableTypes;
}
}
return Dialog(
child: Container(
width: double.maxFinite,
constraints: const BoxConstraints(maxHeight: 500),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Row(
children: [
const Icon(Icons.layers),
const SizedBox(width: 8),
const Text(
'Select Map Layer',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Content
Flexible(
child: ListView(
padding: EdgeInsets.zero,
children: [
if (providerGroups.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Center(
child: Text('No tile providers available'),
),
)
else
...providerGroups.entries.map((entry) {
final provider = entry.key;
final tileTypes = entry.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Provider header
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Theme.of(context).colorScheme.surface,
child: Text(
provider.name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
// Tile types
...tileTypes.map((tileType) => _TileTypeListItem(
tileType: tileType,
provider: provider,
isSelected: _selectedTileTypeId == tileType.id,
onSelected: () {
setState(() {
_selectedTileTypeId = tileType.id;
});
appState.setSelectedTileType(tileType.id);
Navigator.of(context).pop();
},
)),
],
);
}),
],
),
),
],
),
),
);
}
}
class _TileTypeListItem extends StatelessWidget {
final TileType tileType;
final TileProvider provider;
final bool isSelected;
final VoidCallback onSelected;
const _TileTypeListItem({
required this.tileType,
required this.provider,
required this.isSelected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(4),
),
child: tileType.previewTile != null
? ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Image.memory(
tileType.previewTile!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _FallbackPreview(),
),
)
: _FallbackPreview(),
),
title: Text(
tileType.name,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : null,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
),
subtitle: Text(
tileType.attribution,
style: Theme.of(context).textTheme.bodySmall,
),
trailing: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: null,
onTap: onSelected,
);
}
}
class _FallbackPreview extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.grey.shade200,
child: const Center(
child: Icon(
Icons.map,
size: 24,
color: Colors.grey,
),
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
import '../camera_icon.dart';
import 'layer_selector_button.dart';
/// Widget that renders all map overlay UI elements
class MapOverlays extends StatelessWidget {
final MapController mapController;
final UploadMode uploadMode;
final AddCameraSession? session;
final String? attribution; // Attribution for current tile provider
const MapOverlays({
super.key,
required this.mapController,
required this.uploadMode,
this.session,
this.attribution,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// MODE INDICATOR badge (top-right)
if (uploadMode == UploadMode.sandbox || uploadMode == UploadMode.simulate)
Positioned(
top: 18,
right: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: uploadMode == UploadMode.sandbox
? Colors.orange.withOpacity(0.90)
: Colors.deepPurple.withOpacity(0.80),
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)),
],
),
child: Text(
uploadMode == UploadMode.sandbox
? 'SANDBOX MODE'
: 'SIMULATE',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
letterSpacing: 1.1,
),
),
),
),
// Zoom indicator, positioned above scale bar
Positioned(
left: 10,
bottom: kZoomIndicatorBottomOffset,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.52),
borderRadius: BorderRadius.circular(7),
),
child: Builder(
builder: (context) {
final zoom = mapController.camera.zoom;
return Text(
'Zoom: ${zoom.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
);
},
),
),
),
// Attribution overlay
if (attribution != null)
Positioned(
bottom: kAttributionBottomOffset,
left: 10,
child: Container(
color: Colors.white70,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text(
attribution!,
style: const TextStyle(fontSize: 11),
),
),
),
// Zoom and layer controls (bottom-right)
Positioned(
bottom: 80,
right: 16,
child: Column(
children: [
// Layer selector button
const LayerSelectorButton(),
const SizedBox(height: 8),
// Zoom in button
FloatingActionButton(
mini: true,
heroTag: "zoom_in",
onPressed: () {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom + 1);
},
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
// Zoom out button
FloatingActionButton(
mini: true,
heroTag: "zoom_out",
onPressed: () {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom - 1);
},
child: const Icon(Icons.remove),
),
],
),
),
// Fixed pin when adding camera
if (session != null)
IgnorePointer(
child: Center(
child: Transform.translate(
offset: const Offset(0, kAddPinYOffset),
child: const CameraIcon(type: CameraIconType.mock),
),
),
),
],
);
}
}

View File

@@ -1,73 +1,26 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:http/io_client.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import 'package:collection/collection.dart';
import '../app_state.dart';
import '../services/map_data_provider.dart';
import '../services/offline_area_service.dart';
import '../services/simple_tile_service.dart';
import '../services/network_status.dart';
import '../models/osm_camera_node.dart';
import '../models/camera_profile.dart';
import '../models/tile_provider.dart';
import 'debouncer.dart';
import 'camera_tag_sheet.dart';
import 'tile_provider_with_cache.dart';
import 'camera_provider_with_cache.dart';
import 'package:flock_map_app/dev_config.dart';
// --- Smart marker widget for camera with single/double tap distinction
class _CameraMapMarker extends StatefulWidget {
final OsmCameraNode node;
final MapController mapController;
const _CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
@override
State<_CameraMapMarker> createState() => _CameraMapMarkerState();
}
class _CameraMapMarkerState extends State<_CameraMapMarker> {
Timer? _tapTimer;
// From dev_config.dart for build-time parameters
static const Duration tapTimeout = kMarkerTapTimeout;
void _onTap() {
_tapTimer = Timer(tapTimeout, () {
showModalBottomSheet(
context: context,
builder: (_) => CameraTagSheet(node: widget.node),
showDragHandle: true,
);
});
}
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
}
@override
void dispose() {
_tapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
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 +35,44 @@ class MapView extends StatefulWidget {
final VoidCallback onUserGesture;
@override
State<MapView> createState() => _MapViewState();
State<MapView> createState() => MapViewState();
}
class _MapViewState extends State<MapView> {
class MapViewState extends State<MapView> {
late final MapController _controller;
final MapDataProvider _mapDataProvider = MapDataProvider();
final Debouncer _debounce = Debouncer(kDebounceCameraRefresh);
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
StreamSubscription<Position>? _positionSub;
LatLng? _currentLatLng;
late final CameraProviderWithCache _cameraProvider;
late final SimpleTileHttpClient _tileHttpClient;
// Track profile changes to trigger camera refresh
List<CameraProfile>? _lastEnabledProfiles;
// Track zoom to clear queue on zoom changes
double? _lastZoom;
// Track changes that require cache clearing
String? _lastTileTypeId;
bool? _lastOfflineMode;
int _mapRebuildKey = 0;
@override
void initState() {
super.initState();
// _debounceTileLayerUpdate removed
OfflineAreaService();
_controller = widget.controller;
_tileHttpClient = SimpleTileHttpClient();
_initLocation();
// Set up camera overlay caching
_cameraProvider = CameraProviderWithCache.instance;
_cameraProvider.addListener(_onCamerasUpdated);
// Ensure initial overlays are fetched
// Fetch initial cameras
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshCamerasFromProvider();
});
@@ -115,8 +81,10 @@ class _MapViewState extends State<MapView> {
@override
void dispose() {
_positionSub?.cancel();
_debounce.dispose();
_cameraDebounce.dispose();
_tileDebounce.dispose();
_cameraProvider.removeListener(_onCamerasUpdated);
_tileHttpClient.close();
super.dispose();
}
@@ -124,6 +92,14 @@ class _MapViewState extends State<MapView> {
if (mounted) setState(() {});
}
/// Public method to retry location initialization (e.g., after permission granted)
void retryLocationInit() {
debugPrint('[MapView] Retrying location initialization');
_initLocation();
}
void _refreshCamerasFromProvider() {
final appState = context.read<AppState>();
LatLngBounds? bounds;
@@ -152,6 +128,10 @@ class _MapViewState extends State<MapView> {
);
}
@override
void didUpdateWidget(covariant MapView oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -191,13 +171,77 @@ class _MapViewState extends State<MapView> {
}
}
/// Helper to check if two profile lists are equal
bool _profileListsEqual(List<CameraProfile> list1, List<CameraProfile> list2) {
if (list1.length != list2.length) return false;
// Compare by profile IDs since profiles are value objects
final ids1 = list1.map((p) => p.id).toSet();
final ids2 = list2.map((p) => p.id).toSet();
return ids1.length == ids2.length && ids1.containsAll(ids2);
}
/// Build tile layer - uses fake domain that SimpleTileHttpClient can parse
Widget _buildTileLayer(AppState appState) {
final selectedTileType = appState.selectedTileType;
final selectedProvider = appState.selectedTileProvider;
// Use fake domain with standard HTTPS scheme: https://tiles.local/provider/type/z/x/y
// This naturally separates cache entries by provider and type while being HTTP-compatible
final urlTemplate = 'https://tiles.local/${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
return TileLayer(
urlTemplate: urlTemplate,
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
),
);
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final session = appState.session;
// Only update cameras when map moves or profiles/mode actually change (not every build!)
// _refreshCamerasFromProvider() is now only called from map movement and relevant change handlers.
// Check if enabled profiles changed and refresh cameras if needed
final currentEnabledProfiles = appState.enabledProfiles;
if (_lastEnabledProfiles == null ||
!_profileListsEqual(_lastEnabledProfiles!, currentEnabledProfiles)) {
_lastEnabledProfiles = List.from(currentEnabledProfiles);
// Refresh cameras when profiles change
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear camera cache to ensure fresh data for new profile combination
_cameraProvider.clearCache();
// Force display refresh first (for immediate UI update)
_cameraProvider.refreshDisplay();
// Then fetch new cameras for newly enabled profiles
_refreshCamerasFromProvider();
});
}
// Check if tile type OR offline mode changed and clear cache if needed
final currentTileTypeId = appState.selectedTileType?.id;
final currentOfflineMode = appState.offlineMode;
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId) ||
(_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
// Force map rebuild with new key to bust flutter_map cache
_mapRebuildKey++;
final reason = _lastTileTypeId != currentTileTypeId
? 'tile type ($currentTileTypeId)'
: 'offline mode ($currentOfflineMode)';
debugPrint('[MapView] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
WidgetsBinding.instance.addPostFrameCallback((_) {
debugPrint('[MapView] Post-frame: Clearing tile request queue');
_tileHttpClient.clearTileQueue();
});
}
_lastTileTypeId = currentTileTypeId;
_lastOfflineMode = currentOfflineMode;
// Seed addmode target once, after first controller center is available.
if (session != null && session.target == null) {
@@ -222,34 +266,19 @@ class _MapViewState extends State<MapView> {
final cameras = (mapBounds != null)
? cameraProvider.getCachedCamerasForBounds(mapBounds)
: <OsmCameraNode>[];
final markers = <Marker>[
...cameras
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
.map((n) => Marker(
point: n.coord,
width: 24,
height: 24,
child: _CameraMapMarker(node: n, mapController: _controller),
)),
if (_currentLatLng != null)
Marker(
point: _currentLatLng!,
width: 16,
height: 16,
child: const Icon(Icons.my_location, color: Colors.blue),
),
];
final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: cameras,
mapController: _controller,
userLocation: _currentLatLng,
);
final overlays = DirectionConesBuilder.buildDirectionCones(
cameras: cameras,
zoom: zoom,
session: session,
);
final overlays = <Polygon>[
if (session != null && session.target != null)
_buildCone(session.target!, session.directionDegrees, zoom),
...cameras
.where((n) => n.hasDirection && n.directionDeg != null)
.where((n) => n.coord.latitude != 0 || n.coord.longitude != 0)
.where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180)
.map((n) => _buildCone(n.coord, n.directionDeg!, zoom)),
];
return Stack(
children: [
PolygonLayer(polygons: overlays),
@@ -262,7 +291,7 @@ class _MapViewState extends State<MapView> {
return Stack(
children: [
FlutterMap(
key: ValueKey(appState.offlineMode),
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_$_mapRebuildKey'),
mapController: _controller,
options: MapOptions(
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),
@@ -274,44 +303,35 @@ class _MapViewState extends State<MapView> {
if (session != null) {
appState.updateSession(target: pos.center);
}
// Only request more cameras if the user navigated the map (and at valid zoom)
if (gesture && pos.zoom >= 10) {
_debounce(_refreshCamerasFromProvider);
// Show waiting indicator when map moves (user is expecting new content)
NetworkStatus.instance.setWaiting();
// Only clear tile queue on significant ZOOM changes (not panning)
final currentZoom = pos.zoom;
final zoomChanged = _lastZoom != null && (currentZoom - _lastZoom!).abs() > 0.5;
if (zoomChanged) {
_tileDebounce(() {
// Clear stale tile requests on zoom change (quietly)
_tileHttpClient.clearTileQueue();
});
}
_lastZoom = currentZoom;
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
if (pos.zoom >= 10) {
_cameraDebounce(_refreshCamerasFromProvider);
}
},
),
children: [
TileLayer(
tileProvider: Provider.of<TileProviderWithCache>(context),
urlTemplate: 'unused-{z}-{x}-{y}',
tileSize: 256,
tileBuilder: (ctx, tileWidget, tileImage) {
try {
final str = tileImage.toString();
final regex = RegExp(r'TileCoordinate\((\d+), (\d+), (\d+)\)');
final match = regex.firstMatch(str);
if (match != null) {
final x = match.group(1);
final y = match.group(2);
final z = match.group(3);
final key = '$z/$x/$y';
final bytes = TileProviderWithCache.tileCache[key];
if (bytes != null && bytes.isNotEmpty) {
return Image.memory(bytes, gaplessPlayback: true, fit: BoxFit.cover);
}
}
return tileWidget;
} catch (e) {
print('tileBuilder error: $e for tileImage: ${tileImage.toString()}');
return tileWidget;
}
}
),
_buildTileLayer(appState),
cameraLayers,
// Built-in scale bar from flutter_map
Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(left: 8, bottom: kScaleBarBottom), // from dev_config, above attribution & BottomAppBar
padding: EdgeInsets.only(left: 8, bottom: kScaleBarBottomOffset), // from dev_config
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
@@ -320,110 +340,18 @@ class _MapViewState extends State<MapView> {
],
),
// MODE INDICATOR badge (top-right)
if (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate)
Positioned(
top: 18,
right: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: appState.uploadMode == UploadMode.sandbox
? Colors.orange.withOpacity(0.90)
: Colors.deepPurple.withOpacity(0.80),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)),
],
),
child: Text(
appState.uploadMode == UploadMode.sandbox
? 'SANDBOX MODE'
: 'SIMULATE',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
letterSpacing: 1.1,
),
),
),
),
// Zoom indicator, positioned above scale bar
Positioned(
left: 10,
bottom: kZoomIndicatorBottom, // from dev_config
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: kAttributionBottom, // from dev_config
left: 10,
child: Container(
color: Colors.white70,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: const Text(
'© OpenStreetMap and contributors',
style: TextStyle(fontSize: 11),
),
),
// All map overlays (mode indicator, zoom, attribution, add pin)
MapOverlays(
mapController: _controller,
uploadMode: appState.uploadMode,
session: session,
attribution: appState.selectedTileType?.attribution,
),
// Fixed pin when adding camera
if (session != null)
IgnorePointer(
child: Center(
child: Transform.translate(
offset: Offset(0, kAddPinYOffset),
child: Icon(Icons.place, size: 40, color: Colors.redAccent),
),
),
),
// Network status indicator (top-left)
const NetworkStatusIndicator(),
],
);
}
Polygon _buildCone(LatLng origin, double bearingDeg, double zoom) {
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,104 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/network_status.dart';
class NetworkStatusIndicator extends StatelessWidget {
const NetworkStatusIndicator({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: NetworkStatus.instance,
child: Consumer<NetworkStatus>(
builder: (context, networkStatus, child) {
String message;
IconData icon;
Color color;
switch (networkStatus.currentStatus) {
case NetworkStatusType.waiting:
message = 'Loading...';
icon = Icons.hourglass_empty;
color = Colors.blue;
break;
case NetworkStatusType.timedOut:
message = 'Timed out';
icon = Icons.hourglass_disabled;
color = Colors.orange;
break;
case NetworkStatusType.noData:
message = 'No tiles here';
icon = Icons.cloud_off;
color = Colors.grey;
break;
case NetworkStatusType.success:
message = 'Tiles loaded';
icon = Icons.check_circle;
color = Colors.green;
break;
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.osmTiles:
message = 'OSM tiles slow';
icon = Icons.map_outlined;
color = Colors.orange;
break;
case NetworkIssueType.overpassApi:
message = 'Camera data slow';
icon = Icons.camera_alt_outlined;
color = Colors.orange;
break;
case NetworkIssueType.both:
message = 'Network issues';
icon = Icons.cloud_off_outlined;
color = Colors.red;
break;
default:
return const SizedBox.shrink();
}
break;
case NetworkStatusType.ready:
return const SizedBox.shrink();
}
return Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: color,
),
const SizedBox(width: 4),
Text(
message,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
},
),
);
}
}

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 */;
}