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