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