allow editing of certain builtin profiles

This commit is contained in:
stopflock
2025-08-29 13:48:08 -05:00
parent 395ef77fe3
commit d2a3e96a86
4 changed files with 80 additions and 31 deletions
+33 -7
View File
@@ -7,6 +7,8 @@ class CameraProfile {
final Map<String, String> tags; final Map<String, String> tags;
final bool builtin; final bool builtin;
final bool requiresDirection; final bool requiresDirection;
final bool submittable;
final bool editable;
CameraProfile({ CameraProfile({
required this.id, required this.id,
@@ -14,9 +16,11 @@ class CameraProfile {
required this.tags, required this.tags,
this.builtin = false, this.builtin = false,
this.requiresDirection = true, this.requiresDirection = true,
this.submittable = true,
this.editable = true,
}); });
/// Builtin default: Generic ALPR camera (view-only) /// Builtin default: Generic ALPR camera (customizable template, not submittable)
factory CameraProfile.genericAlpr() => CameraProfile( factory CameraProfile.genericAlpr() => CameraProfile(
id: 'builtin-generic-alpr', id: 'builtin-generic-alpr',
name: 'Generic ALPR', name: 'Generic ALPR',
@@ -26,6 +30,8 @@ class CameraProfile {
}, },
builtin: true, builtin: true,
requiresDirection: true, requiresDirection: true,
submittable: false,
editable: false,
); );
/// Builtin: Flock Safety ALPR camera /// Builtin: Flock Safety ALPR camera
@@ -43,6 +49,8 @@ class CameraProfile {
}, },
builtin: true, builtin: true,
requiresDirection: true, requiresDirection: true,
submittable: true,
editable: false,
); );
/// Builtin: Motorola Solutions/Vigilant ALPR camera /// Builtin: Motorola Solutions/Vigilant ALPR camera
@@ -60,6 +68,8 @@ class CameraProfile {
}, },
builtin: true, builtin: true,
requiresDirection: true, requiresDirection: true,
submittable: true,
editable: false,
); );
/// Builtin: Genetec ALPR camera /// Builtin: Genetec ALPR camera
@@ -77,6 +87,8 @@ class CameraProfile {
}, },
builtin: true, builtin: true,
requiresDirection: true, requiresDirection: true,
submittable: true,
editable: false,
); );
/// Builtin: Leonardo/ELSAG ALPR camera /// Builtin: Leonardo/ELSAG ALPR camera
@@ -94,6 +106,8 @@ class CameraProfile {
}, },
builtin: true, builtin: true,
requiresDirection: true, requiresDirection: true,
submittable: true,
editable: false,
); );
/// Builtin: Neology ALPR camera /// Builtin: Neology ALPR camera
@@ -110,9 +124,11 @@ class CameraProfile {
}, },
builtin: true, builtin: true,
requiresDirection: true, requiresDirection: true,
submittable: true,
editable: false,
); );
/// Builtin: Generic gunshot detector /// Builtin: Generic gunshot detector (customizable template, not submittable)
factory CameraProfile.genericGunshotDetector() => CameraProfile( factory CameraProfile.genericGunshotDetector() => CameraProfile(
id: 'builtin-generic-gunshot', id: 'builtin-generic-gunshot',
name: 'Generic Gunshot Detector', name: 'Generic Gunshot Detector',
@@ -122,6 +138,8 @@ class CameraProfile {
}, },
builtin: true, builtin: true,
requiresDirection: false, requiresDirection: false,
submittable: false,
editable: false,
); );
/// Builtin: ShotSpotter gunshot detector /// Builtin: ShotSpotter gunshot detector
@@ -137,6 +155,8 @@ class CameraProfile {
}, },
builtin: true, builtin: true,
requiresDirection: false, requiresDirection: false,
submittable: true,
editable: false,
); );
/// Builtin: Flock Raven gunshot detector /// Builtin: Flock Raven gunshot detector
@@ -152,14 +172,12 @@ class CameraProfile {
}, },
builtin: true, builtin: true,
requiresDirection: false, requiresDirection: false,
submittable: true,
editable: false,
); );
/// Returns true if this profile can be used for submissions /// Returns true if this profile can be used for submissions
bool get isSubmittable { bool get isSubmittable => submittable;
if (!builtin) return true; // All custom profiles are submittable
// Only the generic ALPR builtin profile is not submittable
return id != 'builtin-generic-alpr';
}
CameraProfile copyWith({ CameraProfile copyWith({
String? id, String? id,
@@ -167,6 +185,8 @@ class CameraProfile {
Map<String, String>? tags, Map<String, String>? tags,
bool? builtin, bool? builtin,
bool? requiresDirection, bool? requiresDirection,
bool? submittable,
bool? editable,
}) => }) =>
CameraProfile( CameraProfile(
id: id ?? this.id, id: id ?? this.id,
@@ -174,6 +194,8 @@ class CameraProfile {
tags: tags ?? this.tags, tags: tags ?? this.tags,
builtin: builtin ?? this.builtin, builtin: builtin ?? this.builtin,
requiresDirection: requiresDirection ?? this.requiresDirection, requiresDirection: requiresDirection ?? this.requiresDirection,
submittable: submittable ?? this.submittable,
editable: editable ?? this.editable,
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -182,6 +204,8 @@ class CameraProfile {
'tags': tags, 'tags': tags,
'builtin': builtin, 'builtin': builtin,
'requiresDirection': requiresDirection, 'requiresDirection': requiresDirection,
'submittable': submittable,
'editable': editable,
}; };
factory CameraProfile.fromJson(Map<String, dynamic> j) => CameraProfile( factory CameraProfile.fromJson(Map<String, dynamic> j) => CameraProfile(
@@ -190,6 +214,8 @@ class CameraProfile {
tags: Map<String, String>.from(j['tags']), tags: Map<String, String>.from(j['tags']),
builtin: j['builtin'] ?? false, builtin: j['builtin'] ?? false,
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
submittable: j['submittable'] ?? true, // Default to true for backward compatibility
editable: j['editable'] ?? true, // Default to true for backward compatibility
); );
@override @override
+45 -22
View File
@@ -18,6 +18,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
late TextEditingController _nameCtrl; late TextEditingController _nameCtrl;
late List<MapEntry<String, String>> _tags; late List<MapEntry<String, String>> _tags;
late bool _requiresDirection; late bool _requiresDirection;
late bool _submittable;
late bool _editable;
static const _defaultTags = [ static const _defaultTags = [
MapEntry('man_made', 'surveillance'), MapEntry('man_made', 'surveillance'),
@@ -35,6 +37,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
super.initState(); super.initState();
_nameCtrl = TextEditingController(text: widget.profile.name); _nameCtrl = TextEditingController(text: widget.profile.name);
_requiresDirection = widget.profile.requiresDirection; _requiresDirection = widget.profile.requiresDirection;
_submittable = widget.profile.submittable;
_editable = widget.profile.editable;
if (widget.profile.tags.isEmpty) { if (widget.profile.tags.isEmpty) {
// New profile → start with sensible defaults // New profile → start with sensible defaults
@@ -54,7 +58,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.profile.builtin title: Text(!widget.profile.editable
? 'View Profile' ? 'View Profile'
: (widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile')), : (widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile')),
), ),
@@ -63,14 +67,14 @@ class _ProfileEditorState extends State<ProfileEditor> {
children: [ children: [
TextField( TextField(
controller: _nameCtrl, controller: _nameCtrl,
readOnly: widget.profile.builtin, readOnly: !widget.profile.editable,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Profile name', labelText: 'Profile name',
hintText: 'e.g., Custom ALPR Camera', hintText: 'e.g., Custom ALPR Camera',
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (!widget.profile.builtin) if (widget.profile.editable) ...[
CheckboxListTile( CheckboxListTile(
title: const Text('Requires Direction'), title: const Text('Requires Direction'),
subtitle: const Text('Whether cameras of this type need a direction tag'), subtitle: const Text('Whether cameras of this type need a direction tag'),
@@ -78,24 +82,41 @@ class _ProfileEditorState extends State<ProfileEditor> {
onChanged: (value) => setState(() => _requiresDirection = value ?? true), onChanged: (value) => setState(() => _requiresDirection = value ?? true),
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
), ),
const SizedBox(height: 8), if (!widget.profile.builtin) ...[
Row( CheckboxListTile(
mainAxisAlignment: MainAxisAlignment.spaceBetween, title: const Text('Submittable'),
children: [ subtitle: const Text('Whether this profile can be used for submissions'),
const Text('OSM Tags', value: _submittable,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), onChanged: (value) => setState(() => _submittable = value ?? true),
if (!widget.profile.builtin) controlAffinity: ListTileControlAffinity.leading,
TextButton.icon( ),
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))), CheckboxListTile(
icon: const Icon(Icons.add), title: const Text('Editable'),
label: const Text('Add tag'), subtitle: const Text('Whether this profile can be modified after creation'),
), value: _editable,
onChanged: (value) => setState(() => _editable = value ?? true),
controlAffinity: ListTileControlAffinity.leading,
),
], ],
), ],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('OSM Tags',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
if (widget.profile.editable)
TextButton.icon(
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
icon: const Icon(Icons.add),
label: const Text('Add tag'),
),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
..._buildTagRows(), ..._buildTagRows(),
const SizedBox(height: 24), const SizedBox(height: 24),
if (!widget.profile.builtin) if (widget.profile.editable)
ElevatedButton( ElevatedButton(
onPressed: _save, onPressed: _save,
child: const Text('Save Profile'), child: const Text('Save Profile'),
@@ -123,8 +144,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
isDense: true, isDense: true,
), ),
controller: keyController, controller: keyController,
readOnly: widget.profile.builtin, readOnly: !widget.profile.editable,
onChanged: widget.profile.builtin onChanged: !widget.profile.editable
? null ? null
: (v) => _tags[i] = MapEntry(v, _tags[i].value), : (v) => _tags[i] = MapEntry(v, _tags[i].value),
), ),
@@ -139,13 +160,13 @@ class _ProfileEditorState extends State<ProfileEditor> {
isDense: true, isDense: true,
), ),
controller: valueController, controller: valueController,
readOnly: widget.profile.builtin, readOnly: !widget.profile.editable,
onChanged: widget.profile.builtin onChanged: !widget.profile.editable
? null ? null
: (v) => _tags[i] = MapEntry(_tags[i].key, v), : (v) => _tags[i] = MapEntry(_tags[i].key, v),
), ),
), ),
if (!widget.profile.builtin) if (widget.profile.editable)
IconButton( IconButton(
icon: const Icon(Icons.delete, color: Colors.red), icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => setState(() => _tags.removeAt(i)), onPressed: () => setState(() => _tags.removeAt(i)),
@@ -182,6 +203,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
tags: tagMap, tags: tagMap,
builtin: false, builtin: false,
requiresDirection: _requiresDirection, requiresDirection: _requiresDirection,
submittable: _submittable,
editable: _editable,
); );
context.read<AppState>().addOrUpdateProfile(newProfile); context.read<AppState>().addOrUpdateProfile(newProfile);
@@ -44,7 +44,7 @@ class ProfileListSection extends StatelessWidget {
), ),
title: Text(p.name), title: Text(p.name),
subtitle: Text(p.builtin ? 'Built-in' : 'Custom'), subtitle: Text(p.builtin ? 'Built-in' : 'Custom'),
trailing: p.builtin trailing: !p.editable
? PopupMenuButton( ? PopupMenuButton(
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem( PopupMenuItem(
+1 -1
View File
@@ -71,7 +71,7 @@ class ProfileState extends ChangeNotifier {
} }
void deleteProfile(CameraProfile p) { void deleteProfile(CameraProfile p) {
if (p.builtin) return; if (!p.editable) return;
_enabled.remove(p); _enabled.remove(p);
_profiles.removeWhere((x) => x.id == p.id); _profiles.removeWhere((x) => x.id == p.id);
// Safety: Always have at least one enabled profile // Safety: Always have at least one enabled profile