From 2e1b5044199b9dddb189044121bd556e9ec13ebc Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 19 Jul 2025 19:49:17 -0500 Subject: [PATCH 1/4] upload working --- lib/app_state.dart | 72 ++++++++++++++++++++++++++++++-- lib/screens/settings_screen.dart | 42 ++++++++++++++++++- lib/services/auth_service.dart | 69 ++++++++++++++++++++++++++---- lib/services/uploader.dart | 31 +++++++++++--- 4 files changed, 194 insertions(+), 20 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index 5f2fe4f..b359ec0 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -42,16 +42,42 @@ class AppState extends ChangeNotifier { Future _init() async { _enabled.addAll(_profiles); await _loadQueue(); - if (await _auth.isLoggedIn()) { - _username = await _auth.login(); + + // 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'); } + _startUploader(); notifyListeners(); } // ---------- Auth ---------- Future login() async { - _username = await _auth.login(); + 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(); } @@ -61,6 +87,46 @@ class AppState extends ChangeNotifier { notifyListeners(); } + // Add method to refresh auth state + Future 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(); + } + + // Force a completely fresh login (clears stored tokens) + Future 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(); + } + // ---------- Profiles ---------- List get profiles => List.unmodifiable(_profiles); bool isEnabled(CameraProfile p) => _enabled.contains(p); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index bad1e36..5c7139e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -21,8 +21,11 @@ class SettingsScreen extends StatelessWidget { color: appState.isLoggedIn ? Colors.green : null, ), title: Text(appState.isLoggedIn - ? 'Logged in as ${appState.username}' - : 'Log in to OpenStreetMap'), + ? 'Logged in as ${appState.username}' + : 'Log in to OpenStreetMap'), + subtitle: appState.isLoggedIn + ? const Text('Tap to logout') + : const Text('Tap to login'), onTap: () async { if (appState.isLoggedIn) { await appState.logout(); @@ -31,6 +34,41 @@ class SettingsScreen extends StatelessWidget { } }, ), + ListTile( + leading: const Icon(Icons.refresh), + title: const Text('Refresh Login Status'), + subtitle: const Text('Check if you\'re already logged in'), + onTap: () async { + await appState.refreshAuthState(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(appState.isLoggedIn + ? 'Logged in as ${appState.username}' + : 'Not logged in'), + ), + ); + } + }, + ), + ListTile( + leading: const Icon(Icons.login_outlined), + title: const Text('Force Fresh Login'), + subtitle: const Text('Clear stored tokens and login again'), + onTap: () async { + await appState.forceLogin(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(appState.isLoggedIn + ? 'Fresh login successful: ${appState.username}' + : 'Fresh login failed'), + backgroundColor: appState.isLoggedIn ? Colors.green : Colors.red, + ), + ); + } + }, + ), if (appState.isLoggedIn) ListTile( leading: const Icon(Icons.cloud_upload), diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 2949e14..b6d80d4 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:developer'; +import 'dart:math' as math; import 'package:oauth2_client/oauth2_client.dart'; import 'package:oauth2_client/oauth2_helper.dart'; @@ -7,7 +8,7 @@ import 'package:http/http.dart' as http; /// Handles PKCE OAuth login with OpenStreetMap. class AuthService { - static const String _clientId = 'HNbRD_Twxf0_lpkm-BmMB7-zb-v63VLdf_bVlNyU9qs'; + static const String _clientId = 'Js6Fn3NR3HEGaD0ZIiHBQlV9LrVcHmsOsDmApHtSyuY'; static const _redirect = 'flockmap://auth'; late final OAuth2Helper _helper; @@ -24,9 +25,13 @@ class AuthService { _helper = OAuth2Helper( client, clientId: _clientId, - scopes: ['write_api'], + 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'); } Future isLoggedIn() async => @@ -36,14 +41,23 @@ class AuthService { Future login() async { 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; } + 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'); log('OAuth login failed: $e'); rethrow; } @@ -54,21 +68,58 @@ class AuthService { _displayName = null; } + // Force a fresh login by clearing stored tokens + Future forceLogin() async { + print('AuthService: Forcing fresh login by clearing stored tokens...'); + await _helper.removeAllTokens(); + _displayName = null; + return await login(); + } + Future getAccessToken() async => (await _helper.getTokenFromStorage())?.accessToken; /* ───────── helper ───────── */ Future _fetchUsername(String accessToken) async { - final resp = await http.get( - Uri.parse('https://api.openstreetmap.org/api/0.6/user/details.json'), - headers: {'Authorization': 'Bearer $accessToken'}, - ); - if (resp.statusCode != 200) { - log('fetchUsername response ${resp.statusCode}: ${resp.body}'); + try { + print('AuthService: Fetching username from OSM API...'); + 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'), + 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('https://api.openstreetmap.org/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'); + log('Error fetching username: $e'); return null; } - return jsonDecode(resp.body)['user']?['display_name']; } } diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index 624f865..e0d2182 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -11,6 +11,8 @@ class Uploader { Future upload(PendingUpload p) async { try { + print('Uploader: Starting upload for camera at ${p.coord.latitude}, ${p.coord.longitude}'); + // 1. open changeset final csXml = ''' @@ -19,9 +21,15 @@ class Uploader { '''; - final csResp = await _post('/api/0.6/changeset/create', csXml); - if (csResp.statusCode != 200) return false; - final csId = csResp.body; + print('Uploader: Creating changeset...'); + final csResp = await _put('/api/0.6/changeset/create', csXml); + print('Uploader: Changeset response: ${csResp.statusCode} - ${csResp.body}'); + if (csResp.statusCode != 200) { + print('Uploader: Failed to create changeset'); + return false; + } + final csId = csResp.body.trim(); + print('Uploader: Created changeset ID: $csId'); // 2. create node final nodeXml = ''' @@ -33,15 +41,26 @@ class Uploader { '''; + print('Uploader: Creating node...'); final nodeResp = await _put('/api/0.6/node/create', nodeXml); - if (nodeResp.statusCode != 200) return false; + print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}'); + if (nodeResp.statusCode != 200) { + print('Uploader: Failed to create node'); + return false; + } + final nodeId = nodeResp.body.trim(); + print('Uploader: Created node ID: $nodeId'); // 3. close changeset - await _put('/api/0.6/changeset/$csId/close', ''); + print('Uploader: Closing changeset...'); + final closeResp = await _put('/api/0.6/changeset/$csId/close', ''); + print('Uploader: Close response: ${closeResp.statusCode}'); + print('Uploader: Upload successful!'); onSuccess(); return true; - } catch (_) { + } catch (e) { + print('Uploader: Upload failed with error: $e'); return false; } } From e290e11c5b32da9a7e867b022dfb608f37c894b5 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 19 Jul 2025 19:58:26 -0500 Subject: [PATCH 2/4] test mode to disable uploads, queue view/edit/clear --- lib/app_state.dart | 50 ++++++++++++-- lib/screens/settings_screen.dart | 108 ++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index b359ec0..2bbb5d6 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -28,6 +28,15 @@ class AppState extends ChangeNotifier { late final List _profiles = [CameraProfile.alpr()]; final Set _enabled = {}; + + // Test mode - prevents actual uploads to OSM + bool _testMode = false; + bool get testMode => _testMode; + void setTestMode(bool enabled) { + _testMode = enabled; + print('AppState: Test mode ${enabled ? 'enabled' : 'disabled'}'); + notifyListeners(); + } AddCameraSession? _session; AddCameraSession? get session => _session; @@ -216,13 +225,30 @@ class AppState extends ChangeNotifier { if (access == null) return; // not logged in final item = _queue.first; - final up = Uploader(access, () { + + bool ok; + if (_testMode) { + // Test mode - simulate successful upload without actually calling OSM API + print('AppState: Test mode - simulating upload for ${item.coord}'); + await Future.delayed(const Duration(seconds: 1)); // Simulate network delay + ok = true; + print('AppState: Test mode - simulated upload successful'); + } else { + // Real upload + final up = Uploader(access, () { + _queue.remove(item); + _saveQueue(); + notifyListeners(); + }); + ok = await up.upload(item); + } + + if (ok && _testMode) { + // In test mode, manually remove from queue since Uploader callback won't be called _queue.remove(item); _saveQueue(); notifyListeners(); - }); - - final ok = await up.upload(item); + } if (!ok) { item.attempts++; if (item.attempts >= 3) { @@ -237,5 +263,21 @@ class AppState extends ChangeNotifier { // ---------- Exposed getters ---------- int get pendingCount => _queue.length; + List get pendingUploads => List.unmodifiable(_queue); + + // ---------- Queue management ---------- + void clearQueue() { + print('AppState: Clearing upload queue (${_queue.length} items)'); + _queue.clear(); + _saveQueue(); + notifyListeners(); + } + + void removeFromQueue(PendingUpload upload) { + print('AppState: Removing upload from queue: ${upload.coord}'); + _queue.remove(upload); + _saveQueue(); + notifyListeners(); + } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 5c7139e..139ca6c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -88,10 +88,112 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(), - ListTile( - leading: const Icon(Icons.sync), - title: Text('Pending uploads: ${appState.pendingCount}'), + // 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), ), + 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'), + onTap: appState.pendingCount > 0 ? () { + _showQueueDialog(context, appState); + } : null, + ), + if (appState.pendingCount > 0) + ListTile( + leading: const Icon(Icons.clear_all), + title: const Text('Clear Upload Queue'), + subtitle: Text('Remove all ${appState.pendingCount} pending uploads'), + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Queue'), + content: Text('Remove all ${appState.pendingCount} pending uploads?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.clearQueue(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Queue cleared')), + ); + }, + child: const Text('Clear'), + ), + ], + ), + ); + }, + ), + ], + ), + ); + } + + void _showQueueDialog(BuildContext context, AppState appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Upload Queue (${appState.pendingCount} items)'), + content: SizedBox( + width: double.maxFinite, + height: 300, + child: ListView.builder( + itemCount: appState.pendingUploads.length, + itemBuilder: (context, index) { + final upload = appState.pendingUploads[index]; + return ListTile( + leading: const Icon(Icons.camera_alt), + title: Text('Camera ${index + 1}'), + subtitle: Text( + 'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n' + 'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n' + 'Direction: ${upload.direction.round()}°\n' + 'Attempts: ${upload.attempts}' + ), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + appState.removeFromQueue(upload); + if (appState.pendingCount == 0) { + Navigator.pop(context); + } + }, + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + if (appState.pendingCount > 1) + TextButton( + onPressed: () { + appState.clearQueue(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Queue cleared')), + ); + }, + child: const Text('Clear All'), + ), ], ), ); From f8d71d0c7575632180b614a48b64e26d8ea0f40f Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 19 Jul 2025 20:22:20 -0500 Subject: [PATCH 3/4] fixes, improvements, and cleanup. notably, custom camera profiles make a comeback. --- README.md | 24 +++-- lib/app_state.dart | 27 ++++- lib/main.dart | 2 - lib/models/camera_profile.dart | 47 ++++++-- lib/screens/add_camera_screen.dart | 21 ---- lib/screens/profile_editor.dart | 167 +++++++++++++++++++++++++++++ lib/screens/settings_screen.dart | 100 ++++++++++++++++- lib/services/profile_service.dart | 29 +++++ pubspec.lock | 2 +- pubspec.yaml | 1 + 10 files changed, 375 insertions(+), 45 deletions(-) delete mode 100644 lib/screens/add_camera_screen.dart create mode 100644 lib/screens/profile_editor.dart create mode 100644 lib/services/profile_service.dart diff --git a/README.md b/README.md index fa2d99f..cd9bbff 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,25 @@ Forks should register for their own oauth2 client id from OSM: https://www.opens 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. +## TODO for Beta/RC Release -## Platform setup notes -### iOS -Add location permission strings to `ios/Runner/Info.plist`: -```xml -NSLocationWhenInUseUsageDescription -This app needs your location to show nearby cameras. -``` +### COMPLETED +- Queue view/retry/clear - Implemented with test mode support +- Fix login not opening browser - Fixed OAuth scope and client ID issues +- Add "new profile" text to button in settings - Enhanced profile management UI +- Profile management (create/edit/delete) - Full CRUD operations integrated +### 🔄 REMAINING FOR BETA/RC +- Better icons for cameras, prettier/wider FOV cones +- North up mode, satellite view mode +- Error handling when clicking "add camera" but no profiles enabled +- Camera point details popup (tap to view full details, edit if user-submitted) +- One-time popup about "this app trusts the user to know what they are doing" + credits/attributions +- Optional height tag for cameras +- More (unspecified items) + +### FUTURE (Post-Beta) +- Wayfinding to avoid cameras ## Stuff for build env # Install from GUI: diff --git a/lib/app_state.dart b/lib/app_state.dart index 2bbb5d6..8053745 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -8,6 +8,7 @@ import 'models/camera_profile.dart'; import 'models/pending_upload.dart'; import 'services/auth_service.dart'; import 'services/uploader.dart'; +import 'services/profile_service.dart'; // ------------------ AddCameraSession ------------------ class AddCameraSession { @@ -26,7 +27,7 @@ class AppState extends ChangeNotifier { final _auth = AuthService(); String? _username; - late final List _profiles = [CameraProfile.alpr()]; + final List _profiles = []; final Set _enabled = {}; // Test mode - prevents actual uploads to OSM @@ -49,7 +50,11 @@ class AppState extends ChangeNotifier { // ---------- Init ---------- Future _init() async { + // Initialize profiles: built-in + custom + _profiles.add(CameraProfile.alpr()); + _profiles.addAll(await ProfileService().load()); _enabled.addAll(_profiles); + await _loadQueue(); // Check if we're already logged in and get username @@ -146,6 +151,26 @@ class AppState extends ChangeNotifier { 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); + } + ProfileService().save(_profiles); + notifyListeners(); + } + + void deleteProfile(CameraProfile p) { + if (p.builtin) return; + _enabled.remove(p); + _profiles.removeWhere((x) => x.id == p.id); + ProfileService().save(_profiles); + notifyListeners(); + } + // ---------- Add‑camera session ---------- void startAddSession() { _session = AddCameraSession(profile: enabledProfiles.first); diff --git a/lib/main.dart b/lib/main.dart index c8d6774..9d85692 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import 'app_state.dart'; import 'screens/home_screen.dart'; -import 'screens/add_camera_screen.dart'; import 'screens/settings_screen.dart'; void main() { @@ -28,7 +27,6 @@ class FlockMapApp extends StatelessWidget { ), routes: { '/': (context) => const HomeScreen(), - '/add': (context) => const AddCameraScreen(), '/settings': (context) => const SettingsScreen(), }, initialRoute: '/', diff --git a/lib/models/camera_profile.dart b/lib/models/camera_profile.dart index 09d06d2..f71c479 100644 --- a/lib/models/camera_profile.dart +++ b/lib/models/camera_profile.dart @@ -1,33 +1,64 @@ +import 'package:uuid/uuid.dart'; + /// A bundle of preset OSM tags that describe a particular camera model/type. class CameraProfile { + final String id; final String name; final Map tags; + final bool builtin; - const CameraProfile({ + CameraProfile({ + required this.id, required this.name, required this.tags, + this.builtin = false, }); - // Built‑in ALPR profile (Flock Falcon‑style). - factory CameraProfile.alpr() => const CameraProfile( - name: 'ALPR Camera', - tags: { + /// Built‑in default: Generic Flock ALPR camera + factory CameraProfile.alpr() => CameraProfile( + id: 'builtin-alpr', + name: 'Generic Flock', + tags: const { 'man_made': 'surveillance', 'surveillance:type': 'ALPR', - 'surveillance': 'public', - 'surveillance:zone': 'traffic', 'camera:type': 'fixed', - 'camera:mount': 'pole', + 'manufacturer': 'Flock Safety', + 'manufacturer:wikidata': 'Q108485435', }, + builtin: true, ); CameraProfile copyWith({ + String? id, String? name, Map? tags, + bool? builtin, }) => CameraProfile( + id: id ?? this.id, name: name ?? this.name, tags: tags ?? this.tags, + builtin: builtin ?? this.builtin, ); + + Map toJson() => + {'id': id, 'name': name, 'tags': tags, 'builtin': builtin}; + + factory CameraProfile.fromJson(Map j) => CameraProfile( + id: j['id'], + name: j['name'], + tags: Map.from(j['tags']), + builtin: j['builtin'] ?? false, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CameraProfile && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; } diff --git a/lib/screens/add_camera_screen.dart b/lib/screens/add_camera_screen.dart deleted file mode 100644 index 598b476..0000000 --- a/lib/screens/add_camera_screen.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/material.dart'; - -class AddCameraScreen extends StatelessWidget { - const AddCameraScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Add Camera'), - ), - body: const Center( - child: Text( - 'Add‑Camera UI coming in Stage 3', - style: TextStyle(fontSize: 18), - ), - ), - ); - } -} - diff --git a/lib/screens/profile_editor.dart b/lib/screens/profile_editor.dart new file mode 100644 index 0000000..f0012e9 --- /dev/null +++ b/lib/screens/profile_editor.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/camera_profile.dart'; +import '../app_state.dart'; + +class ProfileEditor extends StatefulWidget { + const ProfileEditor({super.key, required this.profile}); + + final CameraProfile profile; + + @override + State createState() => _ProfileEditorState(); +} + +class _ProfileEditorState extends State { + late TextEditingController _nameCtrl; + late List> _tags; + + static const _defaultTags = [ + MapEntry('man_made', 'surveillance'), + MapEntry('surveillance:type', 'ALPR'), + MapEntry('camera:type', 'fixed'), + MapEntry('manufacturer', ''), + ]; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(text: widget.profile.name); + + if (widget.profile.tags.isEmpty) { + // New profile → start with sensible defaults + _tags = [..._defaultTags]; + } else { + _tags = widget.profile.tags.entries.toList(); + } + } + + @override + void dispose() { + _nameCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: + Text(widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: _nameCtrl, + decoration: const InputDecoration( + labelText: 'Profile name', + hintText: 'e.g., Custom ALPR Camera', + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('OSM Tags', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + TextButton.icon( + onPressed: () => setState(() => _tags.add(const MapEntry('', ''))), + icon: const Icon(Icons.add), + label: const Text('Add tag'), + ), + ], + ), + const SizedBox(height: 8), + ..._buildTagRows(), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _save, + child: const Text('Save Profile'), + ), + ], + ), + ); + } + + List _buildTagRows() { + return List.generate(_tags.length, (i) { + final keyController = TextEditingController(text: _tags[i].key); + final valueController = TextEditingController(text: _tags[i].value); + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Expanded( + flex: 2, + child: TextField( + decoration: const InputDecoration( + hintText: 'key', + border: OutlineInputBorder(), + isDense: true, + ), + controller: keyController, + onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: TextField( + decoration: const InputDecoration( + hintText: 'value', + border: OutlineInputBorder(), + isDense: true, + ), + controller: valueController, + onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v), + ), + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => setState(() => _tags.removeAt(i)), + ), + ], + ), + ); + }); + } + + void _save() { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Profile name is required'))); + return; + } + + final tagMap = {}; + for (final e in _tags) { + if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue; + tagMap[e.key.trim()] = e.value.trim(); + } + + if (tagMap.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('At least one tag is required'))); + return; + } + + final newProfile = widget.profile.copyWith( + id: widget.profile.id.isEmpty ? const Uuid().v4() : widget.profile.id, + name: name, + tags: tagMap, + builtin: false, + ); + + context.read().addOrUpdateProfile(newProfile); + Navigator.pop(context); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Profile "${newProfile.name}" saved')), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 139ca6c..8ea5d40 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; import '../app_state.dart'; +import '../models/camera_profile.dart'; +import 'profile_editor.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -78,13 +81,73 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(), - const Text('Camera Profiles', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Camera Profiles', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + TextButton.icon( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProfileEditor( + profile: CameraProfile( + id: const Uuid().v4(), + name: '', + tags: const {}, + ), + ), + ), + ), + icon: const Icon(Icons.add), + label: const Text('New Profile'), + ), + ], + ), ...appState.profiles.map( - (p) => SwitchListTile( + (p) => ListTile( + leading: Checkbox( + value: appState.isEnabled(p), + onChanged: (v) => appState.toggleProfile(p, v ?? false), + ), title: Text(p.name), - value: appState.isEnabled(p), - onChanged: (v) => appState.toggleProfile(p, v), + subtitle: Text(p.builtin ? 'Built-in' : 'Custom'), + trailing: p.builtin ? null : PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + value: 'edit', + child: const Row( + children: [ + Icon(Icons.edit), + SizedBox(width: 8), + Text('Edit'), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: const Row( + children: [ + Icon(Icons.delete, color: Colors.red), + SizedBox(width: 8), + Text('Delete', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'edit') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProfileEditor(profile: p), + ), + ); + } else if (value == 'delete') { + _showDeleteProfileDialog(context, appState, p); + } + }, + ), ), ), const Divider(), @@ -144,6 +207,33 @@ class SettingsScreen extends StatelessWidget { ); } + void _showDeleteProfileDialog(BuildContext context, AppState appState, CameraProfile profile) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Profile'), + content: Text('Are you sure you want to delete "${profile.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.deleteProfile(profile); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profile deleted')), + ); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + } + void _showQueueDialog(BuildContext context, AppState appState) { showDialog( context: context, diff --git a/lib/services/profile_service.dart b/lib/services/profile_service.dart new file mode 100644 index 0000000..3ee9ddc --- /dev/null +++ b/lib/services/profile_service.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/camera_profile.dart'; + +class ProfileService { + static const _key = 'custom_profiles'; + + Future> load() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString(_key); + if (jsonStr == null) return []; + final list = jsonDecode(jsonStr) as List; + return list.map((e) => CameraProfile.fromJson(e)).toList(); + } + + Future save(List profiles) async { + final prefs = await SharedPreferences.getInstance(); + + // MUST convert to List before jsonEncode; the previous MappedIterable + // caused "Converting object to an encodable object failed". + final encodable = profiles + .where((p) => !p.builtin) + .map((p) => p.toJson()) + .toList(); // <- crucial + + await prefs.setString(_key, jsonEncode(encodable)); + } +} diff --git a/pubspec.lock b/pubspec.lock index af05978..f62b0ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -569,7 +569,7 @@ packages: source: hosted version: "3.1.4" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff diff --git a/pubspec.yaml b/pubspec.yaml index a16f956..9a5d591 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: # Persistence shared_preferences: ^2.2.2 + uuid: ^4.0.0 flutter: uses-material-design: true From 978223f7bd098474f4e2deb6d245b7379074f8d1 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 19 Jul 2025 22:01:19 -0500 Subject: [PATCH 4/4] clean up settings, hopefully fix delay between submit and upload --- android/app/src/main/AndroidManifest.xml | 4 +- lib/app_state.dart | 15 +++++- lib/screens/settings_screen.dart | 61 ++++++++++-------------- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 65034ea..651fab9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,8 @@ + + + @@ -69,4 +72,3 @@ - diff --git a/lib/app_state.dart b/lib/app_state.dart index 8053745..448ef41 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -141,6 +141,16 @@ class AppState extends ChangeNotifier { notifyListeners(); } + // Validate current token/credentials + Future validateToken() async { + try { + return await _auth.isLoggedIn(); + } catch (e) { + print('AppState: Token validation error: $e'); + return false; + } + } + // ---------- Profiles ---------- List get profiles => List.unmodifiable(_profiles); bool isEnabled(CameraProfile p) => _enabled.contains(p); @@ -216,6 +226,10 @@ class AppState extends ChangeNotifier { ); _saveQueue(); _session = null; + + // Restart uploader when new items are added + _startUploader(); + notifyListeners(); } @@ -305,4 +319,3 @@ class AppState extends ChangeNotifier { notifyListeners(); } } - diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8ea5d40..80147e7 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -18,6 +18,7 @@ class SettingsScreen extends StatelessWidget { body: ListView( padding: const EdgeInsets.all(16), children: [ + // Authentication section ListTile( leading: Icon( appState.isLoggedIn ? Icons.person : Icons.login, @@ -28,57 +29,48 @@ class SettingsScreen extends StatelessWidget { : 'Log in to OpenStreetMap'), subtitle: appState.isLoggedIn ? const Text('Tap to logout') - : const Text('Tap to login'), + : const Text('Required to submit camera data'), onTap: () async { if (appState.isLoggedIn) { await appState.logout(); } else { - await appState.login(); + await appState.forceLogin(); // Use force login as the primary method } - }, - ), - ListTile( - leading: const Icon(Icons.refresh), - title: const Text('Refresh Login Status'), - subtitle: const Text('Check if you\'re already logged in'), - onTap: () async { - await appState.refreshAuthState(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(appState.isLoggedIn ? 'Logged in as ${appState.username}' - : 'Not logged in'), - ), - ); - } - }, - ), - ListTile( - leading: const Icon(Icons.login_outlined), - title: const Text('Force Fresh Login'), - subtitle: const Text('Clear stored tokens and login again'), - onTap: () async { - await appState.forceLogin(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(appState.isLoggedIn - ? 'Fresh login successful: ${appState.username}' - : 'Fresh login failed'), - backgroundColor: appState.isLoggedIn ? Colors.green : Colors.red, + : 'Logged out'), + backgroundColor: appState.isLoggedIn ? Colors.green : Colors.grey, ), ); } }, ), + // Test connection (only when logged in) if (appState.isLoggedIn) ListTile( - leading: const Icon(Icons.cloud_upload), - title: const Text('Test upload'), - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Upload will run soon...')), - ), + leading: const Icon(Icons.wifi_protected_setup), + title: const Text('Test Connection'), + subtitle: const Text('Verify OSM credentials are working'), + onTap: () async { + final isValid = await appState.validateToken(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(isValid + ? 'Connection OK - credentials are valid' + : 'Connection failed - please re-login'), + backgroundColor: isValid ? Colors.green : Colors.red, + ), + ); + } + if (!isValid) { + // Auto-logout if token is invalid + await appState.logout(); + } + }, ), const Divider(), Row( @@ -289,4 +281,3 @@ class SettingsScreen extends StatelessWidget { ); } } -