import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' as services;
import 'package:google_maps_flutter/google_maps_flutter.dart' as google;
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart' as latlong;
import '../../core/config/app_config.dart';
import '../../l10n/app_localizations.dart';
import '../../models/content_models.dart';
import '../navigation/external_navigation_adapter.dart';
import 'google_maps_runtime.dart';
import 'map_policy.dart';
const _bundledUserMarkerAsset = 'assets/ico/user_location_marker.png';
/// The product map implementation for builds with Google Maps Platform
/// enabled. WordPress still supplies the geographic content and viewport
/// policy; the Google SDK owns projection, tiles, camera and gestures.
class GoogleMapSurface extends StatefulWidget {
const GoogleMapSurface({
super.key,
required this.items,
required this.meta,
required this.geoConfig,
required this.onItemTap,
this.onRetry,
this.onOpenGoogleMaps,
this.initialSelectedItem,
this.searchLatitude,
this.searchLongitude,
this.userLatitude,
this.userLongitude,
this.radiusKm = 10,
this.compact = false,
this.showSearchCenterMarker = true,
});
final List<DiscoveryItem> items;
final DiscoveryMeta? meta;
final GeoConfig? geoConfig;
final ValueChanged<DiscoveryItem> onItemTap;
final VoidCallback? onRetry;
final Future<bool> Function(NavigationTarget target)? onOpenGoogleMaps;
final DiscoveryItem? initialSelectedItem;
final double? searchLatitude;
final double? searchLongitude;
final double? userLatitude;
final double? userLongitude;
final double radiusKm;
final bool compact;
final bool showSearchCenterMarker;
@override
State<GoogleMapSurface> createState() => _GoogleMapSurfaceState();
}
class _GoogleMapSurfaceState extends State<GoogleMapSurface> {
google.GoogleMapController? _controller;
late Future<bool> _mapsReady;
bool _hasFittedViewport = false;
bool _cameraMoveRequested = false;
final _markerIconLoads = <String, Future<google.BitmapDescriptor?>>{};
final _markerIcons = <String, google.BitmapDescriptor>{};
Future<google.BitmapDescriptor?>? _bundledUserMarkerLoad;
google.BitmapDescriptor? _userMarkerIcon;
late Future<void> _markerIconsReady;
@override
void initState() {
super.initState();
_mapsReady = ensureGoogleMapsWebReady();
_markerIconsReady = _prepareMarkerIcons(widget.items, widget.geoConfig);
}
@override
void didUpdateWidget(covariant GoogleMapSurface oldWidget) {
super.didUpdateWidget(oldWidget);
if (_markerSignature(oldWidget.items, oldWidget.geoConfig) !=
_markerSignature(widget.items, widget.geoConfig)) {
_markerIconsReady = _prepareMarkerIcons(widget.items, widget.geoConfig);
}
if (_viewportSignature(oldWidget) != _viewportSignature(widget)) {
_hasFittedViewport = false;
_scheduleFit();
}
}
@override
Widget build(BuildContext context) {
final height = widget.compact ? 220.0 : 330.0;
return Container(
height: height,
margin: EdgeInsets.only(bottom: widget.compact ? 0 : 16),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(widget.compact ? 14 : 20),
),
child: FutureBuilder<bool>(
future: _mapsReady,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.data != true) {
return _GoogleMapSetupState(
compact: widget.compact,
onOpen: () => _openExternalMap(context),
);
}
return FutureBuilder<void>(
future: _markerIconsReady,
builder: (context, markerSnapshot) {
if (markerSnapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
return _buildMap(context);
},
);
},
),
);
}
Widget _buildMap(BuildContext context) {
final geo = widget.geoConfig ?? GeoConfig.fallback();
final center = _priorityCenter(geo);
final l10n = AppLocalizations.of(context);
final clusterManager = const google.ClusterManager(
clusterManagerId: google.ClusterManagerId('meinweimar-content'),
);
final useNativeClustering = !kIsWeb;
return Stack(
children: [
Positioned.fill(
child: google.GoogleMap(
initialCameraPosition: google.CameraPosition(
target: google.LatLng(center.latitude, center.longitude),
zoom: _initialZoom(geo),
),
mapType: google.MapType.normal,
compassEnabled: !kIsWeb,
mapToolbarEnabled: false,
mapTypeControlEnabled: false,
fullscreenControlEnabled: false,
streetViewControlEnabled: false,
myLocationEnabled: false,
myLocationButtonEnabled: false,
zoomControlsEnabled: !kIsWeb,
webGestureHandling: kIsWeb
? google.WebGestureHandling.greedy
: null,
minMaxZoomPreference: const google.MinMaxZoomPreference(
MapPolicy.minZoom,
MapPolicy.maxZoom,
),
clusterManagers: useNativeClustering ? {clusterManager} : const {},
markers: _markers(
l10n,
useNativeClustering ? clusterManager.clusterManagerId : null,
),
onMapCreated: _onMapCreated,
onCameraIdle: _scheduleFit,
),
),
if (widget.items.every((item) => !_isMappable(item)))
Positioned(
left: 16,
right: 16,
bottom: 16,
child: _MapMessage(
text: l10n.mapNoPublicLocations,
icon: Icons.place_outlined,
),
),
if (widget.initialSelectedItem != null && !widget.compact)
Positioned(
left: 16,
right: 16,
bottom: 16,
child: _MapPreviewCard(
item: widget.initialSelectedItem!,
onOpen: () => widget.onItemTap(widget.initialSelectedItem!),
),
),
],
);
}
Set<google.Marker> _markers(
AppLocalizations l10n,
google.ClusterManagerId? clusterManagerId,
) {
final markers = <google.Marker>{};
for (final item in widget.items.where(_isMappable)) {
final id = 'content-${item.resource}-${item.id}';
final markerImageUrl = item.marker.imageUrl.trim();
markers.add(
google.Marker(
markerId: google.MarkerId(id),
position: google.LatLng(item.geo.latitude!, item.geo.longitude!),
clusterManagerId: clusterManagerId,
infoWindow: google.InfoWindow(title: item.title),
onTap: () => widget.onItemTap(item),
icon:
_markerIcons[markerImageUrl] ??
google.BitmapDescriptor.defaultMarkerWithHue(
google.BitmapDescriptor.hueRose,
),
),
);
}
final search = _searchCenter;
if (widget.showSearchCenterMarker &&
search != null &&
!_hasContentMarkerAt(search)) {
markers.add(
google.Marker(
markerId: const google.MarkerId('search-center'),
position: google.LatLng(search.latitude, search.longitude),
infoWindow: google.InfoWindow(title: l10n.mapSearchCenter),
icon: google.BitmapDescriptor.defaultMarkerWithHue(
google.BitmapDescriptor.hueAzure,
),
),
);
}
if (_showUserLocation) {
markers.add(
google.Marker(
markerId: const google.MarkerId('user-location'),
position: google.LatLng(widget.userLatitude!, widget.userLongitude!),
infoWindow: google.InfoWindow(title: l10n.mapCurrentLocation),
icon:
_userMarkerIcon ??
google.BitmapDescriptor.defaultMarkerWithHue(
google.BitmapDescriptor.hueViolet,
),
),
);
}
return markers;
}
Future<void> _prepareMarkerIcons(
Iterable<DiscoveryItem> items,
GeoConfig? geoConfig,
) async {
final urls = items
.where(_isMappable)
.map((item) => item.marker.imageUrl.trim())
.where((url) => url.isNotEmpty)
.toSet();
await Future.wait(urls.map(_loadMarkerIcon));
final userUrl = _userMarkerImageUrl(geoConfig);
_userMarkerIcon = userUrl.isNotEmpty
? await _loadMarkerIcon(userUrl) ?? await _loadBundledUserMarker()
: await _loadBundledUserMarker();
}
Future<google.BitmapDescriptor?> _loadMarkerIcon(String url) {
final cached = _markerIcons[url];
if (cached != null) return Future.value(cached);
final existing = _markerIconLoads[url];
if (existing != null) return existing;
final load = _fetchMarkerIcon(url).then((icon) {
if (icon != null) _markerIcons[url] = icon;
return icon;
});
_markerIconLoads[url] = load;
return load;
}
Future<google.BitmapDescriptor?> _fetchMarkerIcon(String url) async {
// WordPress exposes approved marker media as image bytes through the
// public media route. BitmapDescriptor.bytes is supported by Android,
// iOS and the Google Maps JavaScript implementation. SVG media is left
// on the deterministic hue fallback because the native SDKs require PNG
// bytes for bitmap markers.
if (url.toLowerCase().split('?').first.endsWith('.svg')) return null;
try {
final response = await http
.get(Uri.parse(url))
.timeout(const Duration(seconds: 4));
if (response.statusCode != 200 || response.bodyBytes.isEmpty) {
return null;
}
return google.BitmapDescriptor.bytes(
response.bodyBytes,
// Set only the height. google_maps_flutter preserves the source
// aspect ratio when exactly one dimension is supplied; passing both
// dimensions would behave like BoxFit.fill and stretch markers such
// as the 224x340 MeinWeimar pin horizontally.
height: 52,
);
} on Object {
return null;
}
}
Future<google.BitmapDescriptor?> _loadBundledUserMarker() {
final existing = _bundledUserMarkerLoad;
if (existing != null) return existing;
final load = _readBundledUserMarker();
_bundledUserMarkerLoad = load;
return load;
}
Future<google.BitmapDescriptor?> _readBundledUserMarker() async {
try {
final data = await services.rootBundle.load(_bundledUserMarkerAsset);
return google.BitmapDescriptor.bytes(
data.buffer.asUint8List(),
height: 52,
);
} on Object {
return null;
}
}
String _userMarkerImageUrl(GeoConfig? geoConfig) {
final marker = geoConfig?.userMarker;
if (marker == null) return '';
final appUrl = marker['app_url'];
if (appUrl is String && appUrl.trim().isNotEmpty) return appUrl.trim();
final imageUrl = marker['image_url'];
return imageUrl is String ? imageUrl.trim() : '';
}
String _markerSignature(Iterable<DiscoveryItem> items, GeoConfig? geoConfig) {
final urls =
items
.where(_isMappable)
.map((item) => item.marker.imageUrl.trim())
.where((url) => url.isNotEmpty)
.toSet()
.toList()
..sort();
return '${_userMarkerImageUrl(geoConfig)}\u0000${urls.join('\u0000')}';
}
void _onMapCreated(google.GoogleMapController controller) {
_controller = controller;
_scheduleFit();
}
void _scheduleFit() {
if (_cameraMoveRequested || _controller == null || _hasFittedViewport) {
return;
}
_cameraMoveRequested = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
_cameraMoveRequested = false;
if (!mounted || _controller == null || _hasFittedViewport) return;
await _fitViewport();
});
}
Future<void> _fitViewport() async {
final controller = _controller;
if (controller == null) return;
final points = <google.LatLng>[];
final center = _searchCenter;
if (center != null) {
points.add(google.LatLng(center.latitude, center.longitude));
}
for (final item in widget.items.where(_isMappable)) {
points.add(google.LatLng(item.geo.latitude!, item.geo.longitude!));
}
if (points.length > 1 && widget.initialSelectedItem == null) {
final bounds = _boundsFor(points);
try {
await controller.animateCamera(
google.CameraUpdate.newLatLngBounds(bounds, widget.compact ? 28 : 56),
);
_hasFittedViewport = true;
return;
} on Object {
// The native/web map may not have committed its dimensions yet. The
// next idle callback will retry without changing marker coordinates.
return;
}
}
final target = widget.initialSelectedItem != null
? google.LatLng(
widget.initialSelectedItem!.geo.latitude!,
widget.initialSelectedItem!.geo.longitude!,
)
: points.length == 1
? points.single
: google.LatLng(
_priorityCenter(widget.geoConfig ?? GeoConfig.fallback()).latitude,
_priorityCenter(widget.geoConfig ?? GeoConfig.fallback()).longitude,
);
await controller.animateCamera(
google.CameraUpdate.newCameraPosition(
google.CameraPosition(
target: target,
zoom: widget.initialSelectedItem != null
? MapPolicy.detailZoom
: points.length == 1
? MapPolicy.detailZoom
: _initialZoom(widget.geoConfig ?? GeoConfig.fallback()),
),
),
);
_hasFittedViewport = true;
}
google.LatLngBounds _boundsFor(List<google.LatLng> points) {
var minLat = points.first.latitude;
var maxLat = points.first.latitude;
var minLng = points.first.longitude;
var maxLng = points.first.longitude;
for (final point in points.skip(1)) {
minLat = math.min(minLat, point.latitude);
maxLat = math.max(maxLat, point.latitude);
minLng = math.min(minLng, point.longitude);
maxLng = math.max(maxLng, point.longitude);
}
return google.LatLngBounds(
southwest: google.LatLng(minLat, minLng),
northeast: google.LatLng(maxLat, maxLng),
);
}
Future<void> _openExternalMap(BuildContext context) async {
final geo = widget.geoConfig ?? GeoConfig.fallback();
final center = _priorityCenter(geo);
final target = NavigationTarget(
latitude: center.latitude,
longitude: center.longitude,
);
final opened = widget.onOpenGoogleMaps != null
? await widget.onOpenGoogleMaps!(target)
: await ExternalNavigationAdapter.system().openMap(
target,
zoom: geo.defaultMapZoom,
);
if (!opened && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).linkOpenFailed)),
);
}
}
latlong.LatLng _priorityCenter(GeoConfig geo) {
if (widget.searchLatitude != null && widget.searchLongitude != null) {
return latlong.LatLng(widget.searchLatitude!, widget.searchLongitude!);
}
if (widget.userLatitude != null && widget.userLongitude != null) {
return latlong.LatLng(widget.userLatitude!, widget.userLongitude!);
}
final detail = widget.initialSelectedItem;
if (detail != null && _isMappable(detail)) {
return latlong.LatLng(detail.geo.latitude!, detail.geo.longitude!);
}
return latlong.LatLng(geo.defaultLatitude, geo.defaultLongitude);
}
latlong.LatLng? get _searchCenter {
if (widget.searchLatitude == null || widget.searchLongitude == null) {
return null;
}
return latlong.LatLng(widget.searchLatitude!, widget.searchLongitude!);
}
bool get _showUserLocation =>
_searchCenter == null &&
widget.userLatitude != null &&
widget.userLongitude != null;
bool _isMappable(DiscoveryItem item) =>
item.geo.available &&
item.geo.latitude != null &&
item.geo.longitude != null;
bool _hasContentMarkerAt(latlong.LatLng point) {
for (final item in widget.items.where(_isMappable)) {
final latDelta = (item.geo.latitude! - point.latitude).abs() * 111320;
final lngDelta =
(item.geo.longitude! - point.longitude).abs() *
111320 *
math.cos(point.latitude * math.pi / 180).abs().clamp(0.01, 1.0);
if (math.sqrt(latDelta * latDelta + lngDelta * lngDelta) <=
MapPolicy.searchCenterCollisionMeters) {
return true;
}
}
return false;
}
double _initialZoom(GeoConfig geo) {
if (widget.initialSelectedItem != null) return MapPolicy.detailZoom;
return MapPolicy.zoomForRadius(
widget.radiusKm,
fallback: geo.defaultMapZoom.toDouble(),
);
}
String _viewportSignature(GoogleMapSurface value) => [
value.initialSelectedItem?.id,
value.searchLatitude,
value.searchLongitude,
value.userLatitude,
value.userLongitude,
value.radiusKm,
value.items
.map((item) => '${item.id}:${item.geo.latitude}:${item.geo.longitude}')
.join(','),
].join('|');
}
class _GoogleMapSetupState extends StatelessWidget {
const _GoogleMapSetupState({required this.compact, required this.onOpen});
final bool compact;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: EdgeInsets.all(compact ? 12 : 20),
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: scheme.outlineVariant),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 12 : 18,
vertical: compact ? 10 : 14,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.map_outlined, color: scheme.primary),
const SizedBox(width: 10),
Flexible(
child: Text(
l10n.mapGoogleFallback,
textAlign: TextAlign.center,
),
),
const SizedBox(width: 10),
TextButton(onPressed: onOpen, child: Text(l10n.openGoogleMaps)),
],
),
),
),
),
);
}
}
class _MapMessage extends StatelessWidget {
const _MapMessage({required this.text, required this.icon});
final String text;
final IconData icon;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.surface.withValues(alpha: .94),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: scheme.primary),
const SizedBox(width: 8),
Flexible(child: Text(text)),
],
),
),
);
}
}
class _MapPreviewCard extends StatelessWidget {
const _MapPreviewCard({required this.item, required this.onOpen});
final DiscoveryItem item;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
Icon(
Icons.place_outlined,
size: 34,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}
}
