From 208b3486f3e813bcf5c6725593188dbd530b53e6 Mon Sep 17 00:00:00 2001 From: stopflock Date: Fri, 29 Aug 2025 15:09:19 -0500 Subject: [PATCH] first pass at operator profiles --- lib/app_state.dart | 23 +++ lib/dev_config.dart | 2 +- lib/models/operator_profile.dart | 48 ++++++ lib/models/pending_upload.dart | 24 +++ lib/screens/operator_profile_editor.dart | 158 ++++++++++++++++++ lib/screens/settings_screen.dart | 3 + .../operator_profile_list_section.dart | 122 ++++++++++++++ lib/services/operator_profile_service.dart | 22 +++ lib/services/uploader.dart | 5 +- lib/state/operator_profile_state.dart | 31 ++++ lib/state/session_state.dart | 13 ++ lib/state/upload_queue_state.dart | 12 +- lib/widgets/add_camera_sheet.dart | 31 ++++ lib/widgets/edit_camera_sheet.dart | 31 ++++ lib/widgets/refine_tags_sheet.dart | 157 +++++++++++++++++ 15 files changed, 669 insertions(+), 13 deletions(-) create mode 100644 lib/models/operator_profile.dart create mode 100644 lib/screens/operator_profile_editor.dart create mode 100644 lib/screens/settings_screen_sections/operator_profile_list_section.dart create mode 100644 lib/services/operator_profile_service.dart create mode 100644 lib/state/operator_profile_state.dart create mode 100644 lib/widgets/refine_tags_sheet.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index c8d5cdb..920f691 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -3,11 +3,13 @@ import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'models/camera_profile.dart'; +import 'models/operator_profile.dart'; import 'models/osm_camera_node.dart'; import 'models/pending_upload.dart'; import 'models/tile_provider.dart'; import 'services/offline_area_service.dart'; import 'state/auth_state.dart'; +import 'state/operator_profile_state.dart'; import 'state/profile_state.dart'; import 'state/session_state.dart'; import 'state/settings_state.dart'; @@ -23,6 +25,7 @@ class AppState extends ChangeNotifier { // State modules late final AuthState _authState; + late final OperatorProfileState _operatorProfileState; late final ProfileState _profileState; late final SessionState _sessionState; late final SettingsState _settingsState; @@ -33,6 +36,7 @@ class AppState extends ChangeNotifier { AppState() { instance = this; _authState = AuthState(); + _operatorProfileState = OperatorProfileState(); _profileState = ProfileState(); _sessionState = SessionState(); _settingsState = SettingsState(); @@ -40,6 +44,7 @@ class AppState extends ChangeNotifier { // Set up state change listeners _authState.addListener(_onStateChanged); + _operatorProfileState.addListener(_onStateChanged); _profileState.addListener(_onStateChanged); _sessionState.addListener(_onStateChanged); _settingsState.addListener(_onStateChanged); @@ -60,6 +65,9 @@ class AppState extends ChangeNotifier { List get enabledProfiles => _profileState.enabledProfiles; bool isEnabled(CameraProfile p) => _profileState.isEnabled(p); + // Operator profile state + List get operatorProfiles => _operatorProfileState.profiles; + // Session state AddCameraSession? get session => _sessionState.session; EditCameraSession? get editSession => _sessionState.editSession; @@ -89,6 +97,7 @@ class AppState extends ChangeNotifier { Future _init() async { // Initialize all state modules await _settingsState.init(); + await _operatorProfileState.init(); await _profileState.init(); await _uploadQueueState.init(); await _authState.init(_settingsState.uploadMode); @@ -137,6 +146,15 @@ class AppState extends ChangeNotifier { _profileState.deleteProfile(p); } + // ---------- Operator Profile Methods ---------- + void addOrUpdateOperatorProfile(OperatorProfile p) { + _operatorProfileState.addOrUpdateProfile(p); + } + + void deleteOperatorProfile(OperatorProfile p) { + _operatorProfileState.deleteProfile(p); + } + // ---------- Session Methods ---------- void startAddSession() { _sessionState.startAddSession(enabledProfiles); @@ -149,11 +167,13 @@ class AppState extends ChangeNotifier { void updateSession({ double? directionDeg, CameraProfile? profile, + OperatorProfile? operatorProfile, LatLng? target, }) { _sessionState.updateSession( directionDeg: directionDeg, profile: profile, + operatorProfile: operatorProfile, target: target, ); } @@ -161,11 +181,13 @@ class AppState extends ChangeNotifier { void updateEditSession({ double? directionDeg, CameraProfile? profile, + OperatorProfile? operatorProfile, LatLng? target, }) { _sessionState.updateEditSession( directionDeg: directionDeg, profile: profile, + operatorProfile: operatorProfile, target: target, ); } @@ -262,6 +284,7 @@ class AppState extends ChangeNotifier { @override void dispose() { _authState.removeListener(_onStateChanged); + _operatorProfileState.removeListener(_onStateChanged); _profileState.removeListener(_onStateChanged); _sessionState.removeListener(_onStateChanged); _settingsState.removeListener(_onStateChanged); diff --git a/lib/dev_config.dart b/lib/dev_config.dart index d5137c8..35fd929 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -11,7 +11,7 @@ const double kTileEstimateKb = 25.0; // Direction cone for map view const double kDirectionConeHalfAngle = 30.0; // degrees const double kDirectionConeBaseLength = 0.001; // multiplier -const Color kDirectionConeColor = Color(0xFF111111); // FOV cone color +const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color // Margin (bottom) for positioning the floating bottom button bar const double kBottomButtonBarMargin = 4.0; diff --git a/lib/models/operator_profile.dart b/lib/models/operator_profile.dart new file mode 100644 index 0000000..1bc1e0b --- /dev/null +++ b/lib/models/operator_profile.dart @@ -0,0 +1,48 @@ +import 'package:uuid/uuid.dart'; + +/// A bundle of OSM tags that describe a particular surveillance operator. +/// These are applied on top of camera profile tags during submissions. +class OperatorProfile { + final String id; + final String name; + final Map tags; + + OperatorProfile({ + required this.id, + required this.name, + required this.tags, + }); + + OperatorProfile copyWith({ + String? id, + String? name, + Map? tags, + }) => + OperatorProfile( + id: id ?? this.id, + name: name ?? this.name, + tags: tags ?? this.tags, + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'tags': tags, + }; + + factory OperatorProfile.fromJson(Map j) => OperatorProfile( + id: j['id'], + name: j['name'], + tags: Map.from(j['tags']), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OperatorProfile && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} \ No newline at end of file diff --git a/lib/models/pending_upload.dart b/lib/models/pending_upload.dart index feda5d6..37fc8c7 100644 --- a/lib/models/pending_upload.dart +++ b/lib/models/pending_upload.dart @@ -1,11 +1,13 @@ import 'package:latlong2/latlong.dart'; import 'camera_profile.dart'; +import 'operator_profile.dart'; import '../state/settings_state.dart'; class PendingUpload { final LatLng coord; final double direction; final CameraProfile profile; + final OperatorProfile? operatorProfile; final UploadMode uploadMode; // Capture upload destination when queued final int? originalNodeId; // If this is an edit, the ID of the original OSM node int attempts; @@ -16,6 +18,7 @@ class PendingUpload { required this.coord, required this.direction, required this.profile, + this.operatorProfile, required this.uploadMode, this.originalNodeId, this.attempts = 0, @@ -38,11 +41,29 @@ class PendingUpload { } } + // Get combined tags from camera profile and operator profile + Map getCombinedTags() { + final tags = Map.from(profile.tags); + + // Add operator profile tags (they override camera profile tags if there are conflicts) + if (operatorProfile != null) { + tags.addAll(operatorProfile!.tags); + } + + // Add direction if required + if (profile.requiresDirection) { + tags['direction'] = direction.toStringAsFixed(0); + } + + return tags; + } + Map toJson() => { 'lat': coord.latitude, 'lon': coord.longitude, 'dir': direction, 'profile': profile.toJson(), + 'operatorProfile': operatorProfile?.toJson(), 'uploadMode': uploadMode.index, 'originalNodeId': originalNodeId, 'attempts': attempts, @@ -56,6 +77,9 @@ class PendingUpload { profile: j['profile'] is Map ? CameraProfile.fromJson(j['profile']) : CameraProfile.genericAlpr(), + operatorProfile: j['operatorProfile'] != null + ? OperatorProfile.fromJson(j['operatorProfile']) + : null, uploadMode: j['uploadMode'] != null ? UploadMode.values[j['uploadMode']] : UploadMode.production, // Default for legacy entries diff --git a/lib/screens/operator_profile_editor.dart b/lib/screens/operator_profile_editor.dart new file mode 100644 index 0000000..1b62be5 --- /dev/null +++ b/lib/screens/operator_profile_editor.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/operator_profile.dart'; +import '../app_state.dart'; + +class OperatorProfileEditor extends StatefulWidget { + const OperatorProfileEditor({super.key, required this.profile}); + + final OperatorProfile profile; + + @override + State createState() => _OperatorProfileEditorState(); +} + +class _OperatorProfileEditorState extends State { + late TextEditingController _nameCtrl; + late List> _tags; + + static const _defaultTags = [ + MapEntry('operator', ''), + MapEntry('operator:type', ''), + MapEntry('operator:wikidata', ''), + ]; + + @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 Operator Profile' : 'Edit Operator Profile'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: _nameCtrl, + decoration: const InputDecoration( + labelText: 'Operator name', + hintText: 'e.g., Austin Police Department', + ), + ), + 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('Operator 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(); + } + + final newProfile = widget.profile.copyWith( + id: widget.profile.id.isEmpty ? const Uuid().v4() : widget.profile.id, + name: name, + tags: tagMap, + ); + + context.read().addOrUpdateOperatorProfile(newProfile); + Navigator.pop(context); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Operator profile "${newProfile.name}" saved')), + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 05f8a36..9972adb 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'settings_screen_sections/auth_section.dart'; import 'settings_screen_sections/upload_mode_section.dart'; import 'settings_screen_sections/profile_list_section.dart'; +import 'settings_screen_sections/operator_profile_list_section.dart'; import 'settings_screen_sections/queue_section.dart'; import 'settings_screen_sections/offline_areas_section.dart'; import 'settings_screen_sections/offline_mode_section.dart'; @@ -27,6 +28,8 @@ class SettingsScreen extends StatelessWidget { Divider(), ProfileListSection(), Divider(), + OperatorProfileListSection(), + Divider(), MaxCamerasSection(), Divider(), TileProviderSection(), diff --git a/lib/screens/settings_screen_sections/operator_profile_list_section.dart b/lib/screens/settings_screen_sections/operator_profile_list_section.dart new file mode 100644 index 0000000..9a42692 --- /dev/null +++ b/lib/screens/settings_screen_sections/operator_profile_list_section.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import 'package:provider/provider.dart'; +import '../../app_state.dart'; +import '../../models/operator_profile.dart'; +import '../operator_profile_editor.dart'; + +class OperatorProfileListSection extends StatelessWidget { + const OperatorProfileListSection({super.key}); + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Operator Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + TextButton.icon( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => OperatorProfileEditor( + profile: OperatorProfile( + id: const Uuid().v4(), + name: '', + tags: const {}, + ), + ), + ), + ), + icon: const Icon(Icons.add), + label: const Text('New Profile'), + ), + ], + ), + if (appState.operatorProfiles.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'No operator profiles defined. Create one to apply operator tags to camera submissions.', + style: TextStyle(color: Colors.grey), + textAlign: TextAlign.center, + ), + ) + else + ...appState.operatorProfiles.map( + (p) => ListTile( + title: Text(p.name), + subtitle: Text('${p.tags.length} tags'), + trailing: 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: (_) => OperatorProfileEditor(profile: p), + ), + ); + } else if (value == 'delete') { + _showDeleteProfileDialog(context, p); + } + }, + ), + ), + ), + ], + ); + } + +void _showDeleteProfileDialog(BuildContext context, OperatorProfile profile) { + final appState = context.read(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Operator 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.deleteOperatorProfile(profile); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Operator profile deleted')), + ); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); +} +} \ No newline at end of file diff --git a/lib/services/operator_profile_service.dart b/lib/services/operator_profile_service.dart new file mode 100644 index 0000000..488c3a8 --- /dev/null +++ b/lib/services/operator_profile_service.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/operator_profile.dart'; + +class OperatorProfileService { + static const _key = 'operator_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) => OperatorProfile.fromJson(e)).toList(); + } + + Future save(List profiles) async { + final prefs = await SharedPreferences.getInstance(); + final encodable = profiles.map((p) => p.toJson()).toList(); + await prefs.setString(_key, jsonEncode(encodable)); + } +} \ No newline at end of file diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index bced41e..555c1d9 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -36,10 +36,7 @@ class Uploader { print('Uploader: Created changeset ID: $csId'); // 2. create or update node - final mergedTags = Map.from(p.profile.tags); - if (p.profile.requiresDirection) { - mergedTags['direction'] = p.direction.round().toString(); - } + final mergedTags = p.getCombinedTags(); final tagsXml = mergedTags.entries.map((e) => '').join('\n '); diff --git a/lib/state/operator_profile_state.dart b/lib/state/operator_profile_state.dart new file mode 100644 index 0000000..ff143bb --- /dev/null +++ b/lib/state/operator_profile_state.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../models/operator_profile.dart'; +import '../services/operator_profile_service.dart'; + +class OperatorProfileState extends ChangeNotifier { + final List _profiles = []; + + List get profiles => List.unmodifiable(_profiles); + + Future init() async { + _profiles.addAll(await OperatorProfileService().load()); + } + + void addOrUpdateProfile(OperatorProfile p) { + final idx = _profiles.indexWhere((x) => x.id == p.id); + if (idx >= 0) { + _profiles[idx] = p; + } else { + _profiles.add(p); + } + OperatorProfileService().save(_profiles); + notifyListeners(); + } + + void deleteProfile(OperatorProfile p) { + _profiles.removeWhere((x) => x.id == p.id); + OperatorProfileService().save(_profiles); + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index 80097ae..383c97a 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import '../models/camera_profile.dart'; +import '../models/operator_profile.dart'; import '../models/osm_camera_node.dart'; // ------------------ AddCameraSession ------------------ class AddCameraSession { AddCameraSession({required this.profile, this.directionDegrees = 0}); CameraProfile profile; + OperatorProfile? operatorProfile; double directionDegrees; LatLng? target; } @@ -23,6 +25,7 @@ class EditCameraSession { final OsmCameraNode originalNode; // The original camera being edited CameraProfile profile; + OperatorProfile? operatorProfile; double directionDegrees; LatLng target; // Current position (can be dragged) } @@ -84,6 +87,7 @@ class SessionState extends ChangeNotifier { void updateSession({ double? directionDeg, CameraProfile? profile, + OperatorProfile? operatorProfile, LatLng? target, }) { if (_session == null) return; @@ -97,6 +101,10 @@ class SessionState extends ChangeNotifier { _session!.profile = profile; dirty = true; } + if (operatorProfile != _session!.operatorProfile) { + _session!.operatorProfile = operatorProfile; + dirty = true; + } if (target != null) { _session!.target = target; dirty = true; @@ -107,6 +115,7 @@ class SessionState extends ChangeNotifier { void updateEditSession({ double? directionDeg, CameraProfile? profile, + OperatorProfile? operatorProfile, LatLng? target, }) { if (_editSession == null) return; @@ -120,6 +129,10 @@ class SessionState extends ChangeNotifier { _editSession!.profile = profile; dirty = true; } + if (operatorProfile != _editSession!.operatorProfile) { + _editSession!.operatorProfile = operatorProfile; + dirty = true; + } if (target != null && target != _editSession!.target) { _editSession!.target = target; dirty = true; diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index b643ee2..7d8df56 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -30,6 +30,7 @@ class UploadQueueState extends ChangeNotifier { coord: session.target!, direction: session.directionDegrees, profile: session.profile, + operatorProfile: session.operatorProfile, uploadMode: uploadMode, ); @@ -40,10 +41,7 @@ class UploadQueueState extends ChangeNotifier { // Create a temporary node with a negative ID (to distinguish from real OSM nodes) // Using timestamp as negative ID to ensure uniqueness final tempId = -DateTime.now().millisecondsSinceEpoch; - final tags = Map.from(upload.profile.tags); - if (upload.profile.requiresDirection) { - tags['direction'] = upload.direction.toStringAsFixed(0); - } + final tags = upload.getCombinedTags(); tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction final tempNode = OsmCameraNode( @@ -65,6 +63,7 @@ class UploadQueueState extends ChangeNotifier { coord: session.target, direction: session.directionDegrees, profile: session.profile, + operatorProfile: session.operatorProfile, uploadMode: uploadMode, originalNodeId: session.originalNode.id, // Track which node we're editing ); @@ -86,10 +85,7 @@ class UploadQueueState extends ChangeNotifier { // 2. Create new temp node for the edited camera (purple ring) at new location final tempId = -DateTime.now().millisecondsSinceEpoch; - final editedTags = Map.from(upload.profile.tags); - if (upload.profile.requiresDirection) { - editedTags['direction'] = upload.direction.toStringAsFixed(0); - } + final editedTags = upload.getCombinedTags(); editedTags['_pending_upload'] = 'true'; // Mark as pending upload editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing diff --git a/lib/widgets/add_camera_sheet.dart b/lib/widgets/add_camera_sheet.dart index f532261..748ec08 100644 --- a/lib/widgets/add_camera_sheet.dart +++ b/lib/widgets/add_camera_sheet.dart @@ -3,6 +3,8 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../models/camera_profile.dart'; +import '../models/operator_profile.dart'; +import 'refine_tags_sheet.dart'; class AddCameraSheet extends StatelessWidget { const AddCameraSheet({super.key, required this.session}); @@ -28,6 +30,21 @@ class AddCameraSheet extends StatelessWidget { final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList(); final allowSubmit = submittableProfiles.isNotEmpty && session.profile.isSubmittable; + + void _openRefineTags() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RefineTagsSheet( + selectedOperatorProfile: session.operatorProfile, + ), + fullscreenDialog: true, + ), + ); + if (result != session.operatorProfile) { + appState.updateSession(operatorProfile: result); + } + } return Padding( padding: @@ -118,6 +135,20 @@ class AddCameraSheet extends StatelessWidget { ), ), const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _openRefineTags, + icon: const Icon(Icons.tune), + label: Text(session.operatorProfile != null + ? 'Refine Tags (${session.operatorProfile!.name})' + : 'Refine Tags'), + ), + ), + ), + const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( diff --git a/lib/widgets/edit_camera_sheet.dart b/lib/widgets/edit_camera_sheet.dart index 5f0cfc1..9b638de 100644 --- a/lib/widgets/edit_camera_sheet.dart +++ b/lib/widgets/edit_camera_sheet.dart @@ -3,7 +3,9 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../models/camera_profile.dart'; +import '../models/operator_profile.dart'; import '../state/settings_state.dart'; +import 'refine_tags_sheet.dart'; class EditCameraSheet extends StatelessWidget { const EditCameraSheet({super.key, required this.session}); @@ -30,6 +32,21 @@ class EditCameraSheet extends StatelessWidget { final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList(); final isSandboxMode = appState.uploadMode == UploadMode.sandbox; final allowSubmit = submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode; + + void _openRefineTags() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => RefineTagsSheet( + selectedOperatorProfile: session.operatorProfile, + ), + fullscreenDialog: true, + ), + ); + if (result != session.operatorProfile) { + appState.updateEditSession(operatorProfile: result); + } + } return Padding( padding: @@ -141,6 +158,20 @@ class EditCameraSheet extends StatelessWidget { ), ), const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _openRefineTags, + icon: const Icon(Icons.tune), + label: Text(session.operatorProfile != null + ? 'Refine Tags (${session.operatorProfile!.name})' + : 'Refine Tags'), + ), + ), + ), + const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( diff --git a/lib/widgets/refine_tags_sheet.dart b/lib/widgets/refine_tags_sheet.dart new file mode 100644 index 0000000..b6a5eec --- /dev/null +++ b/lib/widgets/refine_tags_sheet.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../app_state.dart'; +import '../models/operator_profile.dart'; + +class RefineTagsSheet extends StatefulWidget { + const RefineTagsSheet({ + super.key, + this.selectedOperatorProfile, + }); + + final OperatorProfile? selectedOperatorProfile; + + @override + State createState() => _RefineTagsSheetState(); +} + +class _RefineTagsSheetState extends State { + OperatorProfile? _selectedOperatorProfile; + + @override + void initState() { + super.initState(); + _selectedOperatorProfile = widget.selectedOperatorProfile; + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final operatorProfiles = appState.operatorProfiles; + + return Scaffold( + appBar: AppBar( + title: const Text('Refine Tags'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context, widget.selectedOperatorProfile), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, _selectedOperatorProfile), + child: const Text('Done'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text( + 'Operator Profile', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + if (operatorProfiles.isEmpty) + const Card( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Column( + children: [ + Icon(Icons.info_outline, color: Colors.grey, size: 48), + SizedBox(height: 8), + Text( + 'No operator profiles defined', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + SizedBox(height: 4), + Text( + 'Create operator profiles in Settings to apply additional tags to your camera submissions.', + style: TextStyle(color: Colors.grey), + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + else ...[ + Card( + child: Column( + children: [ + RadioListTile( + title: const Text('None'), + subtitle: const Text('No additional operator tags'), + value: null, + groupValue: _selectedOperatorProfile, + onChanged: (value) => setState(() => _selectedOperatorProfile = value), + ), + ...operatorProfiles.map((profile) => RadioListTile( + title: Text(profile.name), + subtitle: Text('${profile.tags.length} additional tags'), + value: profile, + groupValue: _selectedOperatorProfile, + onChanged: (value) => setState(() => _selectedOperatorProfile = value), + )), + ], + ), + ), + const SizedBox(height: 16), + if (_selectedOperatorProfile != null) ...[ + const Text( + 'Additional Tags', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _selectedOperatorProfile!.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + if (_selectedOperatorProfile!.tags.isEmpty) + const Text( + 'No tags defined for this operator profile.', + style: TextStyle(color: Colors.grey), + ) + else + ...(_selectedOperatorProfile!.tags.entries.map((entry) => + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text( + entry.key, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: Text( + entry.value, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + ], + ), + ), + )), + ], + ), + ), + ), + ], + ], + ], + ), + ); + } +} \ No newline at end of file