import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:path_provider/path_provider.dart'; import 'package:vector_map_tiles/vector_map_tiles.dart'; import '../../../../core/api/api_client.dart'; import '../../providers/map_provider.dart'; import '../../providers/map_style_provider.dart'; import '../widgets/map_controls.dart'; import '../widgets/place_card.dart'; class MapScreen extends ConsumerStatefulWidget { const MapScreen({super.key}); @override ConsumerState createState() => _MapScreenState(); } class _MapScreenState extends ConsumerState { late final MapController _mapController; @override void initState() { super.initState(); _mapController = MapController(); WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(mapProvider.notifier).locateUser(); }); } @override void dispose() { _mapController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final mapState = ref.watch(mapProvider); final apiClient = ref.watch(apiClientProvider); final styleAsync = ref.watch(mapStyleProvider(apiClient.baseUrl)); // Move the map only for programmatic state changes (locateUser, // zoomIn/zoomOut). Gesture-driven pans no longer call updateCamera(), // so this listener only fires for intentional moves and won't interfere // with the TileLayer's in-flight tile loads. ref.listen(mapProvider, (previous, next) { if (previous?.center != next.center || previous?.zoom != next.zoom) { _mapController.move(next.center, next.zoom); } }); return Scaffold( body: Stack( children: [ FlutterMap( mapController: _mapController, options: MapOptions( backgroundColor: Colors.transparent, initialCenter: mapState.center, initialZoom: mapState.zoom, minZoom: 0, maxZoom: 18, onPositionChanged: (position, hasGesture) { // Intentionally not syncing gesture-driven position changes // back to the provider. Doing so caused ref.listen to call // _mapController.move() on every pan/zoom frame, which made // flutter_map's TileLayer cancel in-flight tile loads — // producing a partially-rendered (diagonal) map and crashes // when zooming out during tile loading. }, onTap: (tapPosition, point) { ref.read(mapProvider.notifier).clearSelectedPlace(); }, ), children: [ styleAsync.when( data: (style) => VectorTileLayer( tileProviders: style.providers, theme: style.theme, concurrency: 4, memoryTileCacheMaxSize: 20 * 1024 * 1024, // 20 MB raw tiles memoryTileDataCacheMaxSize: 50, // 50 parsed tiles fileCacheMaximumSizeInBytes: 50 * 1024 * 1024, // 50 MB on disk cacheFolder: () => getApplicationCacheDirectory(), ), loading: () => const SizedBox.shrink(), error: (e, _) => const SizedBox.shrink(), ), if (mapState.currentLocation != null) MarkerLayer( markers: [ Marker( point: mapState.currentLocation!, width: 24, height: 24, child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 3), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), ), ), ], ), ], ), // Search bar at the top Positioned( top: MediaQuery.of(context).padding.top + 8, left: 12, right: 12, child: GestureDetector( onTap: () => context.push('/search'), child: Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Icon(Icons.search, color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 12), Expanded( child: Text( 'Search places...', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Theme.of(context) .colorScheme .onSurfaceVariant, ), ), ), IconButton( icon: const Icon(Icons.settings), onPressed: () => context.push('/settings'), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ), ), ), ), // Map controls const MapControls(), // Place card bottom sheet const PlaceCard(), // Offline button Positioned( left: 16, bottom: 120, child: FloatingActionButton.small( heroTag: 'offline', onPressed: () => context.push('/offline'), child: const Icon(Icons.download), ), ), ], ), ); } }