This commit is contained in:
stopflock
2025-10-02 16:50:07 -05:00
parent b02099e3fe
commit 408b52cdb0
7 changed files with 945 additions and 90 deletions

View File

@@ -13,6 +13,7 @@ import 'services/node_cache.dart';
import 'services/tile_preview_service.dart';
import 'widgets/camera_provider_with_cache.dart';
import 'state/auth_state.dart';
import 'state/navigation_state.dart';
import 'state/operator_profile_state.dart';
import 'state/profile_state.dart';
import 'state/search_state.dart';
@@ -21,6 +22,7 @@ import 'state/settings_state.dart';
import 'state/upload_queue_state.dart';
// Re-export types
export 'state/navigation_state.dart' show AppNavigationMode;
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
@@ -30,6 +32,7 @@ class AppState extends ChangeNotifier {
// State modules
late final AuthState _authState;
late final NavigationState _navigationState;
late final OperatorProfileState _operatorProfileState;
late final ProfileState _profileState;
late final SearchState _searchState;
@@ -42,6 +45,7 @@ class AppState extends ChangeNotifier {
AppState() {
instance = this;
_authState = AuthState();
_navigationState = NavigationState();
_operatorProfileState = OperatorProfileState();
_profileState = ProfileState();
_searchState = SearchState();
@@ -51,6 +55,7 @@ class AppState extends ChangeNotifier {
// Set up state change listeners
_authState.addListener(_onStateChanged);
_navigationState.addListener(_onStateChanged);
_operatorProfileState.addListener(_onStateChanged);
_profileState.addListener(_onStateChanged);
_searchState.addListener(_onStateChanged);
@@ -68,6 +73,28 @@ class AppState extends ChangeNotifier {
bool get isLoggedIn => _authState.isLoggedIn;
String get username => _authState.username;
// Navigation state
AppNavigationMode get navigationMode => _navigationState.mode;
LatLng? get provisionalPinLocation => _navigationState.provisionalPinLocation;
String? get provisionalPinAddress => _navigationState.provisionalPinAddress;
bool get showProvisionalPin => _navigationState.showProvisionalPin;
bool get isInSearchMode => _navigationState.isInSearchMode;
bool get isInRouteMode => _navigationState.isInRouteMode;
bool get hasActiveRoute => _navigationState.hasActiveRoute;
List<LatLng>? get routePath => _navigationState.routePath;
// Route state
LatLng? get routeStart => _navigationState.routeStart;
LatLng? get routeEnd => _navigationState.routeEnd;
String? get routeStartAddress => _navigationState.routeStartAddress;
String? get routeEndAddress => _navigationState.routeEndAddress;
double? get routeDistance => _navigationState.routeDistance;
bool get settingRouteStart => _navigationState.settingRouteStart;
// Navigation search state
bool get isNavigationSearchLoading => _navigationState.isSearchLoading;
List<SearchResult> get navigationSearchResults => _navigationState.searchResults;
// Profile state
List<NodeProfile> get profiles => _profileState.profiles;
List<NodeProfile> get enabledProfiles => _profileState.enabledProfiles;
@@ -250,6 +277,56 @@ class AppState extends ChangeNotifier {
_searchState.clearResults();
}
// ---------- Navigation Methods ----------
void enterSearchMode(LatLng mapCenter) {
_navigationState.enterSearchMode(mapCenter);
}
void cancelSearchMode() {
_navigationState.cancelSearchMode();
}
void updateProvisionalPinLocation(LatLng newLocation) {
_navigationState.updateProvisionalPinLocation(newLocation);
}
void selectSearchResult(SearchResult result) {
_navigationState.selectSearchResult(result);
}
void startRouteSetup({required bool settingStart}) {
_navigationState.startRouteSetup(settingStart: settingStart);
}
void selectRouteLocation() {
_navigationState.selectRouteLocation();
}
void startRoute() {
_navigationState.startRoute();
}
void cancelRoute() {
_navigationState.cancelRoute();
}
void viewRouteOverview() {
_navigationState.viewRouteOverview();
}
void returnToActiveRoute() {
_navigationState.returnToActiveRoute();
}
// Navigation search methods
Future<void> searchNavigation(String query) async {
await _navigationState.search(query);
}
void clearNavigationSearchResults() {
_navigationState.clearSearchResults();
}
// ---------- Settings Methods ----------
Future<void> setOfflineMode(bool enabled) async {
await _settingsState.setOfflineMode(enabled);
@@ -347,6 +424,7 @@ class AppState extends ChangeNotifier {
@override
void dispose() {
_authState.removeListener(_onStateChanged);
_navigationState.removeListener(_onStateChanged);
_operatorProfileState.removeListener(_onStateChanged);
_profileState.removeListener(_onStateChanged);
_searchState.removeListener(_onStateChanged);

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
@@ -15,6 +16,7 @@ import '../widgets/node_tag_sheet.dart';
import '../widgets/camera_provider_with_cache.dart';
import '../widgets/download_area_dialog.dart';
import '../widgets/measured_sheet.dart';
import '../widgets/navigation_sheet.dart';
import '../widgets/search_bar.dart';
import '../models/osm_node.dart';
import '../models/search_result.dart';
@@ -31,11 +33,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
late final AnimatedMapController _mapController;
bool _editSheetShown = false;
bool _navigationSheetShown = false;
// Track sheet heights for map positioning
double _addSheetHeight = 0.0;
double _editSheetHeight = 0.0;
double _tagSheetHeight = 0.0;
double _navigationSheetHeight = 0.0;
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
bool _transitioningToEdit = false;
@@ -162,7 +166,50 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
});
}
void _openNavigationSheet() {
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_navigationSheetHeight = height;
});
},
child: const NavigationSheet(),
),
);
// Reset height when sheet is dismissed
controller.closed.then((_) {
setState(() {
_navigationSheetHeight = 0.0;
});
});
}
void _onNavigationButtonPressed() {
final appState = context.read<AppState>();
if (appState.hasActiveRoute) {
// Route button - view route overview
appState.viewRouteOverview();
} else {
// Search button - enter search mode
try {
final mapCenter = _mapController.mapController.camera.center;
appState.enterSearchMode(mapCenter);
} catch (_) {
// Controller not ready, use fallback location
appState.enterSearchMode(LatLng(37.7749, -122.4194));
}
}
}
void _onSearchResultSelected(SearchResult result) {
final appState = context.read<AppState>();
// Update navigation state with selected result
appState.selectSearchResult(result);
// Jump to the search result location
try {
_mapController.animateTo(
@@ -246,12 +293,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_editSheetShown = false;
}
// Auto-open navigation sheet during search/route modes
if ((appState.isInSearchMode || appState.isInRouteMode) && !_navigationSheetShown) {
_navigationSheetShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet());
} else if (!appState.isInSearchMode && !appState.isInRouteMode) {
_navigationSheetShown = false;
}
// Pass the active sheet height directly to the map
final activeSheetHeight = _addSheetHeight > 0
? _addSheetHeight
: (_editSheetHeight > 0
? _editSheetHeight
: _tagSheetHeight);
: (_navigationSheetHeight > 0
? _navigationSheetHeight
: _tagSheetHeight));
return MultiProvider(
providers: [
@@ -290,94 +347,105 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
),
],
),
body: Column(
body: Stack(
children: [
// Search bar at the top
LocationSearchBar(
onResultSelected: _onSearchResultSelected,
MapView(
key: _mapViewKey,
controller: _mapController,
followMeMode: appState.followMeMode,
sheetHeight: activeSheetHeight,
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
}
},
),
// Map takes the rest of the space
Expanded(
child: Stack(
children: [
MapView(
key: _mapViewKey,
controller: _mapController,
followMeMode: appState.followMeMode,
sheetHeight: activeSheetHeight,
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
}
},
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
left: 8,
right: 8,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
// Search bar (slides in when in search mode)
if (appState.isInSearchMode)
Positioned(
top: 0,
left: 0,
right: 0,
child: LocationSearchBar(
onResultSelected: _onSearchResultSelected,
onCancel: () => appState.cancelSearchMode(),
),
),
// Bottom button bar (restored to original)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
left: 8,
right: 8,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
Expanded(
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
SizedBox(width: 12),
Expanded(
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
],
),
),
SizedBox(width: 12),
Expanded(
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
),
],
),
],
),
),
),
),
// Search button as floating map control
Positioned(
bottom: 200, // Position above other controls
right: 16,
child: FloatingActionButton(
onPressed: _onNavigationButtonPressed,
tooltip: appState.hasActiveRoute ? 'Route Overview' : 'Search Location',
child: Icon(appState.hasActiveRoute ? Icons.route : Icons.search),
),
),
],

