mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
Merge pull request #5 from stopflock/test-modes-sandbox
still cant believe this works
This commit is contained in:
30
README.md
30
README.md
@@ -2,10 +2,32 @@
|
||||
|
||||
A minimal Flutter scaffold for mapping and tagging Flock‑style ALPR cameras in OpenStreetMap.
|
||||
|
||||
# NOTE:
|
||||
Forks should register for their own oauth2 client id from OSM: https://www.openstreetmap.org/oauth2/applications
|
||||
These are hardcoded in lib/services/auth_service.dart for each app.
|
||||
If you discover a bug that causes bad behavior w/rt OSM API, you might want to register a new one for the patched version to distinguish them. You can also then delete the old version from OSM to prevent new people from using the old version.
|
||||
# OAuth Setup
|
||||
|
||||
Before you can upload to OpenStreetMap (production **or sandbox**), you must register your own OAuth2 application on each OSM API you wish to support:
|
||||
- [Production OSM register page](https://www.openstreetmap.org/oauth2/applications)
|
||||
- [Sandbox OSM register page](https://master.apis.dev.openstreetmap.org/oauth2/applications)
|
||||
|
||||
Copy your generated client IDs into a new file:
|
||||
|
||||
```dart
|
||||
// lib/keys.dart
|
||||
const String kOsmProdClientId = 'YOUR_PROD_CLIENT_ID_HERE';
|
||||
const String kOsmSandboxClientId = 'YOUR_SANDBOX_CLIENT_ID_HERE';
|
||||
```
|
||||
|
||||
For open source: use `lib/keys.dart.example` as a template and do **not** commit your real secrets.
|
||||
|
||||
If you discover a bug that causes bad behavior w/rt OSM API, register a new OAuth client to distinguish patched versions and, if needed, delete the old app to prevent misuse.
|
||||
|
||||
# Upload Modes
|
||||
|
||||
In Settings, you can now choose your "Upload Destination":
|
||||
- **Production**: Live OSM database (visible to all users).
|
||||
- **Sandbox**: OSM's dedicated test database; safe for development/testing. [More info](https://wiki.openstreetmap.org/wiki/Sandbox).
|
||||
- **Simulate**: Does not contact any server. Actions are fully offline for testing UI/flows.
|
||||
|
||||
---
|
||||
|
||||
## TODO for Beta/RC Release
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ import 'services/auth_service.dart';
|
||||
import 'services/uploader.dart';
|
||||
import 'services/profile_service.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
|
||||
// ------------------ AddCameraSession ------------------
|
||||
class AddCameraSession {
|
||||
AddCameraSession({required this.profile, this.directionDegrees = 0});
|
||||
@@ -18,6 +21,7 @@ class AddCameraSession {
|
||||
LatLng? target;
|
||||
}
|
||||
|
||||
|
||||
// ------------------ AppState ------------------
|
||||
class AppState extends ChangeNotifier {
|
||||
AppState() {
|
||||
@@ -30,22 +34,26 @@ class AppState extends ChangeNotifier {
|
||||
final List<CameraProfile> _profiles = [];
|
||||
final Set<CameraProfile> _enabled = {};
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
|
||||
// Test mode - prevents actual uploads to OSM
|
||||
bool _testMode = false;
|
||||
static const String _testModePrefsKey = 'test_mode';
|
||||
bool get testMode => _testMode;
|
||||
Future<void> setTestMode(bool enabled) async {
|
||||
_testMode = enabled;
|
||||
|
||||
// Upload mode: production, sandbox, or simulate (in-memory, no uploads)
|
||||
UploadMode _uploadMode = UploadMode.production;
|
||||
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);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_testModePrefsKey, enabled);
|
||||
print('AppState: Test mode ${enabled ? 'enabled' : 'disabled'}');
|
||||
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;
|
||||
|
||||
@@ -58,7 +66,7 @@ class AppState extends ChangeNotifier {
|
||||
_profiles.add(CameraProfile.alpr());
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Load enabled profile IDs and test mode from prefs
|
||||
// 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) {
|
||||
@@ -68,7 +76,21 @@ class AppState extends ChangeNotifier {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
_testMode = prefs.getBool(_testModePrefsKey) ?? false;
|
||||
// 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);
|
||||
}
|
||||
// Ensure AuthService follows loaded mode
|
||||
_auth.setUploadMode(_uploadMode);
|
||||
|
||||
await _loadQueue();
|
||||
|
||||
@@ -287,30 +309,30 @@ class AppState extends ChangeNotifier {
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty) return;
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
final access = await _auth.getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
final item = _queue.first;
|
||||
|
||||
bool ok;
|
||||
if (_testMode) {
|
||||
// Test mode - simulate successful upload without actually calling OSM API
|
||||
print('AppState: Test mode - simulating upload for ${item.coord}');
|
||||
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: Test mode - simulated upload successful');
|
||||
print('AppState: Simulated upload successful');
|
||||
} else {
|
||||
// Real upload
|
||||
// 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 && _testMode) {
|
||||
// In test mode, manually remove from queue since Uploader callback won't be called
|
||||
|
||||
if (ok && _uploadMode == UploadMode.simulate) {
|
||||
// Remove manually for simulate mode
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
|
||||
6
lib/keys.dart
Normal file
6
lib/keys.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
// OpenStreetMap OAuth client IDs for this app.
|
||||
//
|
||||
// NEVER commit real secrets to public repos. For open source, use keys.dart.example instead.
|
||||
|
||||
const String kOsmProdClientId = 'Js6Fn3NR3HEGaD0ZIiHBQlV9LrVcHmsOsDmApHtSyuY'; // example - replace with real
|
||||
const String kOsmSandboxClientId = 'x26twxRKTZwf1a4Ha1a-wkXncBzqnJv8JwtacJope9Q'; // example - replace with real
|
||||
3
lib/keys.dart.example
Normal file
3
lib/keys.dart.example
Normal file
@@ -0,0 +1,3 @@
|
||||
// Example OSM OAuth key config
|
||||
const String kOsmProdClientId = 'YOUR_PROD_CLIENT_ID_HERE';
|
||||
const String kOsmSandboxClientId = 'YOUR_SANDBOX_CLIENT_ID_HERE';
|
||||
@@ -20,7 +20,9 @@ class CameraProfile {
|
||||
name: 'Generic Flock',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance': 'public',
|
||||
'surveillance:zone': 'traffic',
|
||||
'surveillance:type': 'ALPR', // left for backward compatibility — you may want to revisit per OSM best practice
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
|
||||
@@ -20,9 +20,13 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('man_made', 'surveillance'),
|
||||
MapEntry('surveillance', 'public'),
|
||||
MapEntry('surveillance:zone', 'traffic'),
|
||||
MapEntry('surveillance:type', 'ALPR'),
|
||||
MapEntry('camera:type', 'fixed'),
|
||||
MapEntry('camera:mount', ''),
|
||||
MapEntry('manufacturer', ''),
|
||||
MapEntry('manufacturer:wikidata', ''),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@@ -143,22 +143,72 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Test mode toggle
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.bug_report),
|
||||
title: const Text('Test Mode'),
|
||||
subtitle: const Text('Simulate uploads without sending to OSM'),
|
||||
value: appState.testMode,
|
||||
onChanged: (value) => appState.setTestMode(value),
|
||||
// Upload mode selector - Production/Sandbox/Simulate
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cloud_upload),
|
||||
title: const Text('Upload Destination'),
|
||||
subtitle: const Text('Choose where cameras are uploaded'),
|
||||
trailing: DropdownButton<UploadMode>(
|
||||
value: appState.uploadMode,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.production,
|
||||
child: Text('Production'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.sandbox,
|
||||
child: Text('Sandbox'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.simulate,
|
||||
child: Text('Simulate'),
|
||||
),
|
||||
],
|
||||
onChanged: (mode) {
|
||||
if (mode != null) appState.setUploadMode(mode);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Help text
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
switch (appState.uploadMode) {
|
||||
case UploadMode.production:
|
||||
return const Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Colors.black87));
|
||||
case UploadMode.sandbox:
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Uploads go to the OSM Sandbox (safe for testing, resets regularly).',
|
||||
style: TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.redAccent),
|
||||
),
|
||||
],
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return const Text('Simulate uploads (does not contact OSM servers)', style: TextStyle(fontSize: 12, color: Colors.deepPurple));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Queue management
|
||||
ListTile(
|
||||
leading: const Icon(Icons.queue),
|
||||
title: Text('Pending uploads: ${appState.pendingCount}'),
|
||||
subtitle: appState.testMode
|
||||
? const Text('Test mode enabled - uploads simulated')
|
||||
: const Text('Tap to view queue'),
|
||||
subtitle: appState.uploadMode == UploadMode.simulate
|
||||
? const Text('Simulate mode enabled – uploads simulated')
|
||||
: appState.uploadMode == UploadMode.sandbox
|
||||
? const Text('Sandbox mode – uploads go to OSM Sandbox')
|
||||
: const Text('Tap to view queue'),
|
||||
onTap: appState.pendingCount > 0 ? () {
|
||||
_showQueueDialog(context, appState);
|
||||
} : null,
|
||||
|
||||
@@ -7,31 +7,42 @@ import 'package:oauth2_client/oauth2_helper.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Handles PKCE OAuth login with OpenStreetMap.
|
||||
import '../app_state.dart';
|
||||
|
||||
import '../keys.dart';
|
||||
|
||||
class AuthService {
|
||||
static const String _clientId = 'Js6Fn3NR3HEGaD0ZIiHBQlV9LrVcHmsOsDmApHtSyuY';
|
||||
// Both client IDs from keys.dart
|
||||
static const _redirect = 'flockmap://auth';
|
||||
|
||||
late final OAuth2Helper _helper;
|
||||
late OAuth2Helper _helper;
|
||||
String? _displayName;
|
||||
UploadMode _mode = UploadMode.production;
|
||||
|
||||
AuthService() {
|
||||
AuthService({UploadMode mode = UploadMode.production}) {
|
||||
setUploadMode(mode);
|
||||
}
|
||||
|
||||
void setUploadMode(UploadMode mode) {
|
||||
_mode = mode;
|
||||
final isSandbox = (mode == UploadMode.sandbox);
|
||||
final authBase = isSandbox
|
||||
? 'https://master.apis.dev.openstreetmap.org' // sandbox auth
|
||||
: 'https://www.openstreetmap.org';
|
||||
final clientId = isSandbox ? kOsmSandboxClientId : kOsmProdClientId;
|
||||
final client = OAuth2Client(
|
||||
authorizeUrl: 'https://www.openstreetmap.org/oauth2/authorize',
|
||||
tokenUrl: 'https://www.openstreetmap.org/oauth2/token',
|
||||
authorizeUrl: '$authBase/oauth2/authorize',
|
||||
tokenUrl: '$authBase/oauth2/token',
|
||||
redirectUri: _redirect,
|
||||
customUriScheme: 'flockmap',
|
||||
);
|
||||
|
||||
_helper = OAuth2Helper(
|
||||
client,
|
||||
clientId: _clientId,
|
||||
clientId: clientId,
|
||||
scopes: ['read_prefs', 'write_api'],
|
||||
enablePKCE: true,
|
||||
);
|
||||
|
||||
print('AuthService: Initialized with scopes: [read_prefs, write_api]');
|
||||
print('AuthService: Client ID: $_clientId');
|
||||
print('AuthService: Redirect URI: $_redirect');
|
||||
print('AuthService: Initialized for $mode with $authBase and clientId $clientId');
|
||||
}
|
||||
|
||||
Future<bool> isLoggedIn() async =>
|
||||
@@ -81,13 +92,19 @@ class AuthService {
|
||||
|
||||
/* ───────── helper ───────── */
|
||||
|
||||
String get _apiHost {
|
||||
return _mode == UploadMode.sandbox
|
||||
? 'https://api06.dev.openstreetmap.org'
|
||||
: 'https://api.openstreetmap.org';
|
||||
}
|
||||
|
||||
Future<String?> _fetchUsername(String accessToken) async {
|
||||
try {
|
||||
print('AuthService: Fetching username from OSM API...');
|
||||
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('https://api.openstreetmap.org/api/0.6/user/details.json'),
|
||||
Uri.parse('$_apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
print('AuthService: OSM API response status: ${resp.statusCode}');
|
||||
@@ -101,7 +118,7 @@ class AuthService {
|
||||
try {
|
||||
print('AuthService: Checking token permissions...');
|
||||
final permResp = await http.get(
|
||||
Uri.parse('https://api.openstreetmap.org/api/0.6/permissions.json'),
|
||||
Uri.parse('$_apiHost/api/0.6/permissions.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
print('AuthService: Permissions response ${permResp.statusCode}: ${permResp.body}');
|
||||
|
||||
@@ -6,12 +6,17 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/osm_camera_node.dart';
|
||||
|
||||
class OverpassService {
|
||||
static const _endpoint = 'https://overpass-api.de/api/interpreter';
|
||||
import '../app_state.dart';
|
||||
|
||||
class OverpassService {
|
||||
static const _prodEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
static const _sandboxEndpoint = 'https://overpass-api.dev.openstreetmap.org/api/interpreter';
|
||||
|
||||
// You can pass UploadMode, or use production by default
|
||||
Future<List<OsmCameraNode>> fetchCameras(
|
||||
LatLngBounds bbox,
|
||||
List<CameraProfile> profiles,
|
||||
{UploadMode uploadMode = UploadMode.production}
|
||||
) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
@@ -31,25 +36,35 @@ class OverpassService {
|
||||
out body 250;
|
||||
''';
|
||||
|
||||
try {
|
||||
final resp =
|
||||
await http.post(Uri.parse(_endpoint), body: {'data': query.trim()});
|
||||
if (resp.statusCode != 200) return [];
|
||||
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
return elements.whereType<Map<String, dynamic>>().map((e) {
|
||||
return OsmCameraNode(
|
||||
id: e['id'],
|
||||
coord: LatLng(e['lat'], e['lon']),
|
||||
tags: Map<String, String>.from(e['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
} catch (_) {
|
||||
// Network error – return empty list silently
|
||||
return [];
|
||||
Future<List<OsmCameraNode>> fetchFromUri(String endpoint, String query) async {
|
||||
try {
|
||||
print('[Overpass] Querying $endpoint');
|
||||
print('[Overpass] Query:\n$query');
|
||||
final resp = await http.post(Uri.parse(endpoint), body: {'data': query.trim()});
|
||||
print('[Overpass] Status: \\${resp.statusCode}, Length: \\${resp.body.length}');
|
||||
if (resp.statusCode != 200) {
|
||||
print('[Overpass] Failed: \\${resp.body}');
|
||||
return [];
|
||||
}
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
print('[Overpass] Retrieved elements: \\${elements.length}');
|
||||
return elements.whereType<Map<String, dynamic>>().map((e) {
|
||||
return OsmCameraNode(
|
||||
id: e['id'],
|
||||
coord: LatLng(e['lat'], e['lon']),
|
||||
tags: Map<String, String>.from(e['tags'] ?? {}),
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
print('[Overpass] Exception: \\${e}');
|
||||
// Network error – return empty list silently
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from production Overpass for all modes.
|
||||
return await fetchFromUri(_prodEndpoint, query);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@ import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/pending_upload.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
class Uploader {
|
||||
Uploader(this.accessToken, this.onSuccess);
|
||||
Uploader(this.accessToken, this.onSuccess, {this.uploadMode = UploadMode.production});
|
||||
|
||||
final String accessToken;
|
||||
final void Function() onSuccess;
|
||||
final UploadMode uploadMode;
|
||||
|
||||
Future<bool> upload(PendingUpload p) async {
|
||||
try {
|
||||
@@ -68,14 +71,24 @@ class Uploader {
|
||||
}
|
||||
}
|
||||
|
||||
String get _host {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.sandbox:
|
||||
return 'api06.dev.openstreetmap.org';
|
||||
case UploadMode.production:
|
||||
default:
|
||||
return 'api.openstreetmap.org';
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.Response> _post(String path, String body) => http.post(
|
||||
Uri.https('api.openstreetmap.org', path),
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
body: body,
|
||||
);
|
||||
|
||||
Future<http.Response> _put(String path, String body) => http.put(
|
||||
Uri.https('api.openstreetmap.org', path),
|
||||
Uri.https(_host, path),
|
||||
headers: _headers,
|
||||
body: body,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,9 @@ class AddCameraSheet extends StatelessWidget {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
final customProfiles = appState.enabledProfiles.where((p) => !p.builtin).toList();
|
||||
final allowSubmit = customProfiles.isNotEmpty && !session.profile.builtin;
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
@@ -64,6 +67,38 @@ class AddCameraSheet extends StatelessWidget {
|
||||
onChanged: (v) => appState.updateSession(directionDeg: v),
|
||||
),
|
||||
),
|
||||
if (customProfiles.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.info_outline, color: Colors.red, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Enable or create a custom profile in Settings to submit new cameras.',
|
||||
style: TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (session.profile.builtin)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.info_outline, color: Colors.orange, size: 20),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'The built-in profile is for map viewing only. Please select a custom profile to submit new cameras.',
|
||||
style: TextStyle(color: Colors.orange, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
@@ -78,7 +113,7 @@ class AddCameraSheet extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _commit,
|
||||
onPressed: allowSubmit ? _commit : null,
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -82,7 +82,11 @@ class _MapViewState extends State<MapView> {
|
||||
} catch (_) {
|
||||
return; // controller not ready yet
|
||||
}
|
||||
final cams = await _overpass.fetchCameras(bounds, appState.enabledProfiles);
|
||||
final cams = await _overpass.fetchCameras(
|
||||
bounds,
|
||||
appState.enabledProfiles,
|
||||
uploadMode: appState.uploadMode,
|
||||
);
|
||||
if (mounted) setState(() => _cameras = cams);
|
||||
}
|
||||
|
||||
@@ -99,6 +103,12 @@ class _MapViewState extends State<MapView> {
|
||||
final appState = context.watch<AppState>();
|
||||
final session = appState.session;
|
||||
|
||||
// Always watch for changes on uploadMode/profiles and refresh if needed
|
||||
// (debounced, to avoid flooding when quickly toggling)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_debounce(() => _refreshCameras(appState));
|
||||
});
|
||||
|
||||
// Seed add‑mode target once, after first controller center is available.
|
||||
if (session != null && session.target == null) {
|
||||
try {
|
||||
@@ -181,6 +191,36 @@ 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Attribution overlay
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
|
||||
Reference in New Issue
Block a user