mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
UX bones
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
282
lib/state/navigation_state.dart
Normal file
282
lib/state/navigation_state.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
314
lib/widgets/navigation_sheet.dart
Normal file
314
lib/widgets/navigation_sheet.dart
Normal 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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/widgets/provisional_pin.dart
Normal file
60
lib/widgets/provisional_pin.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user