first pass at operator profiles

This commit is contained in:
stopflock
2025-08-29 15:09:19 -05:00
parent 04a6d129b7
commit 208b3486f3
15 changed files with 669 additions and 13 deletions
+23
View File
@@ -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<CameraProfile> get enabledProfiles => _profileState.enabledProfiles;
bool isEnabled(CameraProfile p) => _profileState.isEnabled(p);
// Operator profile state
List<OperatorProfile> 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<void> _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);
+1 -1
View File
@@ -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;
+48
View File
@@ -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<String, String> tags;
OperatorProfile({
required this.id,
required this.name,
required this.tags,
});
OperatorProfile copyWith({
String? id,
String? name,
Map<String, String>? tags,
}) =>
OperatorProfile(
id: id ?? this.id,
name: name ?? this.name,
tags: tags ?? this.tags,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'tags': tags,
};
factory OperatorProfile.fromJson(Map<String, dynamic> j) => OperatorProfile(
id: j['id'],
name: j['name'],
tags: Map<String, String>.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;
}
+24
View File
@@ -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<String, String> getCombinedTags() {
final tags = Map<String, String>.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<String, dynamic> 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<String, dynamic>
? 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
+158
View File
@@ -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<OperatorProfileEditor> createState() => _OperatorProfileEditorState();
}
class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
late TextEditingController _nameCtrl;
late List<MapEntry<String, String>> _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<Widget> _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 = <String, String>{};
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<AppState>().addOrUpdateOperatorProfile(newProfile);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Operator profile "${newProfile.name}" saved')),
);
}
}
+3
View File
@@ -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(),
@@ -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<AppState>();
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<AppState>();
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'),
),
],
),
);
}
}
@@ -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<List<OperatorProfile>> load() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_key);
if (jsonStr == null) return [];
final list = jsonDecode(jsonStr) as List<dynamic>;
return list.map((e) => OperatorProfile.fromJson(e)).toList();
}
Future<void> save(List<OperatorProfile> profiles) async {
final prefs = await SharedPreferences.getInstance();
final encodable = profiles.map((p) => p.toJson()).toList();
await prefs.setString(_key, jsonEncode(encodable));
}
}
+1 -4
View File
@@ -36,10 +36,7 @@ class Uploader {
print('Uploader: Created changeset ID: $csId');
// 2. create or update node
final mergedTags = Map<String, String>.from(p.profile.tags);
if (p.profile.requiresDirection) {
mergedTags['direction'] = p.direction.round().toString();
}
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
+31
View File
@@ -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<OperatorProfile> _profiles = [];
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
Future<void> 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();
}
}
+13
View File
@@ -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;
+4 -8
View File
@@ -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<String, String>.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<String, String>.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
+31
View File
@@ -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<OperatorProfile?>(
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(
+31
View File
@@ -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<OperatorProfile?>(
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(
+157
View File
@@ -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<RefineTagsSheet> createState() => _RefineTagsSheetState();
}
class _RefineTagsSheetState extends State<RefineTagsSheet> {
OperatorProfile? _selectedOperatorProfile;
@override
void initState() {
super.initState();
_selectedOperatorProfile = widget.selectedOperatorProfile;
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
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<OperatorProfile?>(
title: const Text('None'),
subtitle: const Text('No additional operator tags'),
value: null,
groupValue: _selectedOperatorProfile,
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
),
...operatorProfiles.map((profile) => RadioListTile<OperatorProfile?>(
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),
),
),
],
),
),
)),
],
),
),
),
],
],
],
),
);
}
}