Merge pull request #12 from stopflock/edits

Edits
This commit is contained in:
stopflock
2025-08-28 12:40:47 -05:00
committed by GitHub
20 changed files with 773 additions and 113 deletions

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'models/camera_profile.dart';
import 'models/osm_camera_node.dart';
import 'models/pending_upload.dart';
import 'models/tile_provider.dart';
import 'services/offline_area_service.dart';
@@ -14,7 +15,7 @@ import 'state/upload_queue_state.dart';
// Re-export types
export 'state/settings_state.dart' show UploadMode;
export 'state/session_state.dart' show AddCameraSession;
export 'state/session_state.dart' show AddCameraSession, EditCameraSession;
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
@@ -61,6 +62,7 @@ class AppState extends ChangeNotifier {
// Session state
AddCameraSession? get session => _sessionState.session;
EditCameraSession? get editSession => _sessionState.editSession;
// Settings state
bool get offlineMode => _settingsState.offlineMode;
@@ -139,6 +141,10 @@ class AppState extends ChangeNotifier {
_sessionState.startAddSession(enabledProfiles);
}
void startEditSession(OsmCameraNode node) {
_sessionState.startEditSession(node, enabledProfiles);
}
void updateSession({
double? directionDeg,
CameraProfile? profile,
@@ -151,10 +157,26 @@ class AppState extends ChangeNotifier {
);
}
void updateEditSession({
double? directionDeg,
CameraProfile? profile,
LatLng? target,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
profile: profile,
target: target,
);
}
void cancelSession() {
_sessionState.cancelSession();
}
void cancelEditSession() {
_sessionState.cancelEditSession();
}
void commitSession() {
final session = _sessionState.commitSession();
if (session != null) {
@@ -163,6 +185,14 @@ class AppState extends ChangeNotifier {
}
}
void commitEditSession() {
final session = _sessionState.commitEditSession();
if (session != null) {
_uploadQueueState.addFromEditSession(session);
_startUploader();
}
}
// ---------- Settings Methods ----------
Future<void> setOfflineMode(bool enabled) async {
await _settingsState.setOfflineMode(enabled);

View File

@@ -56,3 +56,5 @@ const double kCameraDotOpacity = 0.4; // Opacity for the grey dot interior
const Color kCameraRingColorReal = Color(0xC43F55F3); // Real cameras from OSM - blue
const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add camera mock point - white
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending cameras - purple
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Camera being edited - orange
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original camera with pending edit - grey

View File

@@ -14,19 +14,93 @@ class CameraProfile {
this.builtin = false,
});
/// Builtin default: Generic Flock ALPR camera
factory CameraProfile.alpr() => CameraProfile(
id: 'builtin-alpr',
name: 'Generic Flock',
/// Builtin default: Generic ALPR camera (view-only)
factory CameraProfile.genericAlpr() => CameraProfile(
id: 'builtin-generic-alpr',
name: 'Generic ALPR',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
},
builtin: true,
);
/// Builtin: Flock Safety ALPR camera
factory CameraProfile.flock() => CameraProfile(
id: 'builtin-flock',
name: 'Flock',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
'camera:type': 'fixed',
'manufacturer': 'Flock Safety',
'manufacturer:wikidata': 'Q108485435',
},
builtin: true,
);
/// Builtin: Motorola Solutions/Vigilant ALPR camera
factory CameraProfile.motorola() => CameraProfile(
id: 'builtin-motorola',
name: 'Motorola/Vigilant',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
'camera:type': 'fixed',
'manufacturer': 'Motorola Solutions',
'manufacturer:wikidata': 'Q634815',
},
builtin: true,
);
/// Builtin: Genetec ALPR camera
factory CameraProfile.genetec() => CameraProfile(
id: 'builtin-genetec',
name: 'Genetec',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
'camera:type': 'fixed',
'manufacturer': 'Genetec',
'manufacturer:wikidata': 'Q30295174',
},
builtin: true,
);
/// Builtin: Leonardo/ELSAG ALPR camera
factory CameraProfile.leonardo() => CameraProfile(
id: 'builtin-leonardo',
name: 'Leonardo/ELSAG',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
'camera:type': 'fixed',
'manufacturer': 'Leonardo',
'manufacturer:wikidata': 'Q910379',
},
builtin: true,
);
/// Builtin: Neology ALPR camera
factory CameraProfile.neology() => CameraProfile(
id: 'builtin-neology',
name: 'Neology',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
'camera:type': 'fixed',
'manufacturer': 'Neology, Inc.',
},
builtin: true,
);
/// Returns true if this profile can be used for submissions
bool get isSubmittable {
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({
String? id,
String? name,

View File

@@ -5,6 +5,7 @@ class PendingUpload {
final LatLng coord;
final double direction;
final CameraProfile profile;
final int? originalNodeId; // If this is an edit, the ID of the original OSM node
int attempts;
bool error;
@@ -12,15 +13,20 @@ class PendingUpload {
required this.coord,
required this.direction,
required this.profile,
this.originalNodeId,
this.attempts = 0,
this.error = false,
});
// True if this is an edit of an existing camera, false if it's a new camera
bool get isEdit => originalNodeId != null;
Map<String, dynamic> toJson() => {
'lat': coord.latitude,
'lon': coord.longitude,
'dir': direction,
'profile': profile.toJson(),
'originalNodeId': originalNodeId,
'attempts': attempts,
'error': error,
};
@@ -30,7 +36,8 @@ class PendingUpload {
direction: j['dir'],
profile: j['profile'] is Map<String, dynamic>
? CameraProfile.fromJson(j['profile'])
: CameraProfile.alpr(),
: CameraProfile.genericAlpr(),
originalNodeId: j['originalNodeId'],
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,
);

View File

@@ -7,9 +7,16 @@ import '../dev_config.dart';
import '../widgets/map_view.dart';
import '../widgets/add_camera_sheet.dart';
import '../widgets/edit_camera_sheet.dart';
import '../widgets/camera_provider_with_cache.dart';
import '../widgets/download_area_dialog.dart';
enum FollowMeMode {
off, // No following
northUp, // Follow position, keep north up
rotating, // Follow position and rotation
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -21,11 +28,45 @@ class _HomeScreenState extends State<HomeScreen> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
final MapController _mapController = MapController();
bool _followMe = true;
FollowMeMode _followMeMode = FollowMeMode.northUp;
bool _editSheetShown = false;
String _getFollowMeTooltip() {
switch (_followMeMode) {
case FollowMeMode.off:
return 'Enable follow-me (north up)';
case FollowMeMode.northUp:
return 'Enable follow-me (rotating)';
case FollowMeMode.rotating:
return 'Disable follow-me';
}
}
IconData _getFollowMeIcon() {
switch (_followMeMode) {
case FollowMeMode.off:
return Icons.gps_off;
case FollowMeMode.northUp:
return Icons.gps_fixed;
case FollowMeMode.rotating:
return Icons.navigation;
}
}
FollowMeMode _getNextFollowMeMode() {
switch (_followMeMode) {
case FollowMeMode.off:
return FollowMeMode.northUp;
case FollowMeMode.northUp:
return FollowMeMode.rotating;
case FollowMeMode.rotating:
return FollowMeMode.off;
}
}
void _openAddCameraSheet() {
// Disable follow-me when adding a camera so the map doesn't jump around
setState(() => _followMe = false);
setState(() => _followMeMode = FollowMeMode.off);
final appState = context.read<AppState>();
appState.startAddSession();
@@ -36,10 +77,30 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
void _openEditCameraSheet() {
// Disable follow-me when editing a camera so the map doesn't jump around
setState(() => _followMeMode = FollowMeMode.off);
final appState = context.read<AppState>();
final session = appState.editSession!; // should be non-null when this is called
_scaffoldKey.currentState!.showBottomSheet(
(ctx) => EditCameraSheet(session: session),
);
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
// Auto-open edit sheet when edit session starts
if (appState.editSession != null && !_editSheetShown) {
_editSheetShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _openEditCameraSheet());
} else if (appState.editSession == null) {
_editSheetShown = false;
}
return MultiProvider(
providers: [
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
@@ -50,12 +111,16 @@ class _HomeScreenState extends State<HomeScreen> {
title: const Text('Flock Map'),
actions: [
IconButton(
tooltip: _followMe ? 'Disable followme' : 'Enable followme',
icon: Icon(_followMe ? Icons.gps_fixed : Icons.gps_off),
tooltip: _getFollowMeTooltip(),
icon: Icon(_getFollowMeIcon()),
onPressed: () {
setState(() => _followMe = !_followMe);
setState(() {
final oldMode = _followMeMode;
_followMeMode = _getNextFollowMeMode();
debugPrint('[HomeScreen] Follow mode changed: $oldMode$_followMeMode');
});
// If enabling follow-me, retry location init in case permission was granted
if (_followMe) {
if (_followMeMode != FollowMeMode.off) {
_mapViewKey.currentState?.retryLocationInit();
}
},
@@ -71,9 +136,11 @@ class _HomeScreenState extends State<HomeScreen> {
MapView(
key: _mapViewKey,
controller: _mapController,
followMe: _followMe,
followMeMode: _followMeMode,
onUserGesture: () {
if (_followMe) setState(() => _followMe = false);
if (_followMeMode != FollowMeMode.off) {
setState(() => _followMeMode = FollowMeMode.off);
}
},
),
Align(

View File

@@ -52,14 +52,16 @@ class _ProfileEditorState extends State<ProfileEditor> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title:
Text(widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile'),
title: Text(widget.profile.builtin
? 'View Profile'
: (widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile')),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _nameCtrl,
readOnly: widget.profile.builtin,
decoration: const InputDecoration(
labelText: 'Profile name',
hintText: 'e.g., Custom ALPR Camera',
@@ -71,20 +73,22 @@ class _ProfileEditorState extends State<ProfileEditor> {
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'),
),
if (!widget.profile.builtin)
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'),
),
if (!widget.profile.builtin)
ElevatedButton(
onPressed: _save,
child: const Text('Save Profile'),
),
],
),
);
@@ -108,7 +112,10 @@ class _ProfileEditorState extends State<ProfileEditor> {
isDense: true,
),
controller: keyController,
onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value),
readOnly: widget.profile.builtin,
onChanged: widget.profile.builtin
? null
: (v) => _tags[i] = MapEntry(v, _tags[i].value),
),
),
const SizedBox(width: 8),
@@ -121,13 +128,17 @@ class _ProfileEditorState extends State<ProfileEditor> {
isDense: true,
),
controller: valueController,
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
readOnly: widget.profile.builtin,
onChanged: widget.profile.builtin
? null
: (v) => _tags[i] = MapEntry(_tags[i].key, v),
),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => setState(() => _tags.removeAt(i)),
),
if (!widget.profile.builtin)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => setState(() => _tags.removeAt(i)),
),
],
),
);

View File

@@ -44,42 +44,67 @@ class ProfileListSection extends StatelessWidget {
),
title: Text(p.name),
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),
trailing: p.builtin
? PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'view',
child: const Row(
children: [
Icon(Icons.visibility),
SizedBox(width: 8),
Text('View'),
],
),
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
],
onSelected: (value) {
if (value == 'view') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(profile: p),
),
);
}
},
)
: 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, p);
}
},
),
),
),
],

View File

@@ -13,7 +13,23 @@ class CameraCache {
/// Add or update a batch of camera nodes in the cache.
void addOrUpdate(List<OsmCameraNode> nodes) {
for (var node in nodes) {
_nodes[node.id] = node;
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmCameraNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
);
} else {
_nodes[node.id] = node;
}
}
}

View File

@@ -34,28 +34,45 @@ class Uploader {
final csId = csResp.body.trim();
print('Uploader: Created changeset ID: $csId');
// 2. create node
// Merge tags: direction in PendingUpload should always be present,
// and override any in the profile for upload purposes
// 2. create or update node
final mergedTags = Map<String, String>.from(p.profile.tags)
..['direction'] = p.direction.round().toString();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
final http.Response nodeResp;
final String nodeId;
if (p.isEdit) {
// Update existing node
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Updating node ${p.originalNodeId}...');
nodeResp = await _put('/api/0.6/node/${p.originalNodeId}', nodeXml);
nodeId = p.originalNodeId.toString();
} else {
// Create new node
final nodeXml = '''
<osm>
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
print('Uploader: Creating node...');
final nodeResp = await _put('/api/0.6/node/create', nodeXml);
print('Uploader: Creating new node...');
nodeResp = await _put('/api/0.6/node/create', nodeXml);
nodeId = nodeResp.body.trim();
}
print('Uploader: Node response: ${nodeResp.statusCode} - ${nodeResp.body}');
if (nodeResp.statusCode != 200) {
print('Uploader: Failed to create node');
print('Uploader: Failed to ${p.isEdit ? "update" : "create"} node');
return false;
}
final nodeId = nodeResp.body.trim();
print('Uploader: Created node ID: $nodeId');
print('Uploader: ${p.isEdit ? "Updated" : "Created"} node ID: $nodeId');
// 3. close changeset
print('Uploader: Closing changeset...');

View File

@@ -19,7 +19,12 @@ class ProfileState extends ChangeNotifier {
// Initialize profiles from built-in and custom sources
Future<void> init() async {
// Initialize profiles: built-in + custom
_profiles.add(CameraProfile.alpr());
_profiles.add(CameraProfile.genericAlpr());
_profiles.add(CameraProfile.flock());
_profiles.add(CameraProfile.motorola());
_profiles.add(CameraProfile.genetec());
_profiles.add(CameraProfile.leonardo());
_profiles.add(CameraProfile.neology());
_profiles.addAll(await ProfileService().load());
// Load enabled profile IDs from prefs

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/camera_profile.dart';
import '../models/osm_camera_node.dart';
// ------------------ AddCameraSession ------------------
class AddCameraSession {
@@ -11,17 +12,75 @@ class AddCameraSession {
LatLng? target;
}
// ------------------ EditCameraSession ------------------
class EditCameraSession {
EditCameraSession({
required this.originalNode,
required this.profile,
required this.directionDegrees,
required this.target,
});
final OsmCameraNode originalNode; // The original camera being edited
CameraProfile profile;
double directionDegrees;
LatLng target; // Current position (can be dragged)
}
class SessionState extends ChangeNotifier {
AddCameraSession? _session;
EditCameraSession? _editSession;
// Getter
// Getters
AddCameraSession? get session => _session;
EditCameraSession? get editSession => _editSession;
void startAddSession(List<CameraProfile> enabledProfiles) {
_session = AddCameraSession(profile: enabledProfiles.first);
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
final defaultProfile = submittableProfiles.isNotEmpty
? submittableProfiles.first
: enabledProfiles.first; // Fallback to any enabled profile
_session = AddCameraSession(profile: defaultProfile);
_editSession = null; // Clear any edit session
notifyListeners();
}
void startEditSession(OsmCameraNode node, List<CameraProfile> enabledProfiles) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
// Try to find a matching profile based on the node's tags
CameraProfile matchingProfile = submittableProfiles.isNotEmpty
? submittableProfiles.first
: enabledProfiles.first;
// Attempt to find a better match by comparing tags
for (final profile in submittableProfiles) {
if (_profileMatchesTags(profile, node.tags)) {
matchingProfile = profile;
break;
}
}
_editSession = EditCameraSession(
originalNode: node,
profile: matchingProfile,
directionDegrees: node.directionDeg ?? 0,
target: node.coord,
);
_session = null; // Clear any add session
notifyListeners();
}
bool _profileMatchesTags(CameraProfile profile, Map<String, String> tags) {
// Simple matching: check if all profile tags are present in node tags
for (final entry in profile.tags.entries) {
if (tags[entry.key] != entry.value) {
return false;
}
}
return true;
}
void updateSession({
double? directionDeg,
CameraProfile? profile,
@@ -45,11 +104,39 @@ class SessionState extends ChangeNotifier {
if (dirty) notifyListeners();
}
void updateEditSession({
double? directionDeg,
CameraProfile? profile,
LatLng? target,
}) {
if (_editSession == null) return;
bool dirty = false;
if (directionDeg != null && directionDeg != _editSession!.directionDegrees) {
_editSession!.directionDegrees = directionDeg;
dirty = true;
}
if (profile != null && profile != _editSession!.profile) {
_editSession!.profile = profile;
dirty = true;
}
if (target != null && target != _editSession!.target) {
_editSession!.target = target;
dirty = true;
}
if (dirty) notifyListeners();
}
void cancelSession() {
_session = null;
notifyListeners();
}
void cancelEditSession() {
_editSession = null;
notifyListeners();
}
AddCameraSession? commitSession() {
if (_session?.target == null) return null;
@@ -58,4 +145,13 @@ class SessionState extends ChangeNotifier {
notifyListeners();
return session;
}
EditCameraSession? commitEditSession() {
if (_editSession == null) return null;
final session = _editSession!;
_editSession = null;
notifyListeners();
return session;
}
}

View File

@@ -56,6 +56,50 @@ class UploadQueueState extends ChangeNotifier {
notifyListeners();
}
// Add a completed edit session to the upload queue
void addFromEditSession(EditCameraSession session) {
final upload = PendingUpload(
coord: session.target,
direction: session.directionDegrees,
profile: session.profile,
originalNodeId: session.originalNode.id, // Track which node we're editing
);
_queue.add(upload);
_saveQueue();
// Create two cache entries:
// 1. Mark the original camera with _pending_edit (grey ring) at original location
final originalTags = Map<String, String>.from(session.originalNode.tags);
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
final originalNode = OsmCameraNode(
id: session.originalNode.id,
coord: session.originalNode.coord, // Keep at original location
tags: originalTags,
);
// 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);
editedTags['direction'] = upload.direction.toStringAsFixed(0);
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
final editedNode = OsmCameraNode(
id: tempId,
coord: upload.coord, // At new location
tags: editedTags,
);
CameraCache.instance.addOrUpdate([originalNode, editedNode]);
// Notify camera provider to update the map
CameraProviderWithCache.instance.notifyListeners();
notifyListeners();
}
void clearQueue() {
_queue.clear();
_saveQueue();

View File

@@ -26,8 +26,8 @@ class AddCameraSheet extends StatelessWidget {
Navigator.pop(context);
}
final customProfiles = appState.enabledProfiles.where((p) => !p.builtin).toList();
final allowSubmit = customProfiles.isNotEmpty && !session.profile.builtin;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = submittableProfiles.isNotEmpty && session.profile.isSubmittable;
return Padding(
padding:
@@ -49,7 +49,7 @@ class AddCameraSheet extends StatelessWidget {
title: const Text('Profile'),
trailing: DropdownButton<CameraProfile>(
value: session.profile,
items: appState.enabledProfiles
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
@@ -67,7 +67,7 @@ class AddCameraSheet extends StatelessWidget {
onChanged: (v) => appState.updateSession(directionDeg: v),
),
),
if (customProfiles.isEmpty)
if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -76,14 +76,14 @@ class AddCameraSheet extends StatelessWidget {
SizedBox(width: 6),
Expanded(
child: Text(
'Enable or create a custom profile in Settings to submit new cameras.',
'Enable a submittable profile in Settings to submit new cameras.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (session.profile.builtin)
else if (!session.profile.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -92,7 +92,7 @@ class AddCameraSheet extends StatelessWidget {
SizedBox(width: 6),
Expanded(
child: Text(
'The built-in profile is for map viewing only. Please select a custom profile to submit new cameras.',
'This profile is for map viewing only. Please select a submittable profile to submit new cameras.',
style: TextStyle(color: Colors.orange, fontSize: 13),
),
),

View File

@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
import '../dev_config.dart';
enum CameraIconType {
real, // Blue ring - real cameras from OSM
mock, // White ring - add camera mock point
pending, // Purple ring - submitted/pending cameras
real, // Blue ring - real cameras from OSM
mock, // White ring - add camera mock point
pending, // Purple ring - submitted/pending cameras
editing, // Orange ring - camera being edited
pendingEdit, // Grey ring - original camera with pending edit
}
/// Simple camera icon with grey dot and colored ring
@@ -21,6 +23,10 @@ class CameraIcon extends StatelessWidget {
return kCameraRingColorMock;
case CameraIconType.pending:
return kCameraRingColorPending;
case CameraIconType.editing:
return kCameraRingColorEditing;
case CameraIconType.pendingEdit:
return kCameraRingColorPendingEdit;
}
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
class CameraTagSheet extends StatelessWidget {
final OsmCameraNode node;
@@ -8,6 +10,19 @@ class CameraTagSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
// Check if this camera is editable (not a pending upload or pending edit)
final isEditable = (!node.tags.containsKey('_pending_upload') ||
node.tags['_pending_upload'] != 'true') &&
(!node.tags.containsKey('_pending_edit') ||
node.tags['_pending_edit'] != 'true');
void _openEditSheet() {
Navigator.pop(context); // Close this sheet first
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
@@ -46,13 +61,26 @@ class CameraTagSheet extends StatelessWidget {
),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (isEditable) ...[
ElevatedButton.icon(
onPressed: _openEditSheet,
icon: const Icon(Icons.edit, size: 18),
label: const Text('Edit'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
),
),
const SizedBox(width: 12),
],
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
],
),

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/camera_profile.dart';
class EditCameraSheet extends StatelessWidget {
const EditCameraSheet({super.key, required this.session});
final EditCameraSession session;
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
void _commit() {
appState.commitEditSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Camera edit queued for upload')),
);
}
void _cancel() {
appState.cancelEditSession();
Navigator.pop(context);
}
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = submittableProfiles.isNotEmpty && session.profile.isSubmittable;
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 8),
Text(
'Edit Camera #${session.originalNode.id}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
ListTile(
title: const Text('Profile'),
trailing: DropdownButton<CameraProfile>(
value: session.profile,
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
appState.updateEditSession(profile: p ?? session.profile),
),
),
ListTile(
title: Text('Direction ${session.directionDegrees.round()}°'),
subtitle: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: (v) => appState.updateEditSession(directionDeg: v),
),
),
if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
Icon(Icons.info_outline, color: Colors.red, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'Enable a submittable profile in Settings to edit cameras.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (!session.profile.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
Icon(Icons.info_outline, color: Colors.orange, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'This profile is for map viewing only. Please select a submittable profile to edit cameras.',
style: TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: allowSubmit ? _commit : null,
child: const Text('Save Edit'),
),
),
],
),
),
const SizedBox(height: 20),
],
),
);
}
}

View File

@@ -46,16 +46,25 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
@override
Widget build(BuildContext context) {
// Check if this is a pending upload
final isPending = widget.node.tags.containsKey('_pending_upload') &&
widget.node.tags['_pending_upload'] == 'true';
// Check camera state
final isPendingUpload = widget.node.tags.containsKey('_pending_upload') &&
widget.node.tags['_pending_upload'] == 'true';
final isPendingEdit = widget.node.tags.containsKey('_pending_edit') &&
widget.node.tags['_pending_edit'] == 'true';
CameraIconType iconType;
if (isPendingUpload) {
iconType = CameraIconType.pending;
} else if (isPendingEdit) {
iconType = CameraIconType.pendingEdit;
} else {
iconType = CameraIconType.real;
}
return GestureDetector(
onTap: _onTap,
onDoubleTap: _onDoubleTap,
child: CameraIcon(
type: isPending ? CameraIconType.pending : CameraIconType.real,
),
child: CameraIcon(type: iconType),
);
}
}

View File

@@ -13,6 +13,7 @@ class DirectionConesBuilder {
required List<OsmCameraNode> cameras,
required double zoom,
AddCameraSession? session,
EditCameraSession? editSession,
}) {
final overlays = <Polygon>[];
@@ -22,13 +23,25 @@ class DirectionConesBuilder {
session.target!,
session.directionDegrees,
zoom,
isSession: true,
));
}
// Add cones for cameras with direction
// Add edit session cone if in edit-camera mode
if (editSession != null) {
overlays.add(_buildCone(
editSession.target,
editSession.directionDegrees,
zoom,
isSession: true,
));
}
// Add cones for cameras with direction (but exclude camera being edited)
overlays.addAll(
cameras
.where(_isValidCameraWithDirection)
.where((n) => _isValidCameraWithDirection(n) &&
(editSession == null || n.id != editSession.originalNode.id))
.map((n) => _buildCone(
n.coord,
n.directionDeg!,

View File

@@ -11,6 +11,7 @@ class MapOverlays extends StatelessWidget {
final MapController mapController;
final UploadMode uploadMode;
final AddCameraSession? session;
final EditCameraSession? editSession;
final String? attribution; // Attribution for current tile provider
const MapOverlays({
@@ -18,6 +19,7 @@ class MapOverlays extends StatelessWidget {
required this.mapController,
required this.uploadMode,
this.session,
this.editSession,
this.attribution,
});
@@ -130,13 +132,15 @@ class MapOverlays extends StatelessWidget {
),
),
// Fixed pin when adding camera
if (session != null)
// Fixed pin when adding or editing camera
if (session != null || editSession != null)
IgnorePointer(
child: Center(
child: Transform.translate(
offset: const Offset(0, kAddPinYOffset),
child: const CameraIcon(type: CameraIconType.mock),
child: CameraIcon(
type: editSession != null ? CameraIconType.editing : CameraIconType.mock
),
),
),
),

View File

@@ -21,17 +21,18 @@ import 'map/direction_cones.dart';
import 'map/map_overlays.dart';
import 'network_status_indicator.dart';
import '../dev_config.dart';
import '../screens/home_screen.dart' show FollowMeMode;
class MapView extends StatefulWidget {
final MapController controller;
const MapView({
super.key,
required this.controller,
required this.followMe,
required this.followMeMode,
required this.onUserGesture,
});
final bool followMe;
final FollowMeMode followMeMode;
final VoidCallback onUserGesture;
@override
@@ -135,8 +136,17 @@ class MapViewState extends State<MapView> {
@override
void didUpdateWidget(covariant MapView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.followMe && !oldWidget.followMe && _currentLatLng != null) {
_controller.move(_currentLatLng!, _controller.camera.zoom);
// Back to original pattern - simple check
if (widget.followMeMode != FollowMeMode.off &&
oldWidget.followMeMode == FollowMeMode.off &&
_currentLatLng != null) {
// Move to current location when follow me is first enabled
if (widget.followMeMode == FollowMeMode.northUp) {
_controller.move(_currentLatLng!, _controller.camera.zoom);
} else if (widget.followMeMode == FollowMeMode.rotating) {
// When switching to rotating mode, reset to north-up first
_controller.moveAndRotate(_currentLatLng!, _controller.camera.zoom, 0.0);
}
}
}
@@ -149,11 +159,21 @@ class MapViewState extends State<MapView> {
Geolocator.getPositionStream().listen((Position position) {
final latLng = LatLng(position.latitude, position.longitude);
setState(() => _currentLatLng = latLng);
if (widget.followMe) {
// Back to original pattern - directly check widget parameter
if (widget.followMeMode != FollowMeMode.off) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
_controller.move(latLng, _controller.camera.zoom);
if (widget.followMeMode == FollowMeMode.northUp) {
// Follow position only, keep current rotation
_controller.move(latLng, _controller.camera.zoom);
} else if (widget.followMeMode == FollowMeMode.rotating) {
// Follow position and rotation based on heading
final heading = position.heading;
final rotation = heading.isNaN ? 0.0 : -heading; // Convert to map rotation
_controller.moveAndRotate(latLng, _controller.camera.zoom, rotation);
}
} catch (e) {
debugPrint('MapController not ready yet: $e');
}
@@ -205,6 +225,7 @@ class MapViewState extends State<MapView> {
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final session = appState.session;
final editSession = appState.editSession;
// Check if enabled profiles changed and refresh cameras if needed
final currentEnabledProfiles = appState.enabledProfiles;
@@ -252,6 +273,17 @@ class MapViewState extends State<MapView> {
);
} catch (_) {/* controller not ready yet */}
}
// For edit sessions, center the map on the camera being edited initially
if (editSession != null && _controller.camera.center != editSession.target) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
try {
_controller.move(editSession.target, _controller.camera.zoom);
} catch (_) {/* controller not ready yet */}
},
);
}
final zoom = _safeZoom();
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
@@ -277,11 +309,16 @@ class MapViewState extends State<MapView> {
cameras: cameras,
zoom: zoom,
session: session,
editSession: editSession,
);
// Build edit lines connecting original cameras to their edited positions
final editLines = _buildEditLines(cameras);
return Stack(
children: [
PolygonLayer(polygons: overlays),
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
MarkerLayer(markers: markers),
],
);
@@ -303,6 +340,9 @@ class MapViewState extends State<MapView> {
if (session != null) {
appState.updateSession(target: pos.center);
}
if (editSession != null) {
appState.updateEditSession(target: pos.center);
}
// Show waiting indicator when map moves (user is expecting new content)
NetworkStatus.instance.setWaiting();
@@ -345,6 +385,7 @@ class MapViewState extends State<MapView> {
mapController: _controller,
uploadMode: appState.uploadMode,
session: session,
editSession: editSession,
attribution: appState.selectedTileType?.attribution,
),
@@ -353,5 +394,37 @@ class MapViewState extends State<MapView> {
],
);
}
/// Build polylines connecting original cameras to their edited positions
List<Polyline> _buildEditLines(List<OsmCameraNode> cameras) {
final lines = <Polyline>[];
// Create a lookup map of original node IDs to their coordinates
final originalNodes = <int, LatLng>{};
for (final camera in cameras) {
if (camera.tags['_pending_edit'] == 'true') {
originalNodes[camera.id] = camera.coord;
}
}
// Find edited cameras and draw lines to their originals
for (final camera in cameras) {
final originalIdStr = camera.tags['_original_node_id'];
if (originalIdStr != null && camera.tags['_pending_upload'] == 'true') {
final originalId = int.tryParse(originalIdStr);
final originalCoord = originalId != null ? originalNodes[originalId] : null;
if (originalCoord != null) {
lines.add(Polyline(
points: [originalCoord, camera.coord],
color: kCameraRingColorPending,
strokeWidth: 3.0,
));
}
}
}
return lines;
}
}