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/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 5f2fe4f..448ef41 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,8 +27,17 @@ 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
+ 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;
@@ -40,18 +50,48 @@ 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();
- 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 +101,56 @@ 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();
+ }
+
+ // 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);
@@ -71,6 +161,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);
@@ -116,6 +226,10 @@ class AppState extends ChangeNotifier {
);
_saveQueue();
_session = null;
+
+ // Restart uploader when new items are added
+ _startUploader();
+
notifyListeners();
}
@@ -150,13 +264,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) {
@@ -171,5 +302,20 @@ 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/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 bad1e36..80147e7 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});
@@ -15,48 +18,266 @@ class SettingsScreen extends StatelessWidget {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
+ // Authentication section
ListTile(
leading: Icon(
appState.isLoggedIn ? Icons.person : Icons.login,
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('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
+ }
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(appState.isLoggedIn
+ ? 'Logged in as ${appState.username}'
+ : '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(),
- 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(),
+ // 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.sync),
+ 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 _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,
+ 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'),
+ ),
+ ],
+ ),
+ );
+ }
+}
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/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/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;
}
}
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