UX and bones of suspected locations

This commit is contained in:
stopflock
2025-10-06 18:34:20 -05:00
parent 08238eaad2
commit cc0386ee97
14 changed files with 1113 additions and 4 deletions
+3 -2
View File
@@ -95,6 +95,7 @@ class CameraMarkersBuilder {
LatLng? userLocation,
int? selectedNodeId,
void Function(OsmNode)? onNodeTap,
bool shouldDim = false,
}) {
final markers = <Marker>[
// Camera markers
@@ -103,14 +104,14 @@ class CameraMarkersBuilder {
.map((n) {
// Check if this node should be highlighted (selected) or dimmed
final isSelected = selectedNodeId == n.id;
final shouldDim = selectedNodeId != null && !isSelected;
final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected);
return Marker(
point: n.coord,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
child: Opacity(
opacity: shouldDim ? 0.5 : 1.0,
opacity: shouldDimNode ? 0.5 : 1.0,
child: CameraMapMarker(
node: n,
mapController: mapController,
@@ -0,0 +1,111 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../dev_config.dart';
import '../../models/suspected_location.dart';
import '../suspected_location_sheet.dart';
import '../suspected_location_icon.dart';
/// Smart marker widget for suspected location with single/double tap distinction
class SuspectedLocationMapMarker extends StatefulWidget {
final SuspectedLocation location;
final MapController mapController;
final void Function(SuspectedLocation)? onLocationTap;
const SuspectedLocationMapMarker({
required this.location,
required this.mapController,
this.onLocationTap,
Key? key,
}) : super(key: key);
@override
State<SuspectedLocationMapMarker> createState() => _SuspectedLocationMapMarkerState();
}
class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker> {
Timer? _tapTimer;
// From dev_config.dart for build-time parameters
static const Duration tapTimeout = kMarkerTapTimeout;
void _onTap() {
_tapTimer = Timer(tapTimeout, () {
// Use callback if provided, otherwise fallback to direct modal
if (widget.onLocationTap != null) {
widget.onLocationTap!(widget.location);
} else {
showModalBottomSheet(
context: context,
builder: (_) => SuspectedLocationSheet(location: widget.location),
showDragHandle: true,
);
}
});
}
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + 1);
}
@override
void dispose() {
_tapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
onDoubleTap: _onDoubleTap,
child: const SuspectedLocationIcon(),
);
}
}
/// Helper class to build marker layers for suspected locations
class SuspectedLocationMarkersBuilder {
static List<Marker> buildSuspectedLocationMarkers({
required List<SuspectedLocation> locations,
required MapController mapController,
String? selectedLocationId,
void Function(SuspectedLocation)? onLocationTap,
}) {
final markers = <Marker>[];
for (final location in locations) {
if (!_isValidCoordinate(location.centroid)) continue;
// Check if this location should be highlighted (selected) or dimmed
final isSelected = selectedLocationId == location.ticketNo;
final shouldDim = selectedLocationId != null && !isSelected;
markers.add(
Marker(
point: location.centroid,
width: 20,
height: 20,
child: Opacity(
opacity: shouldDim ? 0.5 : 1.0,
child: SuspectedLocationMapMarker(
location: location,
mapController: mapController,
onLocationTap: onLocationTap,
),
),
),
);
}
return markers;
}
static bool _isValidCoordinate(LatLng coord) {
return (coord.latitude != 0 || coord.longitude != 0) &&
coord.latitude.abs() <= 90 &&
coord.longitude.abs() <= 180;
}
}
+44 -1
View File
@@ -9,6 +9,7 @@ import '../services/offline_area_service.dart';
import '../services/network_status.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../models/suspected_location.dart';
import '../models/tile_provider.dart';
import 'debouncer.dart';
import 'camera_provider_with_cache.dart';
@@ -20,6 +21,7 @@ import 'map/map_position_manager.dart';
import 'map/tile_layer_manager.dart';
import 'map/camera_refresh_controller.dart';
import 'map/gps_controller.dart';
import 'map/suspected_location_markers.dart';
import 'network_status_indicator.dart';
import 'provisional_pin.dart';
import 'proximity_alert_banner.dart';
@@ -38,6 +40,7 @@ class MapView extends StatefulWidget {
this.sheetHeight = 0.0,
this.selectedNodeId,
this.onNodeTap,
this.onSuspectedLocationTap,
this.onSearchPressed,
});
@@ -46,6 +49,7 @@ class MapView extends StatefulWidget {
final double sheetHeight;
final int? selectedNodeId;
final void Function(OsmNode)? onNodeTap;
final void Function(SuspectedLocation)? onSuspectedLocationTap;
final VoidCallback? onSearchPressed;
@override
@@ -336,14 +340,38 @@ class MapViewState extends State<MapView> {
? cameraProvider.getCachedNodesForBounds(mapBounds)
: <OsmNode>[];
// Determine if we should dim camera markers (when suspected location is selected)
final shouldDimCameras = appState.selectedSuspectedLocation != null;
final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: cameras,
mapController: _controller.mapController,
userLocation: _gpsController.currentLocation,
selectedNodeId: widget.selectedNodeId,
onNodeTap: widget.onNodeTap,
shouldDim: shouldDimCameras,
);
// Build suspected location markers
final suspectedLocationMarkers = <Marker>[];
if (appState.suspectedLocationsEnabled && mapBounds != null) {
final suspectedLocations = appState.getSuspectedLocationsInBounds(
north: mapBounds.north,
south: mapBounds.south,
east: mapBounds.east,
west: mapBounds.west,
);
suspectedLocationMarkers.addAll(
SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers(
locations: suspectedLocations,
mapController: _controller.mapController,
selectedLocationId: appState.selectedSuspectedLocation?.ticketNo,
onLocationTap: widget.onSuspectedLocationTap,
),
);
}
// Get current zoom level for direction cones
double currentZoom = 15.0; // fallback
try {
@@ -359,6 +387,21 @@ class MapViewState extends State<MapView> {
editSession: editSession,
);
// Add suspected location bounds if one is selected
if (appState.selectedSuspectedLocation != null) {
final selectedLocation = appState.selectedSuspectedLocation!;
if (selectedLocation.bounds.isNotEmpty) {
overlays.add(
Polygon(
points: selectedLocation.bounds,
color: Colors.orange.withOpacity(0.3),
borderColor: Colors.orange,
borderStrokeWidth: 2.0,
),
);
}
}
// Build edit lines connecting original cameras to their edited positions
final editLines = _buildEditLines(cameras);
@@ -436,7 +479,7 @@ class MapViewState extends State<MapView> {
PolygonLayer(polygons: overlays),
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
MarkerLayer(markers: [...markers, ...centerMarkers]),
MarkerLayer(markers: [...markers, ...suspectedLocationMarkers, ...centerMarkers]),
],
);
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class SuspectedLocationIcon extends StatelessWidget {
const SuspectedLocationIcon({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.orange,
border: Border.all(
color: Colors.white,
width: 2,
),
),
child: const Icon(
Icons.help_outline,
color: Colors.white,
size: 12,
),
);
}
}
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/suspected_location.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
class SuspectedLocationSheet extends StatelessWidget {
final SuspectedLocation location;
const SuspectedLocationSheet({super.key, required this.location});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final appState = context.watch<AppState>();
final locService = LocalizationService.instance;
Future<void> _launchUrl() async {
if (location.urlFull?.isNotEmpty == true) {
final uri = Uri.parse(location.urlFull!);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open URL: ${location.urlFull}'),
),
);
}
}
}
}
// Create display data map
final Map<String, String?> displayData = {
'Ticket No': location.ticketNo,
'Address': location.addr,
'Street': location.street,
'City': location.city,
'State': location.state,
'Intersecting Street': location.digSiteIntersectingStreet,
'Work Done For': location.digWorkDoneFor,
'Remarks': location.digSiteRemarks,
'URL': location.urlFull,
};
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Suspected Location #${location.ticketNo}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
// Display all fields
...displayData.entries.where((e) => e.value?.isNotEmpty == true).map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: e.key == 'URL' && e.value?.isNotEmpty == true
? GestureDetector(
onTap: _launchUrl,
child: Text(
e.value!,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
softWrap: true,
),
)
: Text(
e.value ?? '',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
softWrap: true,
),
),
],
),
),
),
const SizedBox(height: 16),
// Coordinates info
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Coordinates',
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${location.centroid.latitude.toStringAsFixed(6)}, ${location.centroid.longitude.toStringAsFixed(6)}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
softWrap: true,
),
),
],
),
),
const SizedBox(height: 16),
// Close button
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
),
),
);
},
);
}
}