builds, needs work

This commit is contained in:
stopflock
2025-08-28 10:22:57 -05:00
parent 2db4f597dc
commit aee0dcf8b8
10 changed files with 362 additions and 12 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,4 @@ 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

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,
};
@@ -31,6 +37,7 @@ class PendingUpload {
profile: j['profile'] is Map<String, dynamic>
? CameraProfile.fromJson(j['profile'])
: CameraProfile.genericAlpr(),
originalNodeId: j['originalNodeId'],
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,
);

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,11 +12,28 @@ 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) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
@@ -23,9 +41,46 @@ class SessionState extends ChangeNotifier {
? 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,
@@ -49,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;
@@ -62,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,35 @@ 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();
// Update the original camera in the cache to mark it as having a pending edit
final originalTags = Map<String, String>.from(session.originalNode.tags);
originalTags['_pending_upload'] = 'true'; // Mark as pending for UI distinction
final updatedNode = OsmCameraNode(
id: session.originalNode.id,
coord: session.originalNode.coord,
tags: originalTags,
);
CameraCache.instance.addOrUpdate([updatedNode]);
// Notify camera provider to update the map
CameraProviderWithCache.instance.notifyListeners();
notifyListeners();
}
void clearQueue() {
_queue.clear();
_saveQueue();

View File

@@ -5,6 +5,7 @@ enum CameraIconType {
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
}
/// Simple camera icon with grey dot and colored ring
@@ -21,6 +22,8 @@ class CameraIcon extends StatelessWidget {
return kCameraRingColorMock;
case CameraIconType.pending:
return kCameraRingColorPending;
case CameraIconType.editing:
return kCameraRingColorEditing;
}
}

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
import 'edit_camera_sheet.dart';
class CameraTagSheet extends StatelessWidget {
final OsmCameraNode node;
@@ -8,6 +11,25 @@ class CameraTagSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
// Check if this camera is editable (not a pending upload)
final isEditable = !node.tags.containsKey('_pending_upload') ||
node.tags['_pending_upload'] != 'true';
void _openEditSheet() {
Navigator.pop(context); // Close this sheet first
appState.startEditSession(node);
final session = appState.editSession!;
// Show the edit sheet
showModalBottomSheet(
context: context,
builder: (_) => EditCameraSheet(session: session),
showDragHandle: true,
);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
@@ -46,13 +68,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

@@ -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

@@ -225,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;
@@ -272,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)
@@ -323,6 +335,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();
@@ -365,6 +380,7 @@ class MapViewState extends State<MapView> {
mapController: _controller,
uploadMode: appState.uploadMode,
session: session,
editSession: editSession,
attribution: appState.selectedTileType?.attribution,
),