bunch of stuff, good luck. still need to fix user-by-mode thing

This commit is contained in:
stopflock
2025-08-06 12:24:32 -05:00
parent 640903d954
commit dca6cb7179
10 changed files with 174 additions and 31 deletions

View File

@@ -2,10 +2,32 @@
A minimal Flutter scaffold for mapping and tagging Flockstyle 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

6
lib/keys.dart Normal file
View 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
View 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';

View File

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

View File

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

View File

@@ -178,7 +178,20 @@ class SettingsScreen extends StatelessWidget {
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 const Text('Upload to the OSM Sandbox (safe for testing, data resets regularly)', style: TextStyle(fontSize: 12, color: Colors.orange));
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));

View File

@@ -9,8 +9,10 @@ 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 OAuth2Helper _helper;
@@ -27,6 +29,7 @@ class AuthService {
final authBase = isSandbox
? 'https://master.apis.dev.openstreetmap.org' // sandbox auth
: 'https://www.openstreetmap.org';
final clientId = isSandbox ? kOsmSandboxClientId : kOsmProdClientId;
final client = OAuth2Client(
authorizeUrl: '$authBase/oauth2/authorize',
tokenUrl: '$authBase/oauth2/token',
@@ -35,11 +38,11 @@ class AuthService {
);
_helper = OAuth2Helper(
client,
clientId: _clientId,
clientId: clientId,
scopes: ['read_prefs', 'write_api'],
enablePKCE: true,
);
print('AuthService: Initialized for $mode with $authBase');
print('AuthService: Initialized for $mode with $authBase and clientId $clientId');
}
Future<bool> isLoggedIn() async =>

View File

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

View File

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

View File

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