maps/mobile/lib/features/map/presentation/screens/map_screen.dart
Shautvast a3aaf1b6de fix: stop syncing gesture positions back to mapProvider
Removed the updateCamera() call from onPositionChanged. Previously,
every gesture-driven pan/zoom frame updated the provider state, which
triggered ref.listen to call _mapController.move() redundantly.
This caused flutter_map's TileLayer to cancel in-flight tile loads
on every frame — producing partially-rendered maps and crashes when
zooming out during tile loading.

The ref.listen now only fires for programmatic changes (locateUser,
zoomIn/zoomOut), which are infrequent single-shot moves.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 16:15:02 +02:00

174 lines
6.3 KiB
Dart

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<MapScreen> createState() => _MapScreenState();
}
class _MapScreenState extends ConsumerState<MapScreen> {
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<MapState>(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),
),
),
],
),
);
}
}