View File

@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/search_result.dart';
import '../services/search_service.dart';
/// Navigation modes for routing and search functionality
enum AppNavigationMode {
normal, // Default state - normal map view
search, // Search box visible, provisional pin active
searchInput, // Keyboard open, UI elements hidden
routeSetup, // Placing second pin for routing
routeCalculating, // Computing route with loading indicator
routePreview, // Route ready, showing start/cancel options
routeActive, // Following an active route
routeOverview, // Viewing active route overview
}
/// Manages all navigation, search, and routing state
class NavigationState extends ChangeNotifier {
final SearchService _searchService = SearchService();
AppNavigationMode _mode = AppNavigationMode.normal;
// Search state
bool _isSearchLoading = false;
List<SearchResult> _searchResults = [];
String _lastQuery = '';
List<String> _searchHistory = [];
// Provisional pin state (for route planning)
LatLng? _provisionalPinLocation;
String? _provisionalPinAddress;
// Route state
LatLng? _routeStart;
LatLng? _routeEnd;
String? _routeStartAddress;
String? _routeEndAddress;
List<LatLng>? _routePath;
double? _routeDistance;
bool _settingRouteStart = true; // true = setting start, false = setting end
// Getters
AppNavigationMode get mode => _mode;
bool get isSearchLoading => _isSearchLoading;
List<SearchResult> get searchResults => List.unmodifiable(_searchResults);
String get lastQuery => _lastQuery;
List<String> get searchHistory => List.unmodifiable(_searchHistory);
LatLng? get provisionalPinLocation => _provisionalPinLocation;
String? get provisionalPinAddress => _provisionalPinAddress;
LatLng? get routeStart => _routeStart;
LatLng? get routeEnd => _routeEnd;
String? get routeStartAddress => _routeStartAddress;
String? get routeEndAddress => _routeEndAddress;
List<LatLng>? get routePath => _routePath != null ? List.unmodifiable(_routePath!) : null;
double? get routeDistance => _routeDistance;
bool get settingRouteStart => _settingRouteStart;
// Convenience getters
bool get isInSearchMode => _mode == AppNavigationMode.search || _mode == AppNavigationMode.searchInput;
bool get isInRouteMode => _mode == AppNavigationMode.routeSetup ||
_mode == AppNavigationMode.routeCalculating ||
_mode == AppNavigationMode.routePreview ||
_mode == AppNavigationMode.routeActive ||
_mode == AppNavigationMode.routeOverview;
bool get hasActiveRoute => _routePath != null;
bool get showProvisionalPin => _provisionalPinLocation != null &&
(_mode == AppNavigationMode.search ||
_mode == AppNavigationMode.routeSetup);
/// Enter search mode with provisional pin at current map center
void enterSearchMode(LatLng mapCenter) {
if (_mode != AppNavigationMode.normal) return;
_mode = AppNavigationMode.search;
_provisionalPinLocation = mapCenter;
_provisionalPinAddress = null;
_clearSearchResults();
debugPrint('[NavigationState] Entered search mode at $mapCenter');
notifyListeners();
}
/// Enter search input mode (keyboard open)
void enterSearchInputMode() {
if (_mode != AppNavigationMode.search) return;
_mode = AppNavigationMode.searchInput;
debugPrint('[NavigationState] Entered search input mode');
notifyListeners();
}
/// Exit search input mode back to search
void exitSearchInputMode() {
if (_mode != AppNavigationMode.searchInput) return;
_mode = AppNavigationMode.search;
debugPrint('[NavigationState] Exited search input mode');
notifyListeners();
}
/// Cancel search mode and return to normal
void cancelSearchMode() {
if (!isInSearchMode && _mode != AppNavigationMode.routeSetup) return;
_mode = AppNavigationMode.normal;
_provisionalPinLocation = null;
_provisionalPinAddress = null;
_clearSearchResults();
debugPrint('[NavigationState] Cancelled search mode');
notifyListeners();
}
/// Update provisional pin location (when map moves during search)
void updateProvisionalPinLocation(LatLng newLocation) {
if (!showProvisionalPin) return;
_provisionalPinLocation = newLocation;
// Clear address since location changed
_provisionalPinAddress = null;
notifyListeners();
}
/// Jump to search result and update provisional pin
void selectSearchResult(SearchResult result) {
if (!isInSearchMode) return;
_provisionalPinLocation = result.coordinates;
_provisionalPinAddress = result.displayName;
_mode = AppNavigationMode.search; // Exit search input mode
_clearSearchResults();
debugPrint('[NavigationState] Selected search result: ${result.displayName}');
notifyListeners();
}
/// Start route setup (user clicked "route to" or "route from")
void startRouteSetup({required bool settingStart}) {
if (_mode != AppNavigationMode.search || _provisionalPinLocation == null) return;
_settingRouteStart = settingStart;
if (settingStart) {
_routeStart = _provisionalPinLocation;
_routeStartAddress = _provisionalPinAddress;
} else {
_routeEnd = _provisionalPinLocation;
_routeEndAddress = _provisionalPinAddress;
}
_mode = AppNavigationMode.routeSetup;
// Keep provisional pin active for second location
debugPrint('[NavigationState] Started route setup (setting ${settingStart ? 'start' : 'end'})');
notifyListeners();
}
/// Lock in second route location
void selectRouteLocation() {
if (_mode != AppNavigationMode.routeSetup || _provisionalPinLocation == null) return;
if (_settingRouteStart) {
_routeStart = _provisionalPinLocation;
_routeStartAddress = _provisionalPinAddress;
} else {
_routeEnd = _provisionalPinLocation;
_routeEndAddress = _provisionalPinAddress;
}
// Start route calculation
_calculateRoute();
}
/// Calculate route (mock implementation for now)
void _calculateRoute() {
if (_routeStart == null || _routeEnd == null) return;
_mode = AppNavigationMode.routeCalculating;
notifyListeners();
// Mock route calculation with delay
Future.delayed(const Duration(seconds: 2), () {
if (_mode != AppNavigationMode.routeCalculating) return;
// Create simple straight line route for now
_routePath = [_routeStart!, _routeEnd!];
_routeDistance = const Distance().as(LengthUnit.Meter, _routeStart!, _routeEnd!);
_mode = AppNavigationMode.routePreview;
_provisionalPinLocation = null; // Hide provisional pin
debugPrint('[NavigationState] Route calculated: ${_routeDistance! / 1000.0} km');
notifyListeners();
});
}
/// Start following the route
void startRoute() {
if (_mode != AppNavigationMode.routePreview || _routePath == null) return;
_mode = AppNavigationMode.routeActive;
debugPrint('[NavigationState] Started route following');
notifyListeners();
}
/// View route overview (from route button during active route)
void viewRouteOverview() {
if (_mode != AppNavigationMode.routeActive || _routePath == null) return;
_mode = AppNavigationMode.routeOverview;
debugPrint('[NavigationState] Viewing route overview');
notifyListeners();
}
/// Return to active route from overview
void returnToActiveRoute() {
if (_mode != AppNavigationMode.routeOverview) return;
_mode = AppNavigationMode.routeActive;
debugPrint('[NavigationState] Returned to active route');
notifyListeners();
}
/// Cancel route and return to normal mode
void cancelRoute() {
if (!isInRouteMode) return;
_mode = AppNavigationMode.normal;
_routeStart = null;
_routeEnd = null;
_routeStartAddress = null;
_routeEndAddress = null;
_routePath = null;
_routeDistance = null;
_provisionalPinLocation = null;
_provisionalPinAddress = null;
debugPrint('[NavigationState] Cancelled route');
notifyListeners();
}
/// Search functionality (delegates to existing search service)
Future<void> search(String query) async {
if (query.trim().isEmpty) {
_clearSearchResults();
return;
}
if (query.trim() == _lastQuery.trim()) return;
_setSearchLoading(true);
_lastQuery = query.trim();
try {
final results = await _searchService.search(query.trim());
_searchResults = results;
debugPrint('[NavigationState] Found ${results.length} results for "$query"');
} catch (e) {
debugPrint('[NavigationState] Search failed: $e');
_searchResults = [];
}
_setSearchLoading(false);
}
/// Clear search results
void clearSearchResults() {
_clearSearchResults();
}
void _clearSearchResults() {
if (_searchResults.isNotEmpty || _lastQuery.isNotEmpty) {
_searchResults = [];
_lastQuery = '';
notifyListeners();
}
}
void _setSearchLoading(bool loading) {
if (_isSearchLoading != loading) {
_isSearchLoading = loading;
notifyListeners();
}
}
}

View File

@@ -21,6 +21,7 @@ import 'map/tile_layer_manager.dart';
import 'map/camera_refresh_controller.dart';
import 'map/gps_controller.dart';
import 'network_status_indicator.dart';
import 'provisional_pin.dart';
import 'proximity_alert_banner.dart';
import '../dev_config.dart';
import '../app_state.dart' show FollowMeMode;
@@ -374,10 +375,33 @@ class MapViewState extends State<MapView> {
}
}
// Build provisional pin for navigation/search mode
if (appState.showProvisionalPin && appState.provisionalPinLocation != null) {
centerMarkers.add(
Marker(
point: appState.provisionalPinLocation!,
width: 48.0,
height: 48.0,
child: const ProvisionalPin(),
),
);
}
// Build route path visualization
final routeLines = <Polyline>[];
if (appState.routePath != null && appState.routePath!.length > 1) {
routeLines.add(Polyline(
points: appState.routePath!,
color: Colors.blue,
strokeWidth: 4.0,
));
}
return Stack(
children: [
PolygonLayer(polygons: overlays),
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
MarkerLayer(markers: [...markers, ...centerMarkers]),
],
);
@@ -406,6 +430,11 @@ class MapViewState extends State<MapView> {
appState.updateEditSession(target: pos.center);
}
// Update provisional pin location during navigation search/routing
if (appState.showProvisionalPin) {
appState.updateProvisionalPinLocation(pos.center);
}
// Start dual-source waiting when map moves (user is expecting new tiles AND nodes)
NetworkStatus.instance.setDualSourceWaiting();

View File

@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart';
import '../app_state.dart';
class NavigationSheet extends StatelessWidget {
const NavigationSheet({super.key});
String _formatCoordinates(LatLng coordinates) {
return '${coordinates.latitude.toStringAsFixed(6)}, ${coordinates.longitude.toStringAsFixed(6)}';
}
Widget _buildLocationInfo({
required String label,
required LatLng coordinates,
String? address,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
if (address != null) ...[
Text(
address,
style: const TextStyle(fontSize: 16),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
],
Text(
_formatCoordinates(coordinates),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
fontFamily: 'monospace',
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Consumer<AppState>(
builder: (context, appState, child) {
final navigationMode = appState.navigationMode;
final provisionalLocation = appState.provisionalPinLocation;
final provisionalAddress = appState.provisionalPinAddress;
if (provisionalLocation == null) {
return const SizedBox.shrink();
}
switch (navigationMode) {
case AppNavigationMode.search:
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
),
// Location info
_buildLocationInfo(
label: 'Location',
coordinates: provisionalLocation,
address: provisionalAddress,
),
const SizedBox(height: 16),
// Action buttons
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.directions),
label: const Text('Route To'),
onPressed: () {
appState.startRouteSetup(settingStart: false);
},
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.my_location),
label: const Text('Route From'),
onPressed: () {
appState.startRouteSetup(settingStart: true);
},
),
),
],
),
],
),
);
case AppNavigationMode.routeSetup:
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
),
// Route points info
if (appState.routeStart != null) ...[
_buildLocationInfo(
label: 'Start',
coordinates: appState.routeStart!,
address: appState.routeStartAddress,
),
const SizedBox(height: 12),
],
if (appState.routeEnd != null) ...[
_buildLocationInfo(
label: 'End',
coordinates: appState.routeEnd!,
address: appState.routeEndAddress,
),
const SizedBox(height: 12),
],
_buildLocationInfo(
label: appState.settingRouteStart ? 'Start (select)' : 'End (select)',
coordinates: provisionalLocation,
address: provisionalAddress,
),
const SizedBox(height: 16),
// Select location button
ElevatedButton.icon(
icon: const Icon(Icons.check),
label: const Text('Select Location'),
onPressed: () {
appState.selectRouteLocation();
},
),
],
),
);
case AppNavigationMode.routeCalculating:
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
),
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text('Calculating route...'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
appState.cancelRoute();
},
child: const Text('Cancel'),
),
],
),
);
case AppNavigationMode.routePreview:
case AppNavigationMode.routeOverview:
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
),
// Route info
if (appState.routeStart != null) ...[
_buildLocationInfo(
label: 'Start',
coordinates: appState.routeStart!,
address: appState.routeStartAddress,
),
const SizedBox(height: 12),
],
if (appState.routeEnd != null) ...[
_buildLocationInfo(
label: 'End',
coordinates: appState.routeEnd!,
address: appState.routeEndAddress,
),
const SizedBox(height: 12),
],
// Distance info
if (appState.routeDistance != null) ...[
Text(
'Distance: ${(appState.routeDistance! / 1000).toStringAsFixed(1)} km',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
],
// Action buttons
Row(
children: [
if (navigationMode == AppNavigationMode.routePreview) ...[
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.play_arrow),
label: const Text('Start'),
onPressed: () {
appState.startRoute();
},
),
),
const SizedBox(width: 12),
] else if (navigationMode == AppNavigationMode.routeOverview) ...[
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.play_arrow),
label: const Text('Resume'),
onPressed: () {
appState.returnToActiveRoute();
},
),
),
const SizedBox(width: 12),
],
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.close),
label: const Text('Cancel'),
onPressed: () {
appState.cancelRoute();
},
),
),
],
),
],
),
);
default:
return const SizedBox.shrink();
}
},
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
/// A pin icon for marking provisional locations during search/routing
class ProvisionalPin extends StatelessWidget {
final double size;
final Color color;
const ProvisionalPin({
super.key,
this.size = 48.0,
this.color = Colors.red,
});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
// Pin shadow
Positioned(
bottom: 0,
child: Container(
width: size * 0.3,
height: size * 0.15,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
borderRadius: BorderRadius.circular(size * 0.15),
),
),
),
// Main pin
Icon(
Icons.location_pin,
size: size,
color: color,
),
// Inner dot
Positioned(
top: size * 0.15,
child: Container(
width: size * 0.25,
height: size * 0.25,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: color.withOpacity(0.8),
width: 2,
),
),
),
),
],
),
);
}
}

View File

@@ -7,10 +7,12 @@ import '../widgets/debouncer.dart';
class LocationSearchBar extends StatefulWidget {
final void Function(SearchResult)? onResultSelected;
final VoidCallback? onCancel;
const LocationSearchBar({
super.key,
this.onResultSelected,
this.onCancel,
});
@override
@@ -50,14 +52,17 @@ class _LocationSearchBarState extends State<LocationSearchBar> {
});
if (query.isEmpty) {
context.read<AppState>().clearSearchResults();
// Clear navigation search results instead of old search state
final appState = context.read<AppState>();
appState.clearNavigationSearchResults();
return;
}
// Debounce search to avoid too many API calls
_searchDebouncer(() {
if (mounted) {
context.read<AppState>().search(query);
final appState = context.read<AppState>();
appState.searchNavigation(query);
}
});
}
@@ -74,12 +79,22 @@ class _LocationSearchBarState extends State<LocationSearchBar> {
void _onClear() {
_controller.clear();
context.read<AppState>().clearSearchResults();
context.read<AppState>().clearNavigationSearchResults();
setState(() {
_showResults = false;
});
}
void _onCancel() {
_controller.clear();
context.read<AppState>().clearNavigationSearchResults();
setState(() {
_showResults = false;
});
_focusNode.unfocus();
widget.onCancel?.call();
}
Widget _buildResultsList(List<SearchResult> results, bool isLoading) {
if (!_showResults) return const SizedBox.shrink();
@@ -166,12 +181,21 @@ class _LocationSearchBarState extends State<LocationSearchBar> {
decoration: InputDecoration(
hintText: 'Search places or coordinates...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: _onClear,
)
: null,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: _onCancel,
tooltip: 'Cancel search',
),
],
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
@@ -186,7 +210,7 @@ class _LocationSearchBarState extends State<LocationSearchBar> {
onChanged: _onSearchChanged,
),
),
_buildResultsList(appState.searchResults, appState.isSearchLoading),
_buildResultsList(appState.navigationSearchResults, appState.isNavigationSearchLoading),
],
);
},