import 'package:flutter/material.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'; import '../dev_config.dart'; import '../widgets/map_view.dart'; import '../services/localization_service.dart'; import '../widgets/node_tag_sheet.dart'; import '../widgets/download_area_dialog.dart'; import '../widgets/measured_sheet.dart'; import '../widgets/search_bar.dart'; import '../widgets/suspected_location_sheet.dart'; import '../widgets/welcome_dialog.dart'; import '../widgets/changelog_dialog.dart'; import '../models/osm_node.dart'; import '../models/suspected_location.dart'; import '../models/search_result.dart'; import '../services/changelog_service.dart'; import '../services/deep_link_service.dart'; import 'coordinators/sheet_coordinator.dart'; import 'coordinators/navigation_coordinator.dart'; import 'coordinators/map_interaction_handler.dart'; import 'package:geolocator/geolocator.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State with TickerProviderStateMixin { final GlobalKey _scaffoldKey = GlobalKey(); final GlobalKey _mapViewKey = GlobalKey(); late final AnimatedMapController _mapController; // Coordinators for managing different aspects of the home screen late final SheetCoordinator _sheetCoordinator; late final NavigationCoordinator _navigationCoordinator; late final MapInteractionHandler _mapInteractionHandler; // Track node limit state for button disabling bool _isNodeLimitActive = false; // Track selected node for highlighting int? _selectedNodeId; // Track popup display to avoid showing multiple times bool _hasCheckedForPopup = false; @override void initState() { super.initState(); _mapController = AnimatedMapController(vsync: this); _sheetCoordinator = SheetCoordinator(); _navigationCoordinator = NavigationCoordinator(); _mapInteractionHandler = MapInteractionHandler(); DeepLinkService().onNodeDeepLink = _handleNodeDeepLink; } @override void dispose() { DeepLinkService().onNodeDeepLink = null; _mapController.dispose(); super.dispose(); } String _getFollowMeTooltip(FollowMeMode mode) { final locService = LocalizationService.instance; switch (mode) { case FollowMeMode.off: return locService.t('followMe.off'); case FollowMeMode.follow: return locService.t('followMe.follow'); case FollowMeMode.rotating: return locService.t('followMe.rotating'); } } IconData _getFollowMeIcon(FollowMeMode mode) { switch (mode) { case FollowMeMode.off: return Icons.gps_off; case FollowMeMode.follow: return Icons.gps_fixed; case FollowMeMode.rotating: return Icons.navigation; } } FollowMeMode _getNextFollowMeMode(FollowMeMode mode) { switch (mode) { case FollowMeMode.off: return FollowMeMode.follow; case FollowMeMode.follow: return FollowMeMode.rotating; case FollowMeMode.rotating: return FollowMeMode.off; } } void _openAddNodeSheet() { _sheetCoordinator.openAddNodeSheet( context: context, scaffoldKey: _scaffoldKey, mapController: _mapController, isNodeLimitActive: _isNodeLimitActive, onStateChanged: () => setState(() {}), ); } void _openEditNodeSheet() { // Set transition flag BEFORE closing tag sheet to prevent map bounce _sheetCoordinator.setTransitioningToEdit(true); // Close any existing tag sheet first if (_sheetCoordinator.tagSheetHeight > 0) { Navigator.of(context).pop(); } // Small delay to let tag sheet close smoothly Future.delayed(const Duration(milliseconds: 150), () { if (!mounted) return; _sheetCoordinator.openEditNodeSheet( context: context, scaffoldKey: _scaffoldKey, mapController: _mapController, onStateChanged: () { setState(() { // Clear tag sheet height and selected node when transitioning if (_sheetCoordinator.editSheetHeight > 0 && _sheetCoordinator.transitioningToEdit) { _sheetCoordinator.resetTagSheetHeight(() {}); _selectedNodeId = null; // Clear selection when moving to edit } }); }, ); }); } void _openNavigationSheet() { _sheetCoordinator.openNavigationSheet( context: context, scaffoldKey: _scaffoldKey, onStateChanged: () => setState(() {}), onStartRoute: _onStartRoute, onResumeRoute: _onResumeRoute, ); } // Request location permission on first launch Future _requestLocationPermissionIfFirstLaunch() async { if (!mounted) return; try { // Only request on first launch or if user has never seen welcome final isFirstLaunch = await ChangelogService().isFirstLaunch(); final hasSeenWelcome = await ChangelogService().hasSeenWelcome(); if (isFirstLaunch || !hasSeenWelcome) { // Check if location services are enabled bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { debugPrint('[HomeScreen] Location services disabled - skipping permission request'); return; } // Request location permission (this will show system dialog if needed) final permission = await Geolocator.requestPermission(); debugPrint('[HomeScreen] First launch location permission result: $permission'); } } catch (e) { // Silently handle errors to avoid breaking the app launch debugPrint('[HomeScreen] Error requesting location permission: $e'); } } // Check for and display welcome/changelog popup Future _checkForPopup() async { if (!mounted) return; try { final appState = context.read(); // Run any needed migrations first final versionsNeedingMigration = await ChangelogService().getVersionsNeedingMigration(); if (!mounted) return; for (final version in versionsNeedingMigration) { await ChangelogService().runMigration(version, appState, context); } // Determine what popup to show final popupType = await ChangelogService().getPopupType(); if (!mounted) return; // Check again after async operation switch (popupType) { case PopupType.welcome: await showDialog( context: context, barrierDismissible: false, builder: (context) => const WelcomeDialog(), ); // Request location permission right after welcome dialog on first launch if (!mounted) return; await _requestLocationPermissionIfFirstLaunch(); break; case PopupType.changelog: final changelogContent = await ChangelogService().getChangelogContentForDisplay(); if (!mounted) return; if (changelogContent != null) { await showDialog( context: context, barrierDismissible: false, builder: (context) => ChangelogDialog(changelogContent: changelogContent), ); } break; case PopupType.none: // No popup needed break; } // Complete the version change workflow (updates last seen version) await ChangelogService().completeVersionChange(); } catch (e) { // Silently handle errors to avoid breaking the app launch debugPrint('[HomeScreen] Error checking for popup: $e'); // Still complete version change to avoid getting stuck try { await ChangelogService().completeVersionChange(); } catch (e2) { debugPrint('[HomeScreen] Error completing version change: $e2'); } } } void _onStartRoute() { _navigationCoordinator.startRoute( context: context, mapController: _mapController, mapViewKey: _mapViewKey, ); } void _onResumeRoute() { _navigationCoordinator.resumeRoute( context: context, mapController: _mapController, mapViewKey: _mapViewKey, ); } void _onNavigationButtonPressed() { final appState = context.read(); if (appState.showRouteButton) { // Route button - show route overview and zoom to show route appState.showRouteOverview(); _navigationCoordinator.zoomToShowFullRoute( appState: appState, mapController: _mapController, ); } else { // Search/navigation button - delegate to coordinator _navigationCoordinator.handleNavigationButtonPress( context: context, mapController: _mapController, ); } } void _onSearchResultSelected(SearchResult result) { _mapInteractionHandler.handleSearchResultSelection( context: context, result: result, mapController: _mapController, ); } void _onMapLongPress(LatLng location) { // Don't handle long press if tag sheet is open if (_sheetCoordinator.tagSheetHeight > 0) { debugPrint('[HomeScreen] Long press ignored - tag sheet is open'); return; } _mapInteractionHandler.handleMapLongPress( context: context, tapLocation: location, mapController: _mapController, onAddNode: _openAddNodeSheet, ); } void _handleNodeDeepLink(OsmNode node) { try { _mapController.animateTo( dest: node.coord, zoom: 16.0, duration: const Duration(milliseconds: 600), curve: Curves.easeOut, ); } catch (e) { debugPrint('[HomeScreen] Could not animate to deep link node: $e'); } Future.delayed(const Duration(milliseconds: 700), () { if (mounted) { openNodeTagSheet(node); } }); } void openNodeTagSheet(OsmNode node) { // Handle the map interaction (centering and follow-me disable) _mapInteractionHandler.handleNodeTap( context: context, node: node, mapController: _mapController, onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id), ); final controller = _scaffoldKey.currentState!.showBottomSheet( (ctx) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard ), child: MeasuredSheet( onHeightChanged: (height) { _sheetCoordinator.updateTagSheetHeight( height + MediaQuery.of(context).padding.bottom, () => setState(() {}), ); }, child: NodeTagSheet( node: node, isNodeLimitActive: _isNodeLimitActive, onEditPressed: () { // Check minimum zoom level before starting edit session final currentZoom = _mapController.mapController.camera.zoom; if (currentZoom < kMinZoomForNodeEditingSheets) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( LocalizationService.instance.t('editNode.zoomInRequiredMessage', params: [kMinZoomForNodeEditingSheets.toString()]) ), ), ); return; } final appState = context.read(); appState.startEditSession(node); // This will trigger _openEditNodeSheet via the existing auto-show logic }, ), ), ), ); // Reset height and selection when sheet is dismissed (unless transitioning to edit) controller.closed.then((_) { if (!_sheetCoordinator.transitioningToEdit) { _sheetCoordinator.resetTagSheetHeight(() => setState(() {})); setState(() => _selectedNodeId = null); } // If transitioning to edit, keep the height until edit sheet takes over }); } void openSuspectedLocationSheet(SuspectedLocation location) { // Handle the map interaction (centering and selection) _mapInteractionHandler.handleSuspectedLocationTap( context: context, location: location, mapController: _mapController, ); final controller = _scaffoldKey.currentState!.showBottomSheet( (ctx) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard ), child: MeasuredSheet( onHeightChanged: (height) { _sheetCoordinator.updateTagSheetHeight( height + MediaQuery.of(context).padding.bottom, () => setState(() {}), ); }, child: SuspectedLocationSheet(location: location), ), ), ); // Reset height and clear selection when sheet is dismissed final appState = context.read(); controller.closed.then((_) { if (!mounted) return; _sheetCoordinator.resetTagSheetHeight(() => setState(() {})); appState.clearSuspectedLocationSelection(); }); } @override Widget build(BuildContext context) { final appState = context.watch(); // Auto-open edit sheet when edit session starts if (appState.editSession != null && !_sheetCoordinator.editSheetShown) { _sheetCoordinator.setEditSheetShown(true); WidgetsBinding.instance.addPostFrameCallback((_) => _openEditNodeSheet()); } else if (appState.editSession == null) { _sheetCoordinator.setEditSheetShown(false); } // Auto-open navigation sheet when needed - only when online and in nav features mode if (kEnableNavigationFeatures) { final shouldShowNavSheet = !appState.offlineMode && (appState.isInSearchMode || appState.showingOverview); if (shouldShowNavSheet && !_sheetCoordinator.navigationSheetShown) { _sheetCoordinator.setNavigationSheetShown(true); WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet()); } else if (!shouldShowNavSheet && _sheetCoordinator.navigationSheetShown) { _sheetCoordinator.setNavigationSheetShown(false); // When sheet should close (including going offline), clean up navigation state if (appState.offlineMode) { WidgetsBinding.instance.addPostFrameCallback((_) { appState.cancelNavigation(); }); } } } // Check for welcome/changelog popup after app is fully initialized if (appState.isInitialized && !_hasCheckedForPopup) { _hasCheckedForPopup = true; WidgetsBinding.instance.addPostFrameCallback((_) { _checkForPopup(); // Check if re-authentication is needed for message notifications appState.checkAndPromptReauthForMessages(context); }); } // Pass the active sheet height directly to the map final activeSheetHeight = _sheetCoordinator.activeSheetHeight; return MediaQuery( data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero), child: Scaffold( key: _scaffoldKey, appBar: AppBar( automaticallyImplyLeading: false, // Disable automatic back button title: SvgPicture.asset( 'assets/deflock-logo.svg', height: 28, fit: BoxFit.contain, ), actions: [ IconButton( tooltip: _getFollowMeTooltip(appState.followMeMode), icon: Icon(_getFollowMeIcon(appState.followMeMode)), onPressed: (_mapViewKey.currentState?.hasLocation == true && !_sheetCoordinator.hasActiveNodeSheet) ? () { 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 (newMode != FollowMeMode.off) { _mapViewKey.currentState?.retryLocationInit(); } } : null, // Grey out when no location or when node sheet is open ), AnimatedBuilder( animation: LocalizationService.instance, builder: (context, child) { final appState = context.watch(); return IconButton( tooltip: LocalizationService.instance.settings, icon: Stack( children: [ const Icon(Icons.settings), if (appState.hasUnreadMessages) Positioned( right: 0, top: 0, child: Container( width: 10, height: 10, decoration: BoxDecoration( color: Theme.of(context).colorScheme.error, shape: BoxShape.circle, ), ), ), ], ), onPressed: () => Navigator.pushNamed(context, '/settings'), ); }, ), ], ), body: Stack( children: [ MapView( key: _mapViewKey, controller: _mapController, followMeMode: appState.followMeMode, sheetHeight: activeSheetHeight, selectedNodeId: _selectedNodeId, onNodeTap: openNodeTagSheet, onSuspectedLocationTap: openSuspectedLocationSheet, onSearchPressed: _onNavigationButtonPressed, onNodeLimitChanged: (isLimited) { setState(() { _isNodeLimitActive = isLimited; }); }, onLocationStatusChanged: () { // Re-render when location status changes (for follow-me button state) setState(() {}); }, onMapLongPress: _onMapLongPress, onUserGesture: () { // Only clear selected node if tag sheet is not open // This prevents nodes from losing their grey-out when map is moved while viewing tags if (_sheetCoordinator.tagSheetHeight == 0) { _mapInteractionHandler.handleUserGesture( context: context, onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id), ); } else { // Tag sheet is open - only handle suspected location clearing, not node selection final appState = context.read(); appState.clearSuspectedLocationSelection(); } if (appState.followMeMode != FollowMeMode.off) { appState.setFollowMeMode(FollowMeMode.off); } }, ), // Search bar (slides in when in search mode) if (appState.isInSearchMode) Positioned( top: 0, left: 0, right: 0, child: LocationSearchBar( onResultSelected: _onSearchResultSelected, onCancel: () => appState.cancelNavigation(), ), ), // Bottom button bar (restored to original) Align( alignment: Alignment.bottomCenter, child: Builder( builder: (context) { final safeArea = MediaQuery.of(context).padding; return Padding( padding: EdgeInsets.only( bottom: safeArea.bottom + kBottomButtonBarOffset, left: leftPositionWithSafeArea(8, safeArea), right: rightPositionWithSafeArea(8, safeArea), ), 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.withValues(alpha: 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( flex: 7, // 70% for primary action 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( flex: 3, // 30% for secondary action child: AnimatedBuilder( animation: LocalizationService.instance, builder: (context, child) { final appState = context.watch(); final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false; return FittedBox( fit: BoxFit.scaleDown, child: ElevatedButton.icon( icon: Icon(Icons.download_for_offline), label: Text(LocalizationService.instance.download), onPressed: canDownload ? () { // Check minimum zoom level before opening download dialog final currentZoom = _mapController.mapController.camera.zoom; if (currentZoom < kMinZoomForOfflineDownload) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( LocalizationService.instance.t('download.areaTooBigMessage', params: [kMinZoomForOfflineDownload.toString()]) ), ), ); return; } showDialog( context: context, builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), ); } : null, style: ElevatedButton.styleFrom( minimumSize: Size(0, 48), textStyle: TextStyle(fontSize: 16), ), ), ); }, ), ), ], ), ), ), ); }, ), ), ], ), ), ); } }