mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,19 +14,93 @@ class CameraProfile {
|
||||
this.builtin = false,
|
||||
});
|
||||
|
||||
/// Built‑in default: Generic Flock ALPR camera
|
||||
factory CameraProfile.alpr() => CameraProfile(
|
||||
id: 'builtin-alpr',
|
||||
name: 'Generic Flock',
|
||||
/// Built‑in 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
);
|
||||
|
||||
/// Built‑in: 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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 follow‑me' : 'Enable follow‑me',
|
||||
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(
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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...');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
133
lib/widgets/edit_camera_sheet.dart
Normal file
133
lib/widgets/edit_camera_sheet.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user