Compare commits

...

25 Commits

Author SHA1 Message Date
stopflock
a17c50188e direction cones only when directional profile selected 2025-08-29 20:26:21 -05:00
stopflock
5c2bfbc76e Adjust map view when adding/editing to account for bottom sheet 2025-08-29 20:09:42 -05:00
stopflock
a8ac237317 more cameras -> nodes 2025-08-29 18:20:42 -05:00
stopflock
eeedbd7da7 clean up overpass fetching 2025-08-29 16:52:50 -05:00
stopflock
3ddebd2664 nodes, not cameras 2025-08-29 16:44:34 -05:00
stopflock
b5c210d009 fix extra follow-me state updates 2025-08-29 15:33:06 -05:00
stopflock
208b3486f3 first pass at operator profiles 2025-08-29 15:09:19 -05:00
stopflock
04a6d129b7 version bump 2025-08-29 14:21:06 -05:00
stopflock
944df59d7c remove todos from readme 2025-08-29 14:16:50 -05:00
stopflock
29031b1372 dont let user edit editable....... lol and make some builtins editable 2025-08-29 14:08:47 -05:00
stopflock
6bcfef0caa submittable is now an option on editable profiles 2025-08-29 13:53:41 -05:00
stopflock
d2a3e96a86 allow editing of certain builtin profiles 2025-08-29 13:48:08 -05:00
stopflock
395ef77fe3 gunshot detection - direction optional as defined by profile 2025-08-29 13:47:32 -05:00
stopflock
57acff8ae7 update readme 2025-08-29 12:00:18 -05:00
stopflock
a437d9bf60 1000 camera warning limit 2025-08-29 11:29:52 -05:00
stopflock
c4c1505253 refactor follow me mode state handling 2025-08-29 11:08:50 -05:00
stopflock
42c03eca7d fix follow me not turning off 2025-08-29 10:37:19 -05:00
stopflock
bcc4461621 add todo 2025-08-28 23:53:18 -05:00
stopflock
3cb875b67a bump version again 2025-08-28 23:51:21 -05:00
stopflock
d03ef6b50d update readme 2025-08-28 23:50:31 -05:00
stopflock
6db691dbeb Break out follow-me / gps stuff from map_view 2025-08-28 23:50:08 -05:00
stopflock
5ccf215f4e Separate camera_refresh from map view 2025-08-28 23:49:47 -05:00
stopflock
deb9a4272b pull out the tile layer manager 2025-08-28 23:49:19 -05:00
stopflock
1b3c3e620c put map position save/restore into its own file 2025-08-28 23:48:57 -05:00
stopflock
c42d3afd0b Fix edit submission (addnode version to xml changeset) and improve queue UI upon successful submission. And bump version - patch for 0.9.4. 2025-08-28 23:29:34 -05:00
40 changed files with 2164 additions and 927 deletions

View File

@@ -87,31 +87,23 @@ flutter run
## Roadmap
### Current Todo List
- **Performance**: 1000+ camera warning threshold for large datasets
- **UX Polish**:
- Fix "tiles loaded" indicator accuracy across different providers
- Generic tile provider error messages (not always "OSM tiles slow")
- Optional custom icons for camera profiles
- **Data Management**: Clean up cache when submitted changesets appear in Overpass results
- **Visual Improvements**: Upgrade camera marker design (considering nullplate's svg)
### v1 todo/bug List
- Fix "tiles loaded" indicator accuracy across different providers
- Generic tile provider error messages (not always "OSM tiles slow")
- Optional custom icons for camera profiles
- Camera deletions
- Clean up cache when submitted changesets appear in Overpass results
- Upgrade camera marker design (considering nullplate's svg)
### Future Features & Wishlist
- **Operator Profiles**:
- Additional tag sets for different surveillance operators
- **Announcement Mode**:
- Location-based notifications when approaching cameras
- **Enhanced Visualizations**:
- Red/yellow ring for cameras missing specific tag details
- iOS/Android native themes and dark mode support
- **Advanced Offline**:
- "Cache accumulating" offline areas with size estimates per area
- "Offline areas" as tile provider?
- **Navigation & Search**:
- Jump to location by coordinates, address, or POI name
- Route planning that avoids surveillance cameras
- **Data Sources**:
- Custom camera providers and OSM/Overpass alternatives
- Location-based notifications when approaching cameras
- Red/yellow ring for cameras missing specific tag details
- iOS/Android native themes and dark mode support
- "Cache accumulating" offline areas?
- "Offline areas" as tile provider?
- Jump to location by coordinates, address, or POI name
- Route planning that avoids surveillance cameras
- Custom camera providers and OSM/Overpass alternatives
---

View File

@@ -2,20 +2,22 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'models/camera_profile.dart';
import 'models/node_profile.dart';
import 'models/operator_profile.dart';
import 'models/osm_camera_node.dart';
import 'models/pending_upload.dart';
import 'models/tile_provider.dart';
import 'services/offline_area_service.dart';
import 'state/auth_state.dart';
import 'state/operator_profile_state.dart';
import 'state/profile_state.dart';
import 'state/session_state.dart';
import 'state/settings_state.dart';
import 'state/upload_queue_state.dart';
// Re-export types
export 'state/settings_state.dart' show UploadMode;
export 'state/session_state.dart' show AddCameraSession, EditCameraSession;
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
// ------------------ AppState ------------------
class AppState extends ChangeNotifier {
@@ -23,6 +25,7 @@ class AppState extends ChangeNotifier {
// State modules
late final AuthState _authState;
late final OperatorProfileState _operatorProfileState;
late final ProfileState _profileState;
late final SessionState _sessionState;
late final SettingsState _settingsState;
@@ -33,6 +36,7 @@ class AppState extends ChangeNotifier {
AppState() {
instance = this;
_authState = AuthState();
_operatorProfileState = OperatorProfileState();
_profileState = ProfileState();
_sessionState = SessionState();
_settingsState = SettingsState();
@@ -40,6 +44,7 @@ class AppState extends ChangeNotifier {
// Set up state change listeners
_authState.addListener(_onStateChanged);
_operatorProfileState.addListener(_onStateChanged);
_profileState.addListener(_onStateChanged);
_sessionState.addListener(_onStateChanged);
_settingsState.addListener(_onStateChanged);
@@ -56,18 +61,22 @@ class AppState extends ChangeNotifier {
String get username => _authState.username;
// Profile state
List<CameraProfile> get profiles => _profileState.profiles;
List<CameraProfile> get enabledProfiles => _profileState.enabledProfiles;
bool isEnabled(CameraProfile p) => _profileState.isEnabled(p);
List<NodeProfile> get profiles => _profileState.profiles;
List<NodeProfile> get enabledProfiles => _profileState.enabledProfiles;
bool isEnabled(NodeProfile p) => _profileState.isEnabled(p);
// Operator profile state
List<OperatorProfile> get operatorProfiles => _operatorProfileState.profiles;
// Session state
AddCameraSession? get session => _sessionState.session;
EditCameraSession? get editSession => _sessionState.editSession;
AddNodeSession? get session => _sessionState.session;
EditNodeSession? get editSession => _sessionState.editSession;
// Settings state
bool get offlineMode => _settingsState.offlineMode;
int get maxCameras => _settingsState.maxCameras;
UploadMode get uploadMode => _settingsState.uploadMode;
FollowMeMode get followMeMode => _settingsState.followMeMode;
// Tile provider state
List<TileProvider> get tileProviders => _settingsState.tileProviders;
@@ -88,6 +97,7 @@ class AppState extends ChangeNotifier {
Future<void> _init() async {
// Initialize all state modules
await _settingsState.init();
await _operatorProfileState.init();
await _profileState.init();
await _uploadQueueState.init();
await _authState.init(_settingsState.uploadMode);
@@ -124,18 +134,27 @@ class AppState extends ChangeNotifier {
}
// ---------- Profile Methods ----------
void toggleProfile(CameraProfile p, bool e) {
void toggleProfile(NodeProfile p, bool e) {
_profileState.toggleProfile(p, e);
}
void addOrUpdateProfile(CameraProfile p) {
void addOrUpdateProfile(NodeProfile p) {
_profileState.addOrUpdateProfile(p);
}
void deleteProfile(CameraProfile p) {
void deleteProfile(NodeProfile p) {
_profileState.deleteProfile(p);
}
// ---------- Operator Profile Methods ----------
void addOrUpdateOperatorProfile(OperatorProfile p) {
_operatorProfileState.addOrUpdateProfile(p);
}
void deleteOperatorProfile(OperatorProfile p) {
_operatorProfileState.deleteProfile(p);
}
// ---------- Session Methods ----------
void startAddSession() {
_sessionState.startAddSession(enabledProfiles);
@@ -147,24 +166,28 @@ class AppState extends ChangeNotifier {
void updateSession({
double? directionDeg,
CameraProfile? profile,
NodeProfile? profile,
OperatorProfile? operatorProfile,
LatLng? target,
}) {
_sessionState.updateSession(
directionDeg: directionDeg,
profile: profile,
operatorProfile: operatorProfile,
target: target,
);
}
void updateEditSession({
double? directionDeg,
CameraProfile? profile,
NodeProfile? profile,
OperatorProfile? operatorProfile,
LatLng? target,
}) {
_sessionState.updateEditSession(
directionDeg: directionDeg,
profile: profile,
operatorProfile: operatorProfile,
target: target,
);
}
@@ -230,7 +253,10 @@ class AppState extends ChangeNotifier {
await _settingsState.deleteTileProvider(providerId);
}
/// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async {
await _settingsState.setFollowMeMode(mode);
}
// ---------- Queue Methods ----------
void clearQueue() {
@@ -258,6 +284,7 @@ class AppState extends ChangeNotifier {
@override
void dispose() {
_authState.removeListener(_onStateChanged);
_operatorProfileState.removeListener(_onStateChanged);
_profileState.removeListener(_onStateChanged);
_sessionState.removeListener(_onStateChanged);
_settingsState.removeListener(_onStateChanged);

View File

@@ -11,7 +11,7 @@ const double kTileEstimateKb = 25.0;
// Direction cone for map view
const double kDirectionConeHalfAngle = 30.0; // degrees
const double kDirectionConeBaseLength = 0.001; // multiplier
const Color kDirectionConeColor = Color(0xFF111111); // FOV cone color
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
// Margin (bottom) for positioning the floating bottom button bar
const double kBottomButtonBarMargin = 4.0;
@@ -26,10 +26,10 @@ const double kAddPinYOffset = 0.0;
// Client name and version for OSM uploads ("created_by" tag)
const String kClientName = 'FlockMap';
const String kClientVersion = '0.9.4';
const String kClientVersion = '0.9.7';
// Marker/camera interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning
// Marker/node interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes or warning
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
@@ -41,7 +41,6 @@ const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rota
const String kLastMapLatKey = 'last_map_latitude';
const String kLastMapLngKey = 'last_map_longitude';
const String kLastMapZoomKey = 'last_map_zoom';
const String kFollowMeModeKey = 'follow_me_mode';
// Tile/OSM fetch retry parameters (for tunable backoff)
const int kTileFetchMaxAttempts = 3;
@@ -63,8 +62,8 @@ const int kAbsoluteMaxZoom = 19;
const double kCameraIconDiameter = 20.0;
const double kCameraRingThickness = 4.0;
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
const Color kCameraRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey

View File

@@ -1,147 +0,0 @@
import 'package:uuid/uuid.dart';
/// A bundle of preset OSM tags that describe a particular camera model/type.
class CameraProfile {
final String id;
final String name;
final Map<String, String> tags;
final bool builtin;
CameraProfile({
required this.id,
required this.name,
required this.tags,
this.builtin = false,
});
/// 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': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'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': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'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': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'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': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'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': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'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,
Map<String, String>? tags,
bool? builtin,
}) =>
CameraProfile(
id: id ?? this.id,
name: name ?? this.name,
tags: tags ?? this.tags,
builtin: builtin ?? this.builtin,
);
Map<String, dynamic> toJson() =>
{'id': id, 'name': name, 'tags': tags, 'builtin': builtin};
factory CameraProfile.fromJson(Map<String, dynamic> j) => CameraProfile(
id: j['id'],
name: j['name'],
tags: Map<String, String>.from(j['tags']),
builtin: j['builtin'] ?? false,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CameraProfile &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,231 @@
import 'package:uuid/uuid.dart';
/// A bundle of preset OSM tags that describe a particular surveillance node model/type.
class NodeProfile {
final String id;
final String name;
final Map<String, String> tags;
final bool builtin;
final bool requiresDirection;
final bool submittable;
final bool editable;
NodeProfile({
required this.id,
required this.name,
required this.tags,
this.builtin = false,
this.requiresDirection = true,
this.submittable = true,
this.editable = true,
});
/// Builtin default: Generic ALPR camera (customizable template, not submittable)
factory NodeProfile.genericAlpr() => NodeProfile(
id: 'builtin-generic-alpr',
name: 'Generic ALPR',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
},
builtin: true,
requiresDirection: true,
submittable: false,
editable: false,
);
/// Builtin: Flock Safety ALPR camera
factory NodeProfile.flock() => NodeProfile(
id: 'builtin-flock',
name: 'Flock',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Flock Safety',
'manufacturer:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Motorola Solutions/Vigilant ALPR camera
factory NodeProfile.motorola() => NodeProfile(
id: 'builtin-motorola',
name: 'Motorola/Vigilant',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Motorola Solutions',
'manufacturer:wikidata': 'Q634815',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Genetec ALPR camera
factory NodeProfile.genetec() => NodeProfile(
id: 'builtin-genetec',
name: 'Genetec',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Genetec',
'manufacturer:wikidata': 'Q30295174',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Leonardo/ELSAG ALPR camera
factory NodeProfile.leonardo() => NodeProfile(
id: 'builtin-leonardo',
name: 'Leonardo/ELSAG',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Leonardo',
'manufacturer:wikidata': 'Q910379',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Neology ALPR camera
factory NodeProfile.neology() => NodeProfile(
id: 'builtin-neology',
name: 'Neology',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Neology, Inc.',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Generic gunshot detector (customizable template, not submittable)
factory NodeProfile.genericGunshotDetector() => NodeProfile(
id: 'builtin-generic-gunshot',
name: 'Generic Gunshot Detector',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'gunshot_detector',
},
builtin: true,
requiresDirection: false,
submittable: false,
editable: false,
);
/// Builtin: ShotSpotter gunshot detector
factory NodeProfile.shotspotter() => NodeProfile(
id: 'builtin-shotspotter',
name: 'ShotSpotter',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'surveillance:brand': 'ShotSpotter',
'surveillance:brand:wikidata': 'Q107740188',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
);
/// Builtin: Flock Raven gunshot detector
factory NodeProfile.flockRaven() => NodeProfile(
id: 'builtin-flock-raven',
name: 'Flock Raven',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'brand': 'Flock Safety',
'brand:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
);
/// Returns true if this profile can be used for submissions
bool get isSubmittable => submittable;
NodeProfile copyWith({
String? id,
String? name,
Map<String, String>? tags,
bool? builtin,
bool? requiresDirection,
bool? submittable,
bool? editable,
}) =>
NodeProfile(
id: id ?? this.id,
name: name ?? this.name,
tags: tags ?? this.tags,
builtin: builtin ?? this.builtin,
requiresDirection: requiresDirection ?? this.requiresDirection,
submittable: submittable ?? this.submittable,
editable: editable ?? this.editable,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'tags': tags,
'builtin': builtin,
'requiresDirection': requiresDirection,
'submittable': submittable,
'editable': editable,
};
factory NodeProfile.fromJson(Map<String, dynamic> j) => NodeProfile(
id: j['id'],
name: j['name'],
tags: Map<String, String>.from(j['tags']),
builtin: j['builtin'] ?? false,
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
submittable: j['submittable'] ?? true, // Default to true for backward compatibility
editable: j['editable'] ?? true, // Default to true for backward compatibility
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is NodeProfile &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,48 @@
import 'package:uuid/uuid.dart';
/// A bundle of OSM tags that describe a particular surveillance operator.
/// These are applied on top of camera profile tags during submissions.
class OperatorProfile {
final String id;
final String name;
final Map<String, String> tags;
OperatorProfile({
required this.id,
required this.name,
required this.tags,
});
OperatorProfile copyWith({
String? id,
String? name,
Map<String, String>? tags,
}) =>
OperatorProfile(
id: id ?? this.id,
name: name ?? this.name,
tags: tags ?? this.tags,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'tags': tags,
};
factory OperatorProfile.fromJson(Map<String, dynamic> j) => OperatorProfile(
id: j['id'],
name: j['name'],
tags: Map<String, String>.from(j['tags']),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is OperatorProfile &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}

View File

@@ -1,24 +1,29 @@
import 'package:latlong2/latlong.dart';
import 'camera_profile.dart';
import 'node_profile.dart';
import 'operator_profile.dart';
import '../state/settings_state.dart';
class PendingUpload {
final LatLng coord;
final double direction;
final CameraProfile profile;
final NodeProfile profile;
final OperatorProfile? operatorProfile;
final UploadMode uploadMode; // Capture upload destination when queued
final int? originalNodeId; // If this is an edit, the ID of the original OSM node
int attempts;
bool error;
bool completing; // True when upload succeeded but item is showing checkmark briefly
PendingUpload({
required this.coord,
required this.direction,
required this.profile,
this.operatorProfile,
required this.uploadMode,
this.originalNodeId,
this.attempts = 0,
this.error = false,
this.completing = false,
});
// True if this is an edit of an existing camera, false if it's a new camera
@@ -36,29 +41,52 @@ class PendingUpload {
}
}
// Get combined tags from camera profile and operator profile
Map<String, String> getCombinedTags() {
final tags = Map<String, String>.from(profile.tags);
// Add operator profile tags (they override camera profile tags if there are conflicts)
if (operatorProfile != null) {
tags.addAll(operatorProfile!.tags);
}
// Add direction if required
if (profile.requiresDirection) {
tags['direction'] = direction.toStringAsFixed(0);
}
return tags;
}
Map<String, dynamic> toJson() => {
'lat': coord.latitude,
'lon': coord.longitude,
'dir': direction,
'profile': profile.toJson(),
'operatorProfile': operatorProfile?.toJson(),
'uploadMode': uploadMode.index,
'originalNodeId': originalNodeId,
'attempts': attempts,
'error': error,
'completing': completing,
};
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
coord: LatLng(j['lat'], j['lon']),
direction: j['dir'],
profile: j['profile'] is Map<String, dynamic>
? CameraProfile.fromJson(j['profile'])
: CameraProfile.genericAlpr(),
? NodeProfile.fromJson(j['profile'])
: NodeProfile.genericAlpr(),
operatorProfile: j['operatorProfile'] != null
? OperatorProfile.fromJson(j['operatorProfile'])
: null,
uploadMode: j['uploadMode'] != null
? UploadMode.values[j['uploadMode']]
: UploadMode.production, // Default for legacy entries
originalNodeId: j['originalNodeId'],
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,
completing: j['completing'] ?? false, // Default to false for legacy entries
);
}

View File

@@ -7,16 +7,11 @@ import '../app_state.dart';
import '../dev_config.dart';
import '../widgets/map_view.dart';
import '../widgets/add_camera_sheet.dart';
import '../widgets/edit_camera_sheet.dart';
import '../widgets/add_node_sheet.dart';
import '../widgets/edit_node_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
}
import '../widgets/measured_sheet.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -29,25 +24,16 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
late final AnimatedMapController _mapController;
FollowMeMode _followMeMode = FollowMeMode.northUp;
bool _editSheetShown = false;
// Track sheet heights for map padding
double _addSheetHeight = 0.0;
double _editSheetHeight = 0.0;
@override
void initState() {
super.initState();
_mapController = AnimatedMapController(vsync: this);
// Load saved follow-me mode
_loadFollowMeMode();
}
/// Load the saved follow-me mode
Future<void> _loadFollowMeMode() async {
final savedMode = await MapViewState.loadFollowMeMode();
if (mounted) {
setState(() {
_followMeMode = savedMode;
});
}
}
@override
@@ -56,8 +42,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
super.dispose();
}
String _getFollowMeTooltip() {
switch (_followMeMode) {
String _getFollowMeTooltip(FollowMeMode mode) {
switch (mode) {
case FollowMeMode.off:
return 'Enable follow-me (north up)';
case FollowMeMode.northUp:
@@ -67,8 +53,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
IconData _getFollowMeIcon() {
switch (_followMeMode) {
IconData _getFollowMeIcon(FollowMeMode mode) {
switch (mode) {
case FollowMeMode.off:
return Icons.gps_off;
case FollowMeMode.northUp:
@@ -78,8 +64,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
FollowMeMode _getNextFollowMeMode() {
switch (_followMeMode) {
FollowMeMode _getNextFollowMeMode(FollowMeMode mode) {
switch (mode) {
case FollowMeMode.off:
return FollowMeMode.northUp;
case FollowMeMode.northUp:
@@ -89,29 +75,57 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
void _openAddCameraSheet() {
// Disable follow-me when adding a camera so the map doesn't jump around
setState(() => _followMeMode = FollowMeMode.off);
void _openAddNodeSheet() {
final appState = context.read<AppState>();
// Disable follow-me when adding a camera so the map doesn't jump around
appState.setFollowMeMode(FollowMeMode.off);
appState.startAddSession();
final session = appState.session!; // guaranteed nonnull now
_scaffoldKey.currentState!.showBottomSheet(
(ctx) => AddCameraSheet(session: session),
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_addSheetHeight = height;
});
},
child: AddNodeSheet(session: session),
),
);
// Reset height when sheet is dismissed
controller.closed.then((_) {
setState(() {
_addSheetHeight = 0.0;
});
});
}
void _openEditCameraSheet() {
// Disable follow-me when editing a camera so the map doesn't jump around
setState(() => _followMeMode = FollowMeMode.off);
void _openEditNodeSheet() {
final appState = context.read<AppState>();
// Disable follow-me when editing a camera so the map doesn't jump around
appState.setFollowMeMode(FollowMeMode.off);
final session = appState.editSession!; // should be non-null when this is called
_scaffoldKey.currentState!.showBottomSheet(
(ctx) => EditCameraSheet(session: session),
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_editSheetHeight = height;
});
},
child: EditNodeSheet(session: session),
),
);
// Reset height when sheet is dismissed
controller.closed.then((_) {
setState(() {
_editSheetHeight = 0.0;
});
});
}
@override
@@ -121,11 +135,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Auto-open edit sheet when edit session starts
if (appState.editSession != null && !_editSheetShown) {
_editSheetShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _openEditCameraSheet());
WidgetsBinding.instance.addPostFrameCallback((_) => _openEditNodeSheet());
} else if (appState.editSession == null) {
_editSheetShown = false;
}
// Calculate bottom padding for map (90% of active sheet height)
final activeSheetHeight = _addSheetHeight > 0 ? _addSheetHeight : _editSheetHeight;
final mapBottomPadding = activeSheetHeight * 0.9;
return MultiProvider(
providers: [
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
@@ -136,18 +154,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
title: const Text('Flock Map'),
actions: [
IconButton(
tooltip: _getFollowMeTooltip(),
icon: Icon(_getFollowMeIcon()),
tooltip: _getFollowMeTooltip(appState.followMeMode),
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
onPressed: () {
setState(() {
final oldMode = _followMeMode;
_followMeMode = _getNextFollowMeMode();
debugPrint('[HomeScreen] Follow mode changed: $oldMode$_followMeMode');
});
// Save the new follow-me mode
MapViewState.saveFollowMeMode(_followMeMode);
final oldMode = appState.followMeMode;
final newMode = _getNextFollowMeMode(oldMode);
debugPrint('[HomeScreen] Follow mode changed: $oldMode$newMode');
appState.setFollowMeMode(newMode);
// If enabling follow-me, retry location init in case permission was granted
if (_followMeMode != FollowMeMode.off) {
if (newMode != FollowMeMode.off) {
_mapViewKey.currentState?.retryLocationInit();
}
},
@@ -163,10 +178,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
MapView(
key: _mapViewKey,
controller: _mapController,
followMeMode: _followMeMode,
followMeMode: appState.followMeMode,
bottomPadding: mapBottomPadding,
onUserGesture: () {
if (_followMeMode != FollowMeMode.off) {
setState(() => _followMeMode = FollowMeMode.off);
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
}
},
),
@@ -191,8 +207,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Expanded(
child: ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text('Tag Camera'),
onPressed: _openAddCameraSheet,
label: Text('Tag Node'),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),

View File

@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../models/operator_profile.dart';
import '../app_state.dart';
class OperatorProfileEditor extends StatefulWidget {
const OperatorProfileEditor({super.key, required this.profile});
final OperatorProfile profile;
@override
State<OperatorProfileEditor> createState() => _OperatorProfileEditorState();
}
class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
late TextEditingController _nameCtrl;
late List<MapEntry<String, String>> _tags;
static const _defaultTags = [
MapEntry('operator', ''),
MapEntry('operator:type', ''),
MapEntry('operator:wikidata', ''),
];
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.profile.name);
if (widget.profile.tags.isEmpty) {
// New profile → start with sensible defaults
_tags = [..._defaultTags];
} else {
_tags = widget.profile.tags.entries.toList();
}
}
@override
void dispose() {
_nameCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.profile.name.isEmpty ? 'New Operator Profile' : 'Edit Operator Profile'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Operator name',
hintText: 'e.g., Austin Police Department',
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('OSM Tags',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
TextButton.icon(
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
icon: const Icon(Icons.add),
label: const Text('Add tag'),
),
],
),
const SizedBox(height: 8),
..._buildTagRows(),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _save,
child: const Text('Save Profile'),
),
],
),
);
}
List<Widget> _buildTagRows() {
return List.generate(_tags.length, (i) {
final keyController = TextEditingController(text: _tags[i].key);
final valueController = TextEditingController(text: _tags[i].value);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(
flex: 2,
child: TextField(
decoration: const InputDecoration(
hintText: 'key',
border: OutlineInputBorder(),
isDense: true,
),
controller: keyController,
onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value),
),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: TextField(
decoration: const InputDecoration(
hintText: 'value',
border: OutlineInputBorder(),
isDense: true,
),
controller: valueController,
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => setState(() => _tags.removeAt(i)),
),
],
),
);
});
}
void _save() {
final name = _nameCtrl.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Operator name is required')));
return;
}
final tagMap = <String, String>{};
for (final e in _tags) {
if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue;
tagMap[e.key.trim()] = e.value.trim();
}
final newProfile = widget.profile.copyWith(
id: widget.profile.id.isEmpty ? const Uuid().v4() : widget.profile.id,
name: name,
tags: tagMap,
);
context.read<AppState>().addOrUpdateOperatorProfile(newProfile);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Operator profile "${newProfile.name}" saved')),
);
}
}

View File

@@ -2,13 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../models/camera_profile.dart';
import '../models/node_profile.dart';
import '../app_state.dart';
class ProfileEditor extends StatefulWidget {
const ProfileEditor({super.key, required this.profile});
final CameraProfile profile;
final NodeProfile profile;
@override
State<ProfileEditor> createState() => _ProfileEditorState();
@@ -17,6 +17,8 @@ class ProfileEditor extends StatefulWidget {
class _ProfileEditorState extends State<ProfileEditor> {
late TextEditingController _nameCtrl;
late List<MapEntry<String, String>> _tags;
late bool _requiresDirection;
late bool _submittable;
static const _defaultTags = [
MapEntry('man_made', 'surveillance'),
@@ -33,6 +35,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.profile.name);
_requiresDirection = widget.profile.requiresDirection;
_submittable = widget.profile.submittable;
if (widget.profile.tags.isEmpty) {
// New profile → start with sensible defaults
@@ -52,7 +56,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.profile.builtin
title: Text(!widget.profile.editable
? 'View Profile'
: (widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile')),
),
@@ -61,30 +65,47 @@ class _ProfileEditorState extends State<ProfileEditor> {
children: [
TextField(
controller: _nameCtrl,
readOnly: widget.profile.builtin,
readOnly: !widget.profile.editable,
decoration: const InputDecoration(
labelText: 'Profile name',
hintText: 'e.g., Custom ALPR Camera',
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('OSM Tags',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
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: 16),
if (widget.profile.editable) ...[
CheckboxListTile(
title: const Text('Requires Direction'),
subtitle: const Text('Whether cameras of this type need a direction tag'),
value: _requiresDirection,
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
controlAffinity: ListTileControlAffinity.leading,
),
CheckboxListTile(
title: const Text('Submittable'),
subtitle: const Text('Whether this profile can be used for camera submissions'),
value: _submittable,
onChanged: (value) => setState(() => _submittable = value ?? true),
controlAffinity: ListTileControlAffinity.leading,
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('OSM Tags',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
if (widget.profile.editable)
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),
if (!widget.profile.builtin)
if (widget.profile.editable)
ElevatedButton(
onPressed: _save,
child: const Text('Save Profile'),
@@ -112,8 +133,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
isDense: true,
),
controller: keyController,
readOnly: widget.profile.builtin,
onChanged: widget.profile.builtin
readOnly: !widget.profile.editable,
onChanged: !widget.profile.editable
? null
: (v) => _tags[i] = MapEntry(v, _tags[i].value),
),
@@ -128,13 +149,13 @@ class _ProfileEditorState extends State<ProfileEditor> {
isDense: true,
),
controller: valueController,
readOnly: widget.profile.builtin,
onChanged: widget.profile.builtin
readOnly: !widget.profile.editable,
onChanged: !widget.profile.editable
? null
: (v) => _tags[i] = MapEntry(_tags[i].key, v),
),
),
if (!widget.profile.builtin)
if (widget.profile.editable)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => setState(() => _tags.removeAt(i)),
@@ -170,6 +191,9 @@ class _ProfileEditorState extends State<ProfileEditor> {
name: name,
tags: tagMap,
builtin: false,
requiresDirection: _requiresDirection,
submittable: _submittable,
editable: true, // All custom profiles are editable by definition
);
context.read<AppState>().addOrUpdateProfile(newProfile);

View File

@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
import 'settings_screen_sections/auth_section.dart';
import 'settings_screen_sections/upload_mode_section.dart';
import 'settings_screen_sections/profile_list_section.dart';
import 'settings_screen_sections/operator_profile_list_section.dart';
import 'settings_screen_sections/queue_section.dart';
import 'settings_screen_sections/offline_areas_section.dart';
import 'settings_screen_sections/offline_mode_section.dart';
import 'settings_screen_sections/about_section.dart';
import 'settings_screen_sections/max_cameras_section.dart';
import 'settings_screen_sections/max_nodes_section.dart';
import 'settings_screen_sections/tile_provider_section.dart';
class SettingsScreen extends StatelessWidget {
@@ -27,7 +28,9 @@ class SettingsScreen extends StatelessWidget {
Divider(),
ProfileListSection(),
Divider(),
MaxCamerasSection(),
OperatorProfileListSection(),
Divider(),
MaxNodesSection(),
Divider(),
TileProviderSection(),
Divider(),

View File

@@ -2,21 +2,21 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
class MaxCamerasSection extends StatefulWidget {
const MaxCamerasSection({super.key});
class MaxNodesSection extends StatefulWidget {
const MaxNodesSection({super.key});
@override
State<MaxCamerasSection> createState() => _MaxCamerasSectionState();
State<MaxNodesSection> createState() => _MaxNodesSectionState();
}
class _MaxCamerasSectionState extends State<MaxCamerasSection> {
class _MaxNodesSectionState extends State<MaxNodesSection> {
late TextEditingController _controller;
@override
void initState() {
super.initState();
final maxCameras = context.read<AppState>().maxCameras;
_controller = TextEditingController(text: maxCameras.toString());
final maxNodes = context.read<AppState>().maxCameras;
_controller = TextEditingController(text: maxNodes.toString());
}
@override
@@ -29,17 +29,17 @@ class _MaxCamerasSectionState extends State<MaxCamerasSection> {
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final current = appState.maxCameras;
final showWarning = current > 250;
final showWarning = current > 1000;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.filter_alt),
title: const Text('Max cameras fetched/drawn'),
title: const Text('Max nodes fetched/drawn'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Set an upper limit for the number of cameras on the map (default: 250).'),
const Text('Set an upper limit for the number of nodes on the map (default: 250).'),
if (showWarning)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/operator_profile.dart';
import '../operator_profile_editor.dart';
class OperatorProfileListSection extends StatelessWidget {
const OperatorProfileListSection({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Operator Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
TextButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OperatorProfileEditor(
profile: OperatorProfile(
id: const Uuid().v4(),
name: '',
tags: const {},
),
),
),
),
icon: const Icon(Icons.add),
label: const Text('New Profile'),
),
],
),
if (appState.operatorProfiles.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'No operator profiles defined. Create one to apply operator tags to node submissions.',
style: TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
),
)
else
...appState.operatorProfiles.map(
(p) => ListTile(
title: Text(p.name),
subtitle: Text('${p.tags.length} tags'),
trailing: PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: const Row(
children: [
Icon(Icons.edit),
SizedBox(width: 8),
Text('Edit'),
],
),
),
PopupMenuItem(
value: 'delete',
child: const Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Delete', style: TextStyle(color: Colors.red)),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OperatorProfileEditor(profile: p),
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
),
),
],
);
}
void _showDeleteProfileDialog(BuildContext context, OperatorProfile profile) {
final appState = context.read<AppState>();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Operator Profile'),
content: Text('Are you sure you want to delete "${profile.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
appState.deleteOperatorProfile(profile);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Operator profile deleted')),
);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
}
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/camera_profile.dart';
import '../../models/node_profile.dart';
import '../profile_editor.dart';
class ProfileListSection extends StatelessWidget {
@@ -17,13 +17,13 @@ class ProfileListSection extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Camera Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const Text('Node Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
TextButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(
profile: CameraProfile(
profile: NodeProfile(
id: const Uuid().v4(),
name: '',
tags: const {},
@@ -44,7 +44,7 @@ class ProfileListSection extends StatelessWidget {
),
title: Text(p.name),
subtitle: Text(p.builtin ? 'Built-in' : 'Custom'),
trailing: p.builtin
trailing: !p.editable
? PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
@@ -111,7 +111,7 @@ class ProfileListSection extends StatelessWidget {
);
}
void _showDeleteProfileDialog(BuildContext context, CameraProfile profile) {
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
final appState = context.read<AppState>();
showDialog(
context: context,

View File

@@ -81,78 +81,86 @@ class QueueSection extends StatelessWidget {
}
void _showQueueDialog(BuildContext context) {
final appState = context.read<AppState>();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Upload Queue (${appState.pendingCount} items)'),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: ListView.builder(
itemCount: appState.pendingUploads.length,
itemBuilder: (context, index) {
final upload = appState.pendingUploads[index];
return ListTile(
leading: Icon(
upload.error ? Icons.error : Icons.camera_alt,
color: upload.error
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text('Camera ${index + 1}${upload.error ? " (Error)" : ""}'),
subtitle: Text(
'Dest: ${_getUploadModeDisplayName(upload.uploadMode)}\n'
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
'Direction: ${upload.direction.round()}°\n'
'Attempts: ${upload.attempts}' +
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (upload.error)
IconButton(
icon: const Icon(Icons.refresh),
color: Colors.orange,
tooltip: 'Retry upload',
onPressed: () {
appState.retryUpload(upload);
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
appState.removeFromQueue(upload);
if (appState.pendingCount == 0) {
Navigator.pop(context);
}
},
),
],
),
);
},
builder: (context) => Consumer<AppState>(
builder: (context, appState, child) => AlertDialog(
title: Text('Upload Queue (${appState.pendingCount} items)'),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: appState.pendingUploads.isEmpty
? const Center(child: Text('Queue is empty'))
: ListView.builder(
itemCount: appState.pendingUploads.length,
itemBuilder: (context, index) {
final upload = appState.pendingUploads[index];
return ListTile(
leading: Icon(
upload.error ? Icons.error : Icons.camera_alt,
color: upload.error
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text('Camera ${index + 1}'
'${upload.error ? " (Error)" : ""}'
'${upload.completing ? " (Completing...)" : ""}'),
subtitle: Text(
'Dest: ${_getUploadModeDisplayName(upload.uploadMode)}\n'
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
'Direction: ${upload.direction.round()}°\n'
'Attempts: ${upload.attempts}' +
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (upload.error && !upload.completing)
IconButton(
icon: const Icon(Icons.refresh),
color: Colors.orange,
tooltip: 'Retry upload',
onPressed: () {
appState.retryUpload(upload);
},
),
if (upload.completing)
const Icon(Icons.check_circle, color: Colors.green)
else
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
appState.removeFromQueue(upload);
if (appState.pendingCount == 0) {
Navigator.pop(context);
}
},
),
],
),
);
},
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
if (appState.pendingCount > 1)
actions: [
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
},
child: const Text('Clear All'),
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
if (appState.pendingCount > 1)
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
},
child: const Text('Clear All'),
),
],
),
),
);
}

View File

@@ -2,12 +2,12 @@ import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import '../models/camera_profile.dart';
import '../models/node_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
import 'map_data_submodules/cameras_from_overpass.dart';
import 'map_data_submodules/nodes_from_overpass.dart';
import 'map_data_submodules/tiles_from_remote.dart';
import 'map_data_submodules/cameras_from_local.dart';
import 'map_data_submodules/nodes_from_local.dart';
import 'map_data_submodules/tiles_from_local.dart';
enum MapSource { local, remote, auto } // For future use
@@ -31,11 +31,11 @@ class MapDataProvider {
AppState.instance.setOfflineMode(enabled);
}
/// Fetch cameras from OSM/Overpass or local storage.
/// Fetch surveillance nodes from OSM/Overpass or local storage.
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
Future<List<OsmCameraNode>> getCameras({
Future<List<OsmCameraNode>> getNodes({
required LatLngBounds bounds,
required List<CameraProfile> profiles,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
MapSource source = MapSource.auto,
}) async {
@@ -44,20 +44,19 @@ class MapDataProvider {
// Explicit remote request: error if offline, else always remote
if (source == MapSource.remote) {
if (offline) {
throw OfflineModeException("Cannot fetch remote cameras in offline mode.");
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
}
return camerasFromOverpass(
return fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
pageSize: AppState.instance.maxCameras,
fetchAllPages: false,
maxResults: AppState.instance.maxCameras,
);
}
// Explicit local request: always use local
if (source == MapSource.local) {
return fetchLocalCameras(
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
);
@@ -65,50 +64,49 @@ class MapDataProvider {
// AUTO: default = remote first, fallback to local only if offline
if (offline) {
return fetchLocalCameras(
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
);
} else {
// Try remote, fallback to local ONLY if remote throws (optional, could be removed for stricter behavior)
try {
return await camerasFromOverpass(
return await fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
pageSize: AppState.instance.maxCameras,
maxResults: AppState.instance.maxCameras,
);
} catch (e) {
debugPrint('[MapDataProvider] Remote camera fetch failed, error: $e. Falling back to local.');
return fetchLocalCameras(
bounds: bounds,
profiles: profiles,
maxCameras: AppState.instance.maxCameras,
);
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Falling back to local.');
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
);
}
}
}
/// Bulk/paged camera fetch for offline downloads (handling paging, dedup, and Overpass retries)
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
/// Only use for offline area download, not for map browsing! Ignores maxCameras config.
Future<List<OsmCameraNode>> getAllCamerasForDownload({
Future<List<OsmCameraNode>> getAllNodesForDownload({
required LatLngBounds bounds,
required List<CameraProfile> profiles,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
int pageSize = 500,
int maxTries = 3,
}) async {
final offline = AppState.instance.offlineMode;
if (offline) {
throw OfflineModeException("Cannot fetch remote cameras for offline area download in offline mode.");
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
}
return camerasFromOverpass(
return fetchOverpassNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
fetchAllPages: true,
pageSize: pageSize,
maxTries: maxTries,
maxResults: pageSize,
);
}

View File

@@ -1,84 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../models/camera_profile.dart';
import '../../models/osm_camera_node.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches cameras from the Overpass OSM API for the given bounds and profiles.
/// If fetchAllPages is true, returns all possible cameras using multiple API calls (paging with pageSize).
/// If false (the default), returns only the first page of up to pageSize results.
Future<List<OsmCameraNode>> camerasFromOverpass({
required LatLngBounds bounds,
required List<CameraProfile> profiles,
UploadMode uploadMode = UploadMode.production,
int pageSize = 500, // Used for both default limit and paging chunk
bool fetchAllPages = false, // True for offline area download, else just grabs first chunk
int maxTries = 3,
}) async {
if (profiles.isEmpty) return [];
const String prodEndpoint = 'https://overpass-api.de/api/interpreter';
final nodeClauses = profiles.map((profile) {
final tagFilters = profile.tags.entries
.map((e) => '["${e.key}"="${e.value}"]')
.join('\n ');
return '''node\n $tagFilters\n (${bounds.southWest.latitude},${bounds.southWest.longitude},\n ${bounds.northEast.latitude},${bounds.northEast.longitude});''';
}).join('\n ');
// Helper for one Overpass chunk fetch
Future<List<OsmCameraNode>> fetchChunk() async {
final outLine = fetchAllPages ? 'out body;' : 'out body $pageSize;';
final query = '''
[out:json][timeout:25];
(
$nodeClauses
);
$outLine
''';
try {
print('[camerasFromOverpass] Querying Overpass...');
print('[camerasFromOverpass] Query:\n$query');
final resp = await http.post(Uri.parse(prodEndpoint), body: {'data': query.trim()});
// Only log errors
if (resp.statusCode != 200) {
debugPrint('[camerasFromOverpass] Overpass failed: ${resp.body}');
NetworkStatus.instance.reportOverpassIssue();
return [];
}
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
// Only log if many cameras found or if it's a bulk download
if (elements.length > 20 || fetchAllPages) {
debugPrint('[camerasFromOverpass] Retrieved ${elements.length} cameras');
}
NetworkStatus.instance.reportOverpassSuccess();
return elements.whereType<Map<String, dynamic>>().map((e) {
return OsmCameraNode(
id: e['id'],
coord: LatLng(e['lat'], e['lon']),
tags: Map<String, String>.from(e['tags'] ?? {}),
);
}).toList();
} catch (e) {
print('[camerasFromOverpass] Overpass exception: $e');
// Report network issues on connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOverpassIssue();
}
return [];
}
}
// All paths just use a single fetch now; paging logic no longer required.
return await fetchChunk();
}

View File

@@ -3,15 +3,15 @@ import 'dart:convert';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../../models/osm_camera_node.dart';
import '../../models/camera_profile.dart';
import '../../models/node_profile.dart';
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
/// Fetch camera nodes from all offline areas intersecting the bounds/profile list.
Future<List<OsmCameraNode>> fetchLocalCameras({
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
Future<List<OsmCameraNode>> fetchLocalNodes({
required LatLngBounds bounds,
required List<CameraProfile> profiles,
int? maxCameras,
required List<NodeProfile> profiles,
int? maxNodes,
}) async {
final areas = OfflineAreaService().offlineAreas;
final Map<int, OsmCameraNode> deduped = {};
@@ -20,24 +20,24 @@ Future<List<OsmCameraNode>> fetchLocalCameras({
if (area.status != OfflineAreaStatus.complete) continue;
if (!area.bounds.isOverlapping(bounds)) continue;
final nodes = await _loadAreaCameras(area);
for (final cam in nodes) {
// Deduplicate by camera ID, preferring the first occurrence
if (deduped.containsKey(cam.id)) continue;
final nodes = await _loadAreaNodes(area);
for (final node in nodes) {
// Deduplicate by node ID, preferring the first occurrence
if (deduped.containsKey(node.id)) continue;
// Within view bounds?
if (!_pointInBounds(cam.coord, bounds)) continue;
if (!_pointInBounds(node.coord, bounds)) continue;
// Profile filter if used
if (profiles.isNotEmpty && !_matchesAnyProfile(cam, profiles)) continue;
deduped[cam.id] = cam;
if (profiles.isNotEmpty && !_matchesAnyProfile(node, profiles)) continue;
deduped[node.id] = node;
}
}
final out = deduped.values.take(maxCameras ?? deduped.length).toList();
final out = deduped.values.take(maxNodes ?? deduped.length).toList();
return out;
}
// Try in-memory first, else load from disk
Future<List<OsmCameraNode>> _loadAreaCameras(OfflineArea area) async {
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
if (area.cameras.isNotEmpty) {
return area.cameras;
}
@@ -57,16 +57,16 @@ bool _pointInBounds(LatLng pt, LatLngBounds bounds) {
pt.longitude <= bounds.northEast.longitude;
}
bool _matchesAnyProfile(OsmCameraNode cam, List<CameraProfile> profiles) {
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
for (final prof in profiles) {
if (_cameraMatchesProfile(cam, prof)) return true;
if (_nodeMatchesProfile(node, prof)) return true;
}
return false;
}
bool _cameraMatchesProfile(OsmCameraNode cam, CameraProfile profile) {
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
for (final e in profile.tags.entries) {
if (cam.tags[e.key] != e.value) return false; // All profile tags must match
if (node.tags[e.key] != e.value) return false; // All profile tags must match
}
return true;
}

View File

@@ -0,0 +1,92 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../models/node_profile.dart';
import '../../models/osm_camera_node.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
Future<List<OsmCameraNode>> fetchOverpassNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
if (profiles.isEmpty) return [];
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
// Build the Overpass query
final query = _buildOverpassQuery(bounds, profiles, maxResults);
try {
debugPrint('[fetchOverpassNodes] Querying Overpass for surveillance nodes...');
debugPrint('[fetchOverpassNodes] Query:\n$query');
final response = await http.post(
Uri.parse(overpassEndpoint),
body: {'data': query.trim()}
);
if (response.statusCode != 200) {
debugPrint('[fetchOverpassNodes] Overpass API error: ${response.body}');
NetworkStatus.instance.reportOverpassIssue();
return [];
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
if (elements.length > 20) {
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} surveillance nodes');
}
NetworkStatus.instance.reportOverpassSuccess();
return elements.whereType<Map<String, dynamic>>().map((element) {
return OsmCameraNode(
id: element['id'],
coord: LatLng(element['lat'], element['lon']),
tags: Map<String, String>.from(element['tags'] ?? {}),
);
}).toList();
} catch (e) {
debugPrint('[fetchOverpassNodes] Exception: $e');
// Report network issues for connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOverpassIssue();
}
return [];
}
}
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
// Build node clauses for each profile
final nodeClauses = profiles.map((profile) {
// Convert profile tags to Overpass filter format
final tagFilters = profile.tags.entries
.map((entry) => '["${entry.key}"="${entry.value}"]')
.join();
// Build the node query with tag filters and bounding box
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n ');
return '''
[out:json][timeout:25];
(
$nodeClauses
);
out body $maxResults;
''';
}

View File

@@ -147,7 +147,7 @@ class OfflineAreaDownloader {
}) async {
// Calculate expanded camera bounds that cover the entire tile area at minimum zoom
final cameraBounds = _calculateCameraBounds(bounds, minZoom);
final cameras = await MapDataProvider().getAllCamerasForDownload(
final cameras = await MapDataProvider().getAllNodesForDownload(
bounds: cameraBounds,
profiles: AppState.instance.profiles, // Use ALL profiles, not just enabled ones
);

View File

@@ -0,0 +1,22 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/operator_profile.dart';
class OperatorProfileService {
static const _key = 'operator_profiles';
Future<List<OperatorProfile>> load() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_key);
if (jsonStr == null) return [];
final list = jsonDecode(jsonStr) as List<dynamic>;
return list.map((e) => OperatorProfile.fromJson(e)).toList();
}
Future<void> save(List<OperatorProfile> profiles) async {
final prefs = await SharedPreferences.getInstance();
final encodable = profiles.map((p) => p.toJson()).toList();
await prefs.setString(_key, jsonEncode(encodable));
}
}

View File

@@ -1,20 +1,20 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/camera_profile.dart';
import '../models/node_profile.dart';
class ProfileService {
static const _key = 'custom_profiles';
Future<List<CameraProfile>> load() async {
Future<List<NodeProfile>> load() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_key);
if (jsonStr == null) return [];
final list = jsonDecode(jsonStr) as List<dynamic>;
return list.map((e) => CameraProfile.fromJson(e)).toList();
return list.map((e) => NodeProfile.fromJson(e)).toList();
}
Future<void> save(List<CameraProfile> profiles) async {
Future<void> save(List<NodeProfile> profiles) async {
final prefs = await SharedPreferences.getInstance();
// MUST convert to List before jsonEncode; the previous MappedIterable

View File

@@ -14,7 +14,7 @@ class Uploader {
Future<bool> upload(PendingUpload p) async {
try {
print('Uploader: Starting upload for camera at ${p.coord.latitude}, ${p.coord.longitude}');
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
// 1. open changeset
final action = p.isEdit ? 'Update' : 'Add';
@@ -22,7 +22,7 @@ class Uploader {
<osm>
<changeset>
<tag k="created_by" v="$kClientName $kClientVersion"/>
<tag k="comment" v="$action ${p.profile.name} surveillance camera"/>
<tag k="comment" v="$action ${p.profile.name} surveillance node"/>
</changeset>
</osm>''';
print('Uploader: Creating changeset...');
@@ -36,8 +36,7 @@ class Uploader {
print('Uploader: Created changeset ID: $csId');
// 2. create or update node
final mergedTags = Map<String, String>.from(p.profile.tags)
..['direction'] = p.direction.round().toString();
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
@@ -45,10 +44,29 @@ class Uploader {
final String nodeId;
if (p.isEdit) {
// Update existing node
// First, fetch the current node to get its version
print('Uploader: Fetching current node ${p.originalNodeId} to get version...');
final currentNodeResp = await _get('/api/0.6/node/${p.originalNodeId}');
print('Uploader: Current node response: ${currentNodeResp.statusCode}');
if (currentNodeResp.statusCode != 200) {
print('Uploader: Failed to fetch current node');
return false;
}
// Parse version from the response XML
final currentNodeXml = currentNodeResp.body;
final versionMatch = RegExp(r'version="(\d+)"').firstMatch(currentNodeXml);
if (versionMatch == null) {
print('Uploader: Could not parse version from current node XML');
return false;
}
final currentVersion = versionMatch.group(1)!;
print('Uploader: Current node version: $currentVersion');
// Update existing node with version
final nodeXml = '''
<osm>
<node changeset="$csId" id="${p.originalNodeId}" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
<node changeset="$csId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
$tagsXml
</node>
</osm>''';
@@ -99,6 +117,11 @@ class Uploader {
}
}
Future<http.Response> _get(String path) => http.get(
Uri.https(_host, path),
headers: _headers,
);
Future<http.Response> _post(String path, String body) => http.post(
Uri.https(_host, path),
headers: _headers,

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import '../models/operator_profile.dart';
import '../services/operator_profile_service.dart';
class OperatorProfileState extends ChangeNotifier {
final List<OperatorProfile> _profiles = [];
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
Future<void> init() async {
_profiles.addAll(await OperatorProfileService().load());
}
void addOrUpdateProfile(OperatorProfile p) {
final idx = _profiles.indexWhere((x) => x.id == p.id);
if (idx >= 0) {
_profiles[idx] = p;
} else {
_profiles.add(p);
}
OperatorProfileService().save(_profiles);
notifyListeners();
}
void deleteProfile(OperatorProfile p) {
_profiles.removeWhere((x) => x.id == p.id);
OperatorProfileService().save(_profiles);
notifyListeners();
}
}

View File

@@ -1,30 +1,33 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/camera_profile.dart';
import '../models/node_profile.dart';
import '../services/profile_service.dart';
class ProfileState extends ChangeNotifier {
static const String _enabledPrefsKey = 'enabled_profiles';
final List<CameraProfile> _profiles = [];
final Set<CameraProfile> _enabled = {};
final List<NodeProfile> _profiles = [];
final Set<NodeProfile> _enabled = {};
// Getters
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
bool isEnabled(CameraProfile p) => _enabled.contains(p);
List<CameraProfile> get enabledProfiles =>
List<NodeProfile> get profiles => List.unmodifiable(_profiles);
bool isEnabled(NodeProfile p) => _enabled.contains(p);
List<NodeProfile> get enabledProfiles =>
_profiles.where(isEnabled).toList(growable: false);
// Initialize profiles from built-in and custom sources
Future<void> init() async {
// Initialize profiles: built-in + custom
_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.add(NodeProfile.genericAlpr());
_profiles.add(NodeProfile.flock());
_profiles.add(NodeProfile.motorola());
_profiles.add(NodeProfile.genetec());
_profiles.add(NodeProfile.leonardo());
_profiles.add(NodeProfile.neology());
_profiles.add(NodeProfile.genericGunshotDetector());
_profiles.add(NodeProfile.shotspotter());
_profiles.add(NodeProfile.flockRaven());
_profiles.addAll(await ProfileService().load());
// Load enabled profile IDs from prefs
@@ -39,7 +42,7 @@ class ProfileState extends ChangeNotifier {
}
}
void toggleProfile(CameraProfile p, bool e) {
void toggleProfile(NodeProfile p, bool e) {
if (e) {
_enabled.add(p);
} else {
@@ -54,7 +57,7 @@ class ProfileState extends ChangeNotifier {
notifyListeners();
}
void addOrUpdateProfile(CameraProfile p) {
void addOrUpdateProfile(NodeProfile p) {
final idx = _profiles.indexWhere((x) => x.id == p.id);
if (idx >= 0) {
_profiles[idx] = p;
@@ -67,8 +70,8 @@ class ProfileState extends ChangeNotifier {
notifyListeners();
}
void deleteProfile(CameraProfile p) {
if (p.builtin) return;
void deleteProfile(NodeProfile p) {
if (!p.editable) return;
_enabled.remove(p);
_profiles.removeWhere((x) => x.id == p.id);
// Safety: Always have at least one enabled profile

View File

@@ -1,55 +1,58 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/camera_profile.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../models/osm_camera_node.dart';
// ------------------ AddCameraSession ------------------
class AddCameraSession {
AddCameraSession({required this.profile, this.directionDegrees = 0});
CameraProfile profile;
// ------------------ AddNodeSession ------------------
class AddNodeSession {
AddNodeSession({required this.profile, this.directionDegrees = 0});
NodeProfile profile;
OperatorProfile? operatorProfile;
double directionDegrees;
LatLng? target;
}
// ------------------ EditCameraSession ------------------
class EditCameraSession {
EditCameraSession({
// ------------------ EditNodeSession ------------------
class EditNodeSession {
EditNodeSession({
required this.originalNode,
required this.profile,
required this.directionDegrees,
required this.target,
});
final OsmCameraNode originalNode; // The original camera being edited
CameraProfile profile;
final OsmCameraNode originalNode; // The original node being edited
NodeProfile profile;
OperatorProfile? operatorProfile;
double directionDegrees;
LatLng target; // Current position (can be dragged)
}
class SessionState extends ChangeNotifier {
AddCameraSession? _session;
EditCameraSession? _editSession;
AddNodeSession? _session;
EditNodeSession? _editSession;
// Getters
AddCameraSession? get session => _session;
EditCameraSession? get editSession => _editSession;
AddNodeSession? get session => _session;
EditNodeSession? get editSession => _editSession;
void startAddSession(List<CameraProfile> enabledProfiles) {
void startAddSession(List<NodeProfile> enabledProfiles) {
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);
_session = AddNodeSession(profile: defaultProfile);
_editSession = null; // Clear any edit session
notifyListeners();
}
void startEditSession(OsmCameraNode node, List<CameraProfile> enabledProfiles) {
void startEditSession(OsmCameraNode node, List<NodeProfile> 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
NodeProfile matchingProfile = submittableProfiles.isNotEmpty
? submittableProfiles.first
: enabledProfiles.first;
@@ -61,7 +64,7 @@ class SessionState extends ChangeNotifier {
}
}
_editSession = EditCameraSession(
_editSession = EditNodeSession(
originalNode: node,
profile: matchingProfile,
directionDegrees: node.directionDeg ?? 0,
@@ -71,7 +74,7 @@ class SessionState extends ChangeNotifier {
notifyListeners();
}
bool _profileMatchesTags(CameraProfile profile, Map<String, String> tags) {
bool _profileMatchesTags(NodeProfile 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) {
@@ -83,7 +86,8 @@ class SessionState extends ChangeNotifier {
void updateSession({
double? directionDeg,
CameraProfile? profile,
NodeProfile? profile,
OperatorProfile? operatorProfile,
LatLng? target,
}) {
if (_session == null) return;
@@ -97,6 +101,10 @@ class SessionState extends ChangeNotifier {
_session!.profile = profile;
dirty = true;
}
if (operatorProfile != _session!.operatorProfile) {
_session!.operatorProfile = operatorProfile;
dirty = true;
}
if (target != null) {
_session!.target = target;
dirty = true;
@@ -106,7 +114,8 @@ class SessionState extends ChangeNotifier {
void updateEditSession({
double? directionDeg,
CameraProfile? profile,
NodeProfile? profile,
OperatorProfile? operatorProfile,
LatLng? target,
}) {
if (_editSession == null) return;
@@ -120,6 +129,10 @@ class SessionState extends ChangeNotifier {
_editSession!.profile = profile;
dirty = true;
}
if (operatorProfile != _editSession!.operatorProfile) {
_editSession!.operatorProfile = operatorProfile;
dirty = true;
}
if (target != null && target != _editSession!.target) {
_editSession!.target = target;
dirty = true;
@@ -137,7 +150,7 @@ class SessionState extends ChangeNotifier {
notifyListeners();
}
AddCameraSession? commitSession() {
AddNodeSession? commitSession() {
if (_session?.target == null) return null;
final session = _session!;
@@ -146,7 +159,7 @@ class SessionState extends ChangeNotifier {
return session;
}
EditCameraSession? commitEditSession() {
EditNodeSession? commitEditSession() {
if (_editSession == null) return null;
final session = _editSession!;

View File

@@ -8,6 +8,13 @@ import '../models/tile_provider.dart';
// Enum for upload mode (Production, OSM Sandbox, Simulate)
enum UploadMode { production, sandbox, simulate }
// Enum for follow-me mode (moved from HomeScreen to centralized state)
enum FollowMeMode {
off, // No following
northUp, // Follow position, keep north up
rotating, // Follow position and rotation
}
class SettingsState extends ChangeNotifier {
static const String _offlineModePrefsKey = 'offline_mode';
static const String _maxCamerasPrefsKey = 'max_cameras';
@@ -15,10 +22,12 @@ class SettingsState extends ChangeNotifier {
static const String _tileProvidersPrefsKey = 'tile_providers';
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
static const String _legacyTestModePrefsKey = 'test_mode';
static const String _followMeModePrefsKey = 'follow_me_mode';
bool _offlineMode = false;
int _maxCameras = 250;
UploadMode _uploadMode = UploadMode.simulate;
FollowMeMode _followMeMode = FollowMeMode.northUp;
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
@@ -26,6 +35,7 @@ class SettingsState extends ChangeNotifier {
bool get offlineMode => _offlineMode;
int get maxCameras => _maxCameras;
UploadMode get uploadMode => _uploadMode;
FollowMeMode get followMeMode => _followMeMode;
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId;
@@ -91,6 +101,14 @@ class SettingsState extends ChangeNotifier {
// Load tile providers (default to built-in providers if none saved)
await _loadTileProviders(prefs);
// Load follow-me mode
if (prefs.containsKey(_followMeModePrefsKey)) {
final modeIndex = prefs.getInt(_followMeModePrefsKey) ?? 0;
if (modeIndex >= 0 && modeIndex < FollowMeMode.values.length) {
_followMeMode = FollowMeMode.values[modeIndex];
}
}
// Load selected tile type (default to first available)
_selectedTileTypeId = prefs.getString(_selectedTileTypePrefsKey) ?? '';
if (_selectedTileTypeId.isEmpty || selectedTileType == null) {
@@ -211,5 +229,14 @@ class SettingsState extends ChangeNotifier {
notifyListeners();
}
/// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async {
if (_followMeMode != mode) {
_followMeMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_followMeModePrefsKey, mode.index);
notifyListeners();
}
}
}

View File

@@ -25,11 +25,12 @@ class UploadQueueState extends ChangeNotifier {
}
// Add a completed session to the upload queue
void addFromSession(AddCameraSession session, {required UploadMode uploadMode}) {
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target!,
direction: session.directionDegrees,
profile: session.profile,
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
);
@@ -40,8 +41,7 @@ class UploadQueueState extends ChangeNotifier {
// Create a temporary node with a negative ID (to distinguish from real OSM nodes)
// Using timestamp as negative ID to ensure uniqueness
final tempId = -DateTime.now().millisecondsSinceEpoch;
final tags = Map<String, String>.from(upload.profile.tags);
tags['direction'] = upload.direction.toStringAsFixed(0);
final tags = upload.getCombinedTags();
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
final tempNode = OsmCameraNode(
@@ -58,11 +58,12 @@ class UploadQueueState extends ChangeNotifier {
}
// Add a completed edit session to the upload queue
void addFromEditSession(EditCameraSession session, {required UploadMode uploadMode}) {
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target,
direction: session.directionDegrees,
profile: session.profile,
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
originalNodeId: session.originalNode.id, // Track which node we're editing
);
@@ -84,8 +85,7 @@ class UploadQueueState extends ChangeNotifier {
// 2. Create new temp node for the edited camera (purple ring) at new location
final tempId = -DateTime.now().millisecondsSinceEpoch;
final editedTags = Map<String, String>.from(upload.profile.tags);
editedTags['direction'] = upload.direction.toStringAsFixed(0);
final editedTags = upload.getCombinedTags();
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
@@ -157,18 +157,14 @@ class UploadQueueState extends ChangeNotifier {
// Real upload -- use the upload mode that was saved when this item was queued
debugPrint('[UploadQueue] Real upload to: ${item.uploadMode}');
final up = Uploader(access, () {
_queue.remove(item);
_saveQueue();
notifyListeners();
_markAsCompleting(item);
}, uploadMode: item.uploadMode);
ok = await up.upload(item);
}
if (ok && item.uploadMode == UploadMode.simulate) {
// Remove manually for simulate mode
_queue.remove(item);
_saveQueue();
notifyListeners();
// Mark as completing for simulate mode too
_markAsCompleting(item);
}
if (!ok) {
item.attempts++;
@@ -189,6 +185,20 @@ class UploadQueueState extends ChangeNotifier {
_uploadTimer?.cancel();
}
// Mark an item as completing (shows checkmark) and schedule removal after 1 second
void _markAsCompleting(PendingUpload item) {
item.completing = true;
_saveQueue();
notifyListeners();
// Remove the item after 1 second
Timer(const Duration(seconds: 1), () {
_queue.remove(item);
_saveQueue();
notifyListeners();
});
}
// ---------- Queue persistence ----------
Future<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();

View File

@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/camera_profile.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import 'refine_tags_sheet.dart';
class AddCameraSheet extends StatelessWidget {
const AddCameraSheet({super.key, required this.session});
class AddNodeSheet extends StatelessWidget {
const AddNodeSheet({super.key, required this.session});
final AddCameraSession session;
final AddNodeSession session;
@override
Widget build(BuildContext context) {
@@ -17,7 +19,7 @@ class AddCameraSheet extends StatelessWidget {
appState.commitSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Camera queued for upload')),
const SnackBar(content: Text('Node queued for upload')),
);
}
@@ -27,7 +29,22 @@ class AddCameraSheet extends StatelessWidget {
}
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = submittableProfiles.isNotEmpty && session.profile.isSubmittable;
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
context,
MaterialPageRoute(
builder: (context) => RefineTagsSheet(
selectedOperatorProfile: session.operatorProfile,
),
fullscreenDialog: true,
),
);
if (result != session.operatorProfile) {
appState.updateSession(operatorProfile: result);
}
}
return Padding(
padding:
@@ -47,7 +64,7 @@ class AddCameraSheet extends StatelessWidget {
const SizedBox(height: 16),
ListTile(
title: const Text('Profile'),
trailing: DropdownButton<CameraProfile>(
trailing: DropdownButton<NodeProfile>(
value: session.profile,
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
@@ -64,19 +81,53 @@ class AddCameraSheet extends StatelessWidget {
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: (v) => appState.updateSession(directionDeg: v),
onChanged: session.profile.requiresDirection
? (v) => appState.updateSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (submittableProfiles.isEmpty)
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
children: [
Icon(Icons.info_outline, color: Colors.grey, size: 16),
SizedBox(width: 6),
Expanded(
child: Text(
'This profile does not require a direction.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.red, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'Enable a submittable profile in Settings to submit new cameras.',
'You must be logged in to submit new nodes. Please log in via Settings.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.red, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'Enable a submittable profile in Settings to submit new nodes.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
@@ -87,12 +138,12 @@ class AddCameraSheet extends StatelessWidget {
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
children: [
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 submit new cameras.',
'This profile is for map viewing only. Please select a submittable profile to submit new nodes.',
style: TextStyle(color: Colors.orange, fontSize: 13),
),
),
@@ -100,6 +151,20 @@ class AddCameraSheet extends StatelessWidget {
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? 'Refine Tags (${session.operatorProfile!.name})'
: 'Refine Tags'),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(

View File

@@ -6,7 +6,7 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import '../services/map_data_provider.dart';
import '../services/camera_cache.dart';
import '../services/network_status.dart';
import '../models/camera_profile.dart';
import '../models/node_profile.dart';
import '../models/osm_camera_node.dart';
import '../app_state.dart';
@@ -38,7 +38,7 @@ class CameraProviderWithCache extends ChangeNotifier {
/// and notifies listeners/UI when new data is available.
void fetchAndUpdate({
required LatLngBounds bounds,
required List<CameraProfile> profiles,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
}) {
// Fast: serve cached immediately
@@ -48,7 +48,7 @@ class CameraProviderWithCache extends ChangeNotifier {
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
try {
// Use MapSource.auto to handle both offline and online modes appropriately
final fresh = await MapDataProvider().getCameras(
final fresh = await MapDataProvider().getNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
@@ -79,7 +79,7 @@ class CameraProviderWithCache extends ChangeNotifier {
}
/// Check if a camera matches any of the provided profiles
bool _matchesAnyProfile(OsmCameraNode camera, List<CameraProfile> profiles) {
bool _matchesAnyProfile(OsmCameraNode camera, List<NodeProfile> profiles) {
for (final profile in profiles) {
if (_cameraMatchesProfile(camera, profile)) return true;
}
@@ -87,7 +87,7 @@ class CameraProviderWithCache extends ChangeNotifier {
}
/// Check if a camera matches a specific profile (all profile tags must match)
bool _cameraMatchesProfile(OsmCameraNode camera, CameraProfile profile) {
bool _cameraMatchesProfile(OsmCameraNode camera, NodeProfile profile) {
for (final entry in profile.tags.entries) {
if (camera.tags[entry.key] != entry.value) return false;
}

View File

@@ -2,13 +2,15 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/camera_profile.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../state/settings_state.dart';
import 'refine_tags_sheet.dart';
class EditCameraSheet extends StatelessWidget {
const EditCameraSheet({super.key, required this.session});
class EditNodeSheet extends StatelessWidget {
const EditNodeSheet({super.key, required this.session});
final EditCameraSession session;
final EditNodeSession session;
@override
Widget build(BuildContext context) {
@@ -18,7 +20,7 @@ class EditCameraSheet extends StatelessWidget {
appState.commitEditSession();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Camera edit queued for upload')),
const SnackBar(content: Text('Node edit queued for upload')),
);
}
@@ -29,7 +31,22 @@ class EditCameraSheet extends StatelessWidget {
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
final allowSubmit = submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode;
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable && !isSandboxMode;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
context,
MaterialPageRoute(
builder: (context) => RefineTagsSheet(
selectedOperatorProfile: session.operatorProfile,
),
fullscreenDialog: true,
),
);
if (result != session.operatorProfile) {
appState.updateEditSession(operatorProfile: result);
}
}
return Padding(
padding:
@@ -48,13 +65,13 @@ class EditCameraSheet extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'Edit Camera #${session.originalNode.id}',
'Edit Node #${session.originalNode.id}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
ListTile(
title: const Text('Profile'),
trailing: DropdownButton<CameraProfile>(
trailing: DropdownButton<NodeProfile>(
value: session.profile,
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
@@ -71,19 +88,53 @@ class EditCameraSheet extends StatelessWidget {
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: (v) => appState.updateEditSession(directionDeg: v),
onChanged: session.profile.requiresDirection
? (v) => appState.updateEditSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (isSandboxMode)
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
children: [
Icon(Icons.info_outline, color: Colors.grey, size: 16),
SizedBox(width: 6),
Expanded(
child: Text(
'This profile does not require a direction.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.red, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'You must be logged in to edit nodes. Please log in via Settings.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (isSandboxMode)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.blue, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit cameras.',
'Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.',
style: TextStyle(color: Colors.blue, fontSize: 13),
),
),
@@ -94,12 +145,12 @@ class EditCameraSheet extends StatelessWidget {
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
children: [
Icon(Icons.info_outline, color: Colors.red, size: 20),
SizedBox(width: 6),
Expanded(
child: Text(
'Enable a submittable profile in Settings to edit cameras.',
'Enable a submittable profile in Settings to edit nodes.',
style: TextStyle(color: Colors.red, fontSize: 13),
),
),
@@ -110,12 +161,12 @@ class EditCameraSheet extends StatelessWidget {
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: const [
children: [
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.',
'This profile is for map viewing only. Please select a submittable profile to edit nodes.',
style: TextStyle(color: Colors.orange, fontSize: 13),
),
),
@@ -123,6 +174,20 @@ class EditCameraSheet extends StatelessWidget {
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? 'Refine Tags (${session.operatorProfile!.name})'
: 'Refine Tags'),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(

View File

@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/node_profile.dart';
import '../../app_state.dart' show UploadMode;
import '../camera_provider_with_cache.dart';
import '../../dev_config.dart';
/// Manages camera data refreshing, profile change detection, and camera cache operations.
/// Handles debounced camera fetching and profile-based cache invalidation.
class CameraRefreshController {
late final CameraProviderWithCache _cameraProvider;
List<NodeProfile>? _lastEnabledProfiles;
VoidCallback? _onCamerasUpdated;
/// Initialize the camera refresh controller
void initialize({required VoidCallback onCamerasUpdated}) {
_cameraProvider = CameraProviderWithCache.instance;
_onCamerasUpdated = onCamerasUpdated;
_cameraProvider.addListener(_onCamerasUpdated!);
}
/// Dispose of resources and listeners
void dispose() {
if (_onCamerasUpdated != null) {
_cameraProvider.removeListener(_onCamerasUpdated!);
}
}
/// Check if camera profiles changed and handle cache clearing if needed.
/// Returns true if profiles changed (triggering a refresh).
bool checkAndHandleProfileChanges({
required List<NodeProfile> currentEnabledProfiles,
required VoidCallback onProfilesChanged,
}) {
if (_lastEnabledProfiles == null ||
!_profileListsEqual(_lastEnabledProfiles!, currentEnabledProfiles)) {
_lastEnabledProfiles = List.from(currentEnabledProfiles);
// Handle profile change with cache clearing and refresh
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear camera cache to ensure fresh data for new profile combination
_cameraProvider.clearCache();
// Force display refresh first (for immediate UI update)
_cameraProvider.refreshDisplay();
// Notify that profiles changed (triggers camera refresh)
onProfilesChanged();
});
return true;
}
return false;
}
/// Refresh cameras from provider for the current map view
void refreshCamerasFromProvider({
required AnimatedMapController controller,
required List<NodeProfile> enabledProfiles,
required UploadMode uploadMode,
required BuildContext context,
}) {
LatLngBounds? bounds;
try {
bounds = controller.mapController.camera.visibleBounds;
} catch (_) {
return;
}
final zoom = controller.mapController.camera.zoom;
if (zoom < kCameraMinZoomLevel) {
// Show a snackbar-style bubble warning
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cameras not drawn below zoom level $kCameraMinZoomLevel'),
duration: const Duration(seconds: 2),
),
);
}
return;
}
_cameraProvider.fetchAndUpdate(
bounds: bounds,
profiles: enabledProfiles,
uploadMode: uploadMode,
);
}
/// Get the camera provider instance for external access
CameraProviderWithCache get cameraProvider => _cameraProvider;
/// Helper to check if two profile lists are equal by comparing IDs
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
if (list1.length != list2.length) return false;
// Compare by profile IDs since profiles are value objects
final ids1 = list1.map((p) => p.id).toSet();
final ids2 = list2.map((p) => p.id).toSet();
return ids1.length == ids2.length && ids1.containsAll(ids2);
}
}

View File

@@ -12,13 +12,13 @@ class DirectionConesBuilder {
static List<Polygon> buildDirectionCones({
required List<OsmCameraNode> cameras,
required double zoom,
AddCameraSession? session,
EditCameraSession? editSession,
AddNodeSession? session,
EditNodeSession? editSession,
}) {
final overlays = <Polygon>[];
// Add session cone if in add-camera mode
if (session != null && session.target != null) {
// Add session cone if in add-camera mode and profile requires direction
if (session != null && session.target != null && session.profile.requiresDirection) {
overlays.add(_buildCone(
session.target!,
session.directionDegrees,
@@ -27,8 +27,8 @@ class DirectionConesBuilder {
));
}
// Add edit session cone if in edit-camera mode
if (editSession != null) {
// Add edit session cone if in edit-camera mode and profile requires direction
if (editSession != null && editSession.profile.requiresDirection) {
overlays.add(_buildCone(
editSession.target,
editSession.directionDegrees,

View File

@@ -0,0 +1,160 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../../dev_config.dart';
import '../../app_state.dart' show FollowMeMode;
/// Manages GPS location tracking, follow-me modes, and location-based map animations.
/// Handles GPS permissions, position streams, and follow-me behavior.
class GpsController {
StreamSubscription<Position>? _positionSub;
LatLng? _currentLatLng;
/// Get the current GPS location (if available)
LatLng? get currentLocation => _currentLatLng;
/// Initialize GPS location tracking
Future<void> initializeLocation() async {
final perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied ||
perm == LocationPermission.deniedForever) {
debugPrint('[GpsController] Location permission denied');
return;
}
_positionSub = Geolocator.getPositionStream().listen((Position position) {
final latLng = LatLng(position.latitude, position.longitude);
_currentLatLng = latLng;
debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude}');
});
}
/// Retry location initialization (e.g., after permission granted)
Future<void> retryLocationInit() async {
debugPrint('[GpsController] Retrying location initialization');
await initializeLocation();
}
/// Handle follow-me mode changes and animate map accordingly
void handleFollowMeModeChange({
required FollowMeMode newMode,
required FollowMeMode oldMode,
required AnimatedMapController controller,
}) {
debugPrint('[GpsController] Follow-me mode changed: $oldMode$newMode');
// Only act when follow-me is first enabled and we have a current location
if (newMode != FollowMeMode.off &&
oldMode == FollowMeMode.off &&
_currentLatLng != null) {
try {
if (newMode == FollowMeMode.northUp) {
controller.animateTo(
dest: _currentLatLng!,
zoom: controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (newMode == FollowMeMode.rotating) {
// When switching to rotating mode, reset to north-up first
controller.animateTo(
dest: _currentLatLng!,
zoom: controller.mapController.camera.zoom,
rotation: 0.0,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
}
} catch (e) {
debugPrint('[GpsController] MapController not ready for follow-me change: $e');
}
}
}
/// Process GPS position updates and handle follow-me animations
void processPositionUpdate({
required Position position,
required FollowMeMode followMeMode,
required AnimatedMapController controller,
required VoidCallback onLocationUpdated,
}) {
final latLng = LatLng(position.latitude, position.longitude);
_currentLatLng = latLng;
// Notify that location was updated (for setState, etc.)
onLocationUpdated();
// Handle follow-me animations if enabled - use current mode from app state
if (followMeMode != FollowMeMode.off) {
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
if (followMeMode == FollowMeMode.northUp) {
// Follow position only, keep current rotation
controller.animateTo(
dest: latLng,
zoom: controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (followMeMode == FollowMeMode.rotating) {
// Follow position and rotation based on heading
final heading = position.heading;
final speed = position.speed; // Speed in m/s
// Only apply rotation if moving fast enough to avoid wild spinning when stationary
final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN;
final rotation = shouldRotate ? -heading : controller.mapController.camera.rotation;
controller.animateTo(
dest: latLng,
zoom: controller.mapController.camera.zoom,
rotation: rotation,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
}
} catch (e) {
debugPrint('[GpsController] MapController not ready for position animation: $e');
}
});
}
}
/// Initialize GPS with custom position processing callback
Future<void> initializeWithCallback({
required FollowMeMode followMeMode,
required AnimatedMapController controller,
required VoidCallback onLocationUpdated,
required FollowMeMode Function() getCurrentFollowMeMode,
}) async {
final perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied ||
perm == LocationPermission.deniedForever) {
debugPrint('[GpsController] Location permission denied');
return;
}
_positionSub = Geolocator.getPositionStream().listen((Position position) {
// Get the current follow-me mode from the app state each time
final currentFollowMeMode = getCurrentFollowMeMode();
processPositionUpdate(
position: position,
followMeMode: currentFollowMeMode,
controller: controller,
onLocationUpdated: onLocationUpdated,
);
});
}
/// Dispose of GPS resources
void dispose() {
_positionSub?.cancel();
_positionSub = null;
debugPrint('[GpsController] GPS controller disposed');
}
}

View File

@@ -10,8 +10,8 @@ import 'layer_selector_button.dart';
class MapOverlays extends StatelessWidget {
final MapController mapController;
final UploadMode uploadMode;
final AddCameraSession? session;
final EditCameraSession? editSession;
final AddNodeSession? session;
final EditNodeSession? editSession;
final String? attribution; // Attribution for current tile provider
const MapOverlays({
@@ -132,18 +132,7 @@ class MapOverlays extends StatelessWidget {
),
),
// Fixed pin when adding or editing camera
if (session != null || editSession != null)
IgnorePointer(
child: Center(
child: Transform.translate(
offset: const Offset(0, kAddPinYOffset),
child: CameraIcon(
type: editSession != null ? CameraIconType.editing : CameraIconType.mock
),
),
),
),
],
);
}

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../dev_config.dart';
/// Manages map position persistence and initial positioning.
/// Handles saving/loading last map position and moving to initial locations.
class MapPositionManager {
LatLng? _initialLocation;
double? _initialZoom;
bool _hasMovedToInitialLocation = false;
/// Get the initial location (if any was loaded)
LatLng? get initialLocation => _initialLocation;
/// Get the initial zoom (if any was loaded)
double? get initialZoom => _initialZoom;
/// Whether we've already moved to the initial location
bool get hasMovedToInitialLocation => _hasMovedToInitialLocation;
/// Load the last map position from persistent storage.
/// Call this during initialization to set up initial location.
Future<void> loadLastMapPosition() async {
try {
final prefs = await SharedPreferences.getInstance();
final lat = prefs.getDouble(kLastMapLatKey);
final lng = prefs.getDouble(kLastMapLngKey);
final zoom = prefs.getDouble(kLastMapZoomKey);
if (lat != null && lng != null &&
_isValidCoordinate(lat) && _isValidCoordinate(lng)) {
final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0;
_initialLocation = LatLng(lat, lng);
_initialZoom = validZoom;
debugPrint('[MapPositionManager] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
} else {
debugPrint('[MapPositionManager] Invalid saved coordinates, using defaults');
}
} catch (e) {
debugPrint('[MapPositionManager] Failed to load last map position: $e');
}
}
/// Move to initial location if we have one and haven't moved yet.
/// Call this after the map controller is ready.
void moveToInitialLocationIfNeeded(AnimatedMapController controller) {
if (!_hasMovedToInitialLocation && _initialLocation != null) {
try {
final zoom = _initialZoom ?? 15.0;
// Double-check coordinates are valid before moving
if (_isValidCoordinate(_initialLocation!.latitude) &&
_isValidCoordinate(_initialLocation!.longitude) &&
_isValidZoom(zoom)) {
controller.mapController.move(_initialLocation!, zoom);
_hasMovedToInitialLocation = true;
debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
} else {
debugPrint('[MapPositionManager] Invalid initial location, not moving: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $zoom');
}
} catch (e) {
debugPrint('[MapPositionManager] Failed to move to initial location: $e');
}
}
}
/// Save the current map position to persistent storage.
/// Call this when the map position changes.
Future<void> saveMapPosition(LatLng location, double zoom) async {
try {
// Validate coordinates and zoom before saving
if (!_isValidCoordinate(location.latitude) ||
!_isValidCoordinate(location.longitude) ||
!_isValidZoom(zoom)) {
debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
return;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(kLastMapLatKey, location.latitude);
await prefs.setDouble(kLastMapLngKey, location.longitude);
await prefs.setDouble(kLastMapZoomKey, zoom);
debugPrint('[MapPositionManager] Saved last map position: ${location.latitude}, ${location.longitude}, zoom: $zoom');
} catch (e) {
debugPrint('[MapPositionManager] Failed to save last map position: $e');
}
}
/// Clear any stored map position (useful for recovery from invalid data)
static Future<void> clearStoredMapPosition() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(kLastMapLatKey);
await prefs.remove(kLastMapLngKey);
await prefs.remove(kLastMapZoomKey);
debugPrint('[MapPositionManager] Cleared stored map position');
} catch (e) {
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
}
}
/// Validate that a coordinate value is valid (not NaN, not infinite, within bounds)
bool _isValidCoordinate(double value) {
return !value.isNaN &&
!value.isInfinite &&
value >= -180.0 &&
value <= 180.0;
}
/// Validate that a zoom level is valid
bool _isValidZoom(double zoom) {
return !zoom.isNaN &&
!zoom.isInfinite &&
zoom >= 1.0 &&
zoom <= 25.0;
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../models/tile_provider.dart' as models;
import '../../services/simple_tile_service.dart';
/// Manages tile layer creation, caching, and provider switching.
/// Handles tile HTTP client lifecycle and cache invalidation.
class TileLayerManager {
late final SimpleTileHttpClient _tileHttpClient;
int _mapRebuildKey = 0;
String? _lastTileTypeId;
bool? _lastOfflineMode;
/// Get the current map rebuild key for cache busting
int get mapRebuildKey => _mapRebuildKey;
/// Initialize the tile layer manager
void initialize() {
_tileHttpClient = SimpleTileHttpClient();
}
/// Dispose of resources
void dispose() {
_tileHttpClient.close();
}
/// Check if cache should be cleared and increment rebuild key if needed.
/// Returns true if cache was cleared (map should be rebuilt).
bool checkAndClearCacheIfNeeded({
required String? currentTileTypeId,
required bool currentOfflineMode,
}) {
bool shouldClear = false;
String? reason;
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) {
reason = 'tile type ($currentTileTypeId)';
shouldClear = true;
} else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
reason = 'offline mode ($currentOfflineMode)';
shouldClear = true;
}
if (shouldClear) {
// Force map rebuild with new key to bust flutter_map cache
_mapRebuildKey++;
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
}
_lastTileTypeId = currentTileTypeId;
_lastOfflineMode = currentOfflineMode;
return shouldClear;
}
/// Clear the tile request queue (call after cache clear)
void clearTileQueue() {
debugPrint('[TileLayerManager] Post-frame: Clearing tile request queue');
_tileHttpClient.clearTileQueue();
}
/// Clear tile queue immediately (for zoom changes, etc.)
void clearTileQueueImmediate() {
_tileHttpClient.clearTileQueue();
}
/// Build tile layer widget with current provider and type.
/// Uses fake domain that SimpleTileHttpClient can parse for cache separation.
Widget buildTileLayer({
required models.TileProvider? selectedProvider,
required models.TileType? selectedTileType,
}) {
// Use fake domain with standard HTTPS scheme: https://tiles.local/provider/type/z/x/y
// This naturally separates cache entries by provider and type while being HTTP-compatible
final urlTemplate = 'https://tiles.local/${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
return TileLayer(
urlTemplate: urlTemplate,
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
),
);
}
}

View File

@@ -1,29 +1,28 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../app_state.dart';
import '../services/offline_area_service.dart';
import '../services/simple_tile_service.dart';
import '../services/network_status.dart';
import '../models/osm_camera_node.dart';
import '../models/camera_profile.dart';
import '../models/node_profile.dart';
import '../models/tile_provider.dart';
import 'debouncer.dart';
import 'camera_provider_with_cache.dart';
import 'camera_icon.dart';
import 'map/camera_markers.dart';
import 'map/direction_cones.dart';
import 'map/map_overlays.dart';
import 'map/map_position_manager.dart';
import 'map/tile_layer_manager.dart';
import 'map/camera_refresh_controller.dart';
import 'map/gps_controller.dart';
import 'network_status_indicator.dart';
import '../dev_config.dart';
import '../screens/home_screen.dart' show FollowMeMode;
import '../app_state.dart' show FollowMeMode;
class MapView extends StatefulWidget {
final AnimatedMapController controller;
@@ -32,10 +31,12 @@ class MapView extends StatefulWidget {
required this.controller,
required this.followMeMode,
required this.onUserGesture,
this.bottomPadding = 0.0,
});
final FollowMeMode followMeMode;
final VoidCallback onUserGesture;
final double bottomPadding;
@override
State<MapView> createState() => MapViewState();
@@ -47,99 +48,71 @@ class MapViewState extends State<MapView> {
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000));
StreamSubscription<Position>? _positionSub;
LatLng? _currentLatLng;
LatLng? _initialLocation;
double? _initialZoom;
bool _hasMovedToInitialLocation = false;
late final CameraProviderWithCache _cameraProvider;
late final SimpleTileHttpClient _tileHttpClient;
// Track profile changes to trigger camera refresh
List<CameraProfile>? _lastEnabledProfiles;
late final MapPositionManager _positionManager;
late final TileLayerManager _tileManager;
late final CameraRefreshController _cameraController;
late final GpsController _gpsController;
// Track zoom to clear queue on zoom changes
double? _lastZoom;
// Track changes that require cache clearing
String? _lastTileTypeId;
bool? _lastOfflineMode;
int _mapRebuildKey = 0;
@override
void initState() {
super.initState();
OfflineAreaService();
_controller = widget.controller;
_tileHttpClient = SimpleTileHttpClient();
_positionManager = MapPositionManager();
_tileManager = TileLayerManager();
_tileManager.initialize();
_cameraController = CameraRefreshController();
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
_gpsController = GpsController();
// Load last map position before initializing GPS
_loadLastMapPosition().then((_) {
_positionManager.loadLastMapPosition().then((_) {
// Move to last known position after loading and widget is built
WidgetsBinding.instance.addPostFrameCallback((_) {
_moveToInitialLocationIfNeeded();
_positionManager.moveToInitialLocationIfNeeded(_controller);
});
});
_initLocation();
// Set up camera overlay caching
_cameraProvider = CameraProviderWithCache.instance;
_cameraProvider.addListener(_onCamerasUpdated);
// Initialize GPS with callback for position updates and follow-me
_gpsController.initializeWithCallback(
followMeMode: widget.followMeMode,
controller: _controller,
onLocationUpdated: () => setState(() {}),
getCurrentFollowMeMode: () {
// Use mounted check to avoid calling context when widget is disposed
if (mounted) {
try {
return context.read<AppState>().followMeMode;
} catch (e) {
debugPrint('[MapView] Could not read AppState, defaulting to off: $e');
return FollowMeMode.off;
}
}
return FollowMeMode.off;
},
);
// Fetch initial cameras
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshCamerasFromProvider();
});
}
/// Move to initial location if we have one and haven't moved yet
void _moveToInitialLocationIfNeeded() {
if (!_hasMovedToInitialLocation && _initialLocation != null && mounted) {
try {
final zoom = _initialZoom ?? 15.0;
// Double-check coordinates are valid before moving
if (_isValidCoordinate(_initialLocation!.latitude) &&
_isValidCoordinate(_initialLocation!.longitude) &&
_isValidZoom(zoom)) {
_controller.mapController.move(_initialLocation!, zoom);
_hasMovedToInitialLocation = true;
debugPrint('[MapView] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
} else {
debugPrint('[MapView] Invalid initial location, not moving: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $zoom');
}
} catch (e) {
debugPrint('[MapView] Failed to move to initial location: $e');
}
}
}
/// Validate that a coordinate value is valid (not NaN, not infinite, within bounds)
bool _isValidCoordinate(double value) {
return !value.isNaN &&
!value.isInfinite &&
value >= -180.0 &&
value <= 180.0;
}
/// Validate that a zoom level is valid
bool _isValidZoom(double zoom) {
return !zoom.isNaN &&
!zoom.isInfinite &&
zoom >= 1.0 &&
zoom <= 25.0;
}
@override
void dispose() {
_positionSub?.cancel();
_cameraDebounce.dispose();
_tileDebounce.dispose();
_mapPositionDebounce.dispose();
_cameraProvider.removeListener(_onCamerasUpdated);
_tileHttpClient.close();
_cameraController.dispose();
_tileManager.dispose();
_gpsController.dispose();
super.dispose();
}
@@ -149,121 +122,22 @@ class MapViewState extends State<MapView> {
/// Public method to retry location initialization (e.g., after permission granted)
void retryLocationInit() {
debugPrint('[MapView] Retrying location initialization');
_initLocation();
_gpsController.retryLocationInit();
}
/// Save the last map position to persistent storage
Future<void> _saveLastMapPosition(LatLng location, double zoom) async {
try {
// Validate coordinates and zoom before saving
if (!_isValidCoordinate(location.latitude) ||
!_isValidCoordinate(location.longitude) ||
!_isValidZoom(zoom)) {
debugPrint('[MapView] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
return;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(kLastMapLatKey, location.latitude);
await prefs.setDouble(kLastMapLngKey, location.longitude);
await prefs.setDouble(kLastMapZoomKey, zoom);
debugPrint('[MapView] Saved last map position: ${location.latitude}, ${location.longitude}, zoom: $zoom');
} catch (e) {
debugPrint('[MapView] Failed to save last map position: $e');
}
}
/// Load the last map position from persistent storage
Future<void> _loadLastMapPosition() async {
try {
final prefs = await SharedPreferences.getInstance();
final lat = prefs.getDouble(kLastMapLatKey);
final lng = prefs.getDouble(kLastMapLngKey);
final zoom = prefs.getDouble(kLastMapZoomKey);
if (lat != null && lng != null &&
_isValidCoordinate(lat) && _isValidCoordinate(lng)) {
final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0;
_initialLocation = LatLng(lat, lng);
_initialZoom = validZoom;
debugPrint('[MapView] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
} else {
debugPrint('[MapView] Invalid saved coordinates, using defaults');
}
} catch (e) {
debugPrint('[MapView] Failed to load last map position: $e');
}
}
/// Save the follow-me mode to persistent storage
static Future<void> saveFollowMeMode(FollowMeMode mode) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(kFollowMeModeKey, mode.index);
debugPrint('[MapView] Saved follow-me mode: $mode');
} catch (e) {
debugPrint('[MapView] Failed to save follow-me mode: $e');
}
}
/// Load the follow-me mode from persistent storage
static Future<FollowMeMode> loadFollowMeMode() async {
try {
final prefs = await SharedPreferences.getInstance();
final modeIndex = prefs.getInt(kFollowMeModeKey);
if (modeIndex != null && modeIndex < FollowMeMode.values.length) {
final mode = FollowMeMode.values[modeIndex];
debugPrint('[MapView] Loaded follow-me mode: $mode');
return mode;
}
} catch (e) {
debugPrint('[MapView] Failed to load follow-me mode: $e');
}
// Default to northUp if no saved mode
return FollowMeMode.northUp;
}
/// Clear any stored map position (useful for recovery from invalid data)
static Future<void> clearStoredMapPosition() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(kLastMapLatKey);
await prefs.remove(kLastMapLngKey);
await prefs.remove(kLastMapZoomKey);
debugPrint('[MapView] Cleared stored map position');
} catch (e) {
debugPrint('[MapView] Failed to clear stored map position: $e');
}
}
/// Expose static methods from MapPositionManager for external access
static Future<void> clearStoredMapPosition() =>
MapPositionManager.clearStoredMapPosition();
void _refreshCamerasFromProvider() {
final appState = context.read<AppState>();
LatLngBounds? bounds;
try {
bounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
return;
}
final zoom = _controller.mapController.camera.zoom;
if (zoom < kCameraMinZoomLevel) {
// Show a snackbar-style bubble, if desired
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cameras not drawn below zoom level $kCameraMinZoomLevel'),
duration: const Duration(seconds: 2),
),
);
}
return;
}
_cameraProvider.fetchAndUpdate(
bounds: bounds,
profiles: appState.enabledProfiles,
_cameraController.refreshCamerasFromProvider(
controller: _controller,
enabledProfiles: appState.enabledProfiles,
uploadMode: appState.uploadMode,
context: context,
);
}
@@ -274,80 +148,16 @@ class MapViewState extends State<MapView> {
@override
void didUpdateWidget(covariant MapView oldWidget) {
super.didUpdateWidget(oldWidget);
// 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 - smooth animation
if (widget.followMeMode == FollowMeMode.northUp) {
_controller.animateTo(
dest: _currentLatLng!,
zoom: _controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (widget.followMeMode == FollowMeMode.rotating) {
// When switching to rotating mode, reset to north-up first - smooth animation
_controller.animateTo(
dest: _currentLatLng!,
zoom: _controller.mapController.camera.zoom,
rotation: 0.0,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
}
// Handle follow-me mode changes - only if it actually changed
if (widget.followMeMode != oldWidget.followMeMode) {
_gpsController.handleFollowMeModeChange(
newMode: widget.followMeMode,
oldMode: oldWidget.followMeMode,
controller: _controller,
);
}
}
Future<void> _initLocation() async {
final perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied ||
perm == LocationPermission.deniedForever) return;
_positionSub =
Geolocator.getPositionStream().listen((Position position) {
final latLng = LatLng(position.latitude, position.longitude);
setState(() => _currentLatLng = latLng);
// Back to original pattern - directly check widget parameter
if (widget.followMeMode != FollowMeMode.off) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
if (widget.followMeMode == FollowMeMode.northUp) {
// Follow position only, keep current rotation - smooth animation
_controller.animateTo(
dest: latLng,
zoom: _controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (widget.followMeMode == FollowMeMode.rotating) {
// Follow position and rotation based on heading - smooth animation
final heading = position.heading;
final speed = position.speed; // Speed in m/s
// Only apply rotation if moving fast enough to avoid wild spinning when stationary
final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN;
final rotation = shouldRotate ? -heading : _controller.mapController.camera.rotation;
_controller.animateTo(
dest: latLng,
zoom: _controller.mapController.camera.zoom,
rotation: rotation,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
}
} catch (e) {
debugPrint('MapController not ready yet: $e');
}
}
});
}
});
}
double _safeZoom() {
try {
return _controller.mapController.camera.zoom;
@@ -356,33 +166,9 @@ class MapViewState extends State<MapView> {
}
}
/// Helper to check if two profile lists are equal
bool _profileListsEqual(List<CameraProfile> list1, List<CameraProfile> list2) {
if (list1.length != list2.length) return false;
// Compare by profile IDs since profiles are value objects
final ids1 = list1.map((p) => p.id).toSet();
final ids2 = list2.map((p) => p.id).toSet();
return ids1.length == ids2.length && ids1.containsAll(ids2);
}
/// Build tile layer - uses fake domain that SimpleTileHttpClient can parse
Widget _buildTileLayer(AppState appState) {
final selectedTileType = appState.selectedTileType;
final selectedProvider = appState.selectedTileProvider;
// Use fake domain with standard HTTPS scheme: https://tiles.local/provider/type/z/x/y
// This naturally separates cache entries by provider and type while being HTTP-compatible
final urlTemplate = 'https://tiles.local/${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
return TileLayer(
urlTemplate: urlTemplate,
userAgentPackageName: 'com.stopflock.flock_map_app',
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
),
);
}
@@ -393,41 +179,22 @@ class MapViewState extends State<MapView> {
final editSession = appState.editSession;
// Check if enabled profiles changed and refresh cameras if needed
final currentEnabledProfiles = appState.enabledProfiles;
if (_lastEnabledProfiles == null ||
!_profileListsEqual(_lastEnabledProfiles!, currentEnabledProfiles)) {
_lastEnabledProfiles = List.from(currentEnabledProfiles);
// Refresh cameras when profiles change
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear camera cache to ensure fresh data for new profile combination
_cameraProvider.clearCache();
// Force display refresh first (for immediate UI update)
_cameraProvider.refreshDisplay();
// Then fetch new cameras for newly enabled profiles
_refreshCamerasFromProvider();
});
}
_cameraController.checkAndHandleProfileChanges(
currentEnabledProfiles: appState.enabledProfiles,
onProfilesChanged: _refreshCamerasFromProvider,
);
// Check if tile type OR offline mode changed and clear cache if needed
final currentTileTypeId = appState.selectedTileType?.id;
final currentOfflineMode = appState.offlineMode;
final cacheCleared = _tileManager.checkAndClearCacheIfNeeded(
currentTileTypeId: appState.selectedTileType?.id,
currentOfflineMode: appState.offlineMode,
);
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId) ||
(_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
// Force map rebuild with new key to bust flutter_map cache
_mapRebuildKey++;
final reason = _lastTileTypeId != currentTileTypeId
? 'tile type ($currentTileTypeId)'
: 'offline mode ($currentOfflineMode)';
debugPrint('[MapView] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
if (cacheCleared) {
WidgetsBinding.instance.addPostFrameCallback((_) {
debugPrint('[MapView] Post-frame: Clearing tile request queue');
_tileHttpClient.clearTileQueue();
_tileManager.clearTileQueue();
});
}
_lastTileTypeId = currentTileTypeId;
_lastOfflineMode = currentOfflineMode;
// Seed addmode target once, after first controller center is available.
if (session != null && session.target == null) {
@@ -467,7 +234,7 @@ class MapViewState extends State<MapView> {
final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: cameras,
mapController: _controller.mapController,
userLocation: _currentLatLng,
userLocation: _gpsController.currentLocation,
);
final overlays = DirectionConesBuilder.buildDirectionCones(
@@ -480,11 +247,31 @@ class MapViewState extends State<MapView> {
// Build edit lines connecting original cameras to their edited positions
final editLines = _buildEditLines(cameras);
// Build center marker for add/edit sessions
final centerMarkers = <Marker>[];
if (session != null || editSession != null) {
try {
final center = _controller.mapController.camera.center;
centerMarkers.add(
Marker(
point: center,
width: kCameraIconDiameter,
height: kCameraIconDiameter,
child: CameraIcon(
type: editSession != null ? CameraIconType.editing : CameraIconType.mock,
),
),
);
} catch (_) {
// Controller not ready yet
}
}
return Stack(
children: [
PolygonLayer(polygons: overlays),
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
MarkerLayer(markers: markers),
MarkerLayer(markers: [...markers, ...centerMarkers]),
],
);
}
@@ -492,12 +279,16 @@ class MapViewState extends State<MapView> {
return Stack(
children: [
FlutterMap(
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_$_mapRebuildKey'),
mapController: _controller.mapController,
options: MapOptions(
initialCenter: _currentLatLng ?? _initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _initialZoom ?? 15,
AnimatedPadding(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: widget.bottomPadding),
child: FlutterMap(
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
mapController: _controller.mapController,
options: MapOptions(
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _positionManager.initialZoom ?? 15,
maxZoom: 19,
onPositionChanged: (pos, gesture) {
setState(() {}); // Instant UI update for zoom, etc.
@@ -519,19 +310,14 @@ class MapViewState extends State<MapView> {
if (zoomChanged) {
_tileDebounce(() {
// Clear stale tile requests on zoom change (quietly)
_tileHttpClient.clearTileQueue();
_tileManager.clearTileQueueImmediate();
});
}
_lastZoom = currentZoom;
// Save map position (debounced to avoid excessive writes)
_mapPositionDebounce(() {
// Only save if position and zoom are valid
if (_isValidCoordinate(pos.center.latitude) &&
_isValidCoordinate(pos.center.longitude) &&
_isValidZoom(pos.zoom)) {
_saveLastMapPosition(pos.center, pos.zoom);
}
_positionManager.saveMapPosition(pos.center, pos.zoom);
});
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
@@ -541,7 +327,10 @@ class MapViewState extends State<MapView> {
},
),
children: [
_buildTileLayer(appState),
_tileManager.buildTileLayer(
selectedProvider: appState.selectedTileProvider,
selectedTileType: appState.selectedTileType,
),
cameraLayers,
// Built-in scale bar from flutter_map
Scalebar(
@@ -553,6 +342,7 @@ class MapViewState extends State<MapView> {
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
),
],
),
),
// All map overlays (mode indicator, zoom, attribution, add pin)

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
/// Wrapper widget that measures its child's height and reports changes via callback
class MeasuredSheet extends StatefulWidget {
final Widget child;
final ValueChanged<double> onHeightChanged;
const MeasuredSheet({
super.key,
required this.child,
required this.onHeightChanged,
});
@override
State<MeasuredSheet> createState() => _MeasuredSheetState();
}
class _MeasuredSheetState extends State<MeasuredSheet> {
final GlobalKey _key = GlobalKey();
double _lastHeight = 0.0;
@override
void initState() {
super.initState();
// Schedule height measurement after first frame
WidgetsBinding.instance.addPostFrameCallback(_measureHeight);
}
void _measureHeight(Duration _) {
final renderBox = _key.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
final height = renderBox.size.height;
if (height != _lastHeight) {
_lastHeight = height;
widget.onHeightChanged(height);
}
}
}
@override
Widget build(BuildContext context) {
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (notification) {
WidgetsBinding.instance.addPostFrameCallback(_measureHeight);
return true;
},
child: SizeChangedLayoutNotifier(
child: Container(
key: _key,
child: widget.child,
),
),
);
}
}

View File

@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/operator_profile.dart';
class RefineTagsSheet extends StatefulWidget {
const RefineTagsSheet({
super.key,
this.selectedOperatorProfile,
});
final OperatorProfile? selectedOperatorProfile;
@override
State<RefineTagsSheet> createState() => _RefineTagsSheetState();
}
class _RefineTagsSheetState extends State<RefineTagsSheet> {
OperatorProfile? _selectedOperatorProfile;
@override
void initState() {
super.initState();
_selectedOperatorProfile = widget.selectedOperatorProfile;
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final operatorProfiles = appState.operatorProfiles;
return Scaffold(
appBar: AppBar(
title: const Text('Refine Tags'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context, widget.selectedOperatorProfile),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, _selectedOperatorProfile),
child: const Text('Done'),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Operator Profile',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
if (operatorProfiles.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
children: [
Icon(Icons.info_outline, color: Colors.grey, size: 48),
SizedBox(height: 8),
Text(
'No operator profiles defined',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Text(
'Create operator profiles in Settings to apply additional tags to your node submissions.',
style: TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
)
else ...[
Card(
child: Column(
children: [
RadioListTile<OperatorProfile?>(
title: const Text('None'),
subtitle: const Text('No additional operator tags'),
value: null,
groupValue: _selectedOperatorProfile,
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
),
...operatorProfiles.map((profile) => RadioListTile<OperatorProfile?>(
title: Text(profile.name),
subtitle: Text('${profile.tags.length} additional tags'),
value: profile,
groupValue: _selectedOperatorProfile,
onChanged: (value) => setState(() => _selectedOperatorProfile = value),
)),
],
),
),
const SizedBox(height: 16),
if (_selectedOperatorProfile != null) ...[
const Text(
'Additional Tags',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_selectedOperatorProfile!.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
if (_selectedOperatorProfile!.tags.isEmpty)
const Text(
'No tags defined for this operator profile.',
style: TextStyle(color: Colors.grey),
)
else
...(_selectedOperatorProfile!.tags.entries.map((entry) =>
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Text(
entry.key,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: Text(
entry.value,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
],
),
),
)),
],
),
),
),
],
],
],
),
);
}
}