import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import '../../models/content_models.dart';
import '../media/public_media_image.dart';
import '../navigation/external_navigation_adapter.dart';
import 'google_map_surface.dart';
import 'google_maps_runtime.dart';
import 'map_policy.dart';
const _bundledUserMarkerAsset = 'assets/ico/user_location_marker.png';
/// Shared interactive map adapter for Website/API-aligned discovery.
///
/// The adapter always renders a real map surface. If WordPress has not
/// supplied an internal tile source, it keeps the geographic markers and
/// offers the official Google Maps URL fallback instead of showing a dead-end
/// configuration error.
class MapSurface extends StatefulWidget {
const MapSurface({
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,
this.visible = 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;
/// Discovery maps show the address origin as a separate marker. Embedded
/// detail maps must opt out because their only origin is the selected item.
final bool showSearchCenterMarker;
final bool visible;
@override
State<MapSurface> createState() => _MapSurfaceState();
}
class _MapSurfaceState extends State<MapSurface> {
final _controller = MapController();
DiscoveryItem? _selectedItem;
late final StreamController<void> _tileReset;
String _style = 'road';
bool _mapReady = false;
bool _tileError = false;
bool _cameraRebuildScheduled = false;
String _lastViewportSignature = '';
Timer? _clusterRefreshTimer;
@override
void initState() {
super.initState();
_tileReset = StreamController<void>.broadcast();
_selectedItem = widget.initialSelectedItem;
}
@override
void didUpdateWidget(covariant MapSurface oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialSelectedItem?.id != widget.initialSelectedItem?.id) {
_selectedItem = widget.initialSelectedItem;
}
final oldGeo = oldWidget.geoConfig ?? GeoConfig.fallback();
final newGeo = widget.geoConfig ?? GeoConfig.fallback();
final oldTileUrl = MapPolicy.tileUrlFor(
_style,
oldGeo.tileUrlTemplate,
oldGeo.tileStyles,
);
final newTileUrl = MapPolicy.tileUrlFor(
_style,
newGeo.tileUrlTemplate,
newGeo.tileStyles,
);
if (oldTileUrl != newTileUrl) {
_tileError = false;
_tileReset.add(null);
}
if (!oldWidget.visible && widget.visible && _mapReady) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _fitViewport(force: true),
);
}
if (_shouldRefitAfterUpdate(oldWidget, widget) && _mapReady) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _fitViewport(force: true),
);
}
}
@override
void dispose() {
_clusterRefreshTimer?.cancel();
_tileReset.close();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (googleMapsEnabled) {
return GoogleMapSurface(
items: widget.items,
meta: widget.meta,
geoConfig: widget.geoConfig,
onItemTap: widget.onItemTap,
onRetry: widget.onRetry,
onOpenGoogleMaps: widget.onOpenGoogleMaps,
initialSelectedItem: widget.initialSelectedItem,
searchLatitude: widget.searchLatitude,
searchLongitude: widget.searchLongitude,
userLatitude: widget.userLatitude,
userLongitude: widget.userLongitude,
radiusKm: widget.radiusKm,
compact: widget.compact,
showSearchCenterMarker: widget.showSearchCenterMarker,
);
}
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final geo = widget.geoConfig ?? GeoConfig.fallback();
final mapHeight = widget.compact ? 220.0 : 330.0;
final center = _priorityCenter(geo);
final tileUrl = MapPolicy.tileUrlFor(
_style,
geo.tileUrlTemplate,
geo.tileStyles,
);
final tileConfigured = geo.mapConfigured && tileUrl.isNotEmpty;
final mapError = _tileError;
final mapStatusText = _tileError
? l10n.mapTileLoadFailed
: l10n.mapGoogleFallback;
final mapBackground = tileConfigured
? scheme.surfaceContainerHighest
: scheme.surface;
return Semantics(
container: true,
label: l10n.mapView,
child: Container(
height: mapHeight,
margin: EdgeInsets.only(bottom: widget.compact ? 0 : 16),
decoration: BoxDecoration(
color: mapBackground,
borderRadius: BorderRadius.circular(widget.compact ? 14 : 20),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Positioned.fill(
child: FlutterMap(
mapController: _controller,
options: MapOptions(
initialCenter: center,
initialZoom: _initialZoom(geo),
minZoom: MapPolicy.minZoom,
maxZoom: MapPolicy.maxZoom,
keepAlive: true,
backgroundColor: scheme.surfaceContainerHighest,
onMapReady: _handleMapReady,
// Do not rebuild the complete marker/cluster tree for every
// camera tick. flutter_map already reprojects geographic
// markers during the gesture; rebuilding here caused the
// map to fight the gesture and made swipes feel sticky.
// Clusters are refreshed after a movement ends instead.
onMapEvent: _handleMapEvent,
),
children: [
if (tileConfigured)
TileLayer(
key: ValueKey<String>('tiles:$tileUrl'),
urlTemplate: tileUrl,
// flutter_map expects an application/package identifier
// here, not the operator's e-mail address. The latter
// remains part of the centrally supplied provider
// contract but must not be sent as a package name.
userAgentPackageName: MapPolicy.tileUserAgentPackageName(
geo.userAgentContact,
),
errorTileCallback: _handleTileError,
reset: _tileReset.stream,
),
// Keep the marker tree available for late tile
// configuration and testable geographic state, but do not
// show floating pins on a basemap-less fallback surface.
IgnorePointer(
ignoring: !tileConfigured,
child: Opacity(
opacity: tileConfigured ? 1 : 0,
child: MarkerLayer(markers: _markers(context)),
),
),
if (geo.attribution.isNotEmpty && tileConfigured)
RichAttributionWidget(
attributions: [
TextSourceAttribution(
geo.attribution,
prependCopyright: false,
),
],
),
],
),
),
Positioned(
top: 12,
left: 12,
right: 12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (mapError)
Expanded(
child: _MapStatus(
text: mapStatusText,
error: true,
retryLabel: l10n.retry,
onRetry: (widget.onRetry != null || _tileError)
? _retryMap
: null,
),
)
else
const Spacer(),
if (tileConfigured)
_MapControls(
controller: _controller,
styles: geo.tileStyles,
selectedStyle: _style,
onStyleChanged: (style) => setState(() => _style = style),
styleLabel: _styleLabel,
zoomInLabel: l10n.mapZoomIn,
zoomOutLabel: l10n.mapZoomOut,
),
],
),
),
if (!tileConfigured && !_tileError)
Positioned.fill(
child: Center(
child: _GoogleMapsFallbackCard(
text: l10n.mapGoogleFallback,
actionLabel: l10n.openGoogleMaps,
onOpen: () => _openGoogleMaps(geo),
compact: widget.compact,
),
),
),
if (widget.items.every((item) => !_isMappable(item)))
Positioned(
left: 16,
right: 16,
bottom: _selectedItem == null ? 20 : 84,
child: _MapMessage(
text: l10n.mapNoPublicLocations,
icon: Icons.place_outlined,
),
),
if (_selectedItem != null && !widget.compact)
Positioned(
left: 16,
right: 16,
bottom: 16,
child: _MapPreviewCard(
item: _selectedItem!,
onClose: () => setState(() => _selectedItem = null),
onOpen: () => widget.onItemTap(_selectedItem!),
),
),
],
),
),
);
}
List<Marker> _markers(BuildContext context) {
final mappable = widget.items.where(_isMappable).toList(growable: false);
final clusters = _clusters(mappable);
final markers = <Marker>[
for (final cluster in clusters)
Marker(
point: LatLng(cluster.latitude, cluster.longitude),
width: cluster.items.length > 1 ? 52 : 48,
height: cluster.items.length > 1 ? 52 : 58,
alignment: cluster.items.length > 1
? Alignment.center
: Alignment.bottomCenter,
child: cluster.items.length > 1
? _ClusterPin(
count: cluster.items.length,
onTap: () => _focusItems(cluster.items),
)
: _ItemPin(
item: cluster.items.single,
onTap: () => _selectItem(cluster.items.single),
),
),
];
final search = _searchCenter;
if (search != null && !_hasContentMarkerAt(search)) {
markers.add(
Marker(
point: search,
width: 44,
height: 44,
child: _SearchCenterPin(
label: AppLocalizations.of(context).mapSearchCenter,
),
),
);
}
if (_showUserLocation) {
markers.add(
Marker(
point: LatLng(widget.userLatitude!, widget.userLongitude!),
width: 42,
height: 42,
child: _UserPin(
label: AppLocalizations.of(context).mapCurrentLocation,
imageUrl: _userMarkerImageUrl,
),
),
);
}
return markers;
}
String get _userMarkerImageUrl {
final marker = widget.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() : '';
}
bool _hasContentMarkerAt(LatLng point) {
for (final item in widget.items) {
if (!_isMappable(item)) continue;
final latitudeDelta = (item.geo.latitude! - point.latitude).abs();
final longitudeDelta = (item.geo.longitude! - point.longitude).abs();
// At MeinWeimar's scale this conservative degree check covers the
// small physical collision radius without moving or re-projecting any
// marker. The actual item marker remains at its API Lat/Lon.
final latitudeMeters = latitudeDelta * 111320;
final longitudeMeters =
longitudeDelta * 111320 * _longitudeScale(point.latitude);
final squaredDistanceMeters =
(latitudeMeters * latitudeMeters + longitudeMeters * longitudeMeters)
.toDouble();
final distance = math.sqrt(squaredDistanceMeters);
if (distance <= MapPolicy.searchCenterCollisionMeters) {
return true;
}
}
return false;
}
double _longitudeScale(double latitude) {
// Convert longitude degrees to metres at the marker's latitude while
// keeping the marker itself at the API-provided geographic coordinate.
return math.cos(latitude * math.pi / 180).abs().clamp(0.01, 1.0).toDouble();
}
List<_MapCluster> _clusters(List<DiscoveryItem> items) {
if (!_mapReady || items.length < 2) {
return [
for (final item in items)
_MapCluster(
items: [item],
latitude: item.geo.latitude!,
longitude: item.geo.longitude!,
),
];
}
// Cluster in the camera's current screen space, not in a latitude/
// longitude grid. A degree grid changes its cell boundaries continuously
// during zoom and makes markers jump between unrelated centroids. The
// actual Marker points below remain geographic Lat/Lon values.
final clusters = <_MapCluster>[];
final sorted = [...items]..sort((a, b) => a.id.compareTo(b.id));
for (final item in sorted) {
final lat = item.geo.latitude!;
final lng = item.geo.longitude!;
final screenPoint = _controller.camera.latLngToScreenOffset(
LatLng(lat, lng),
);
_MapCluster? existing;
for (final cluster in clusters) {
if ((screenPoint - cluster.screenPoint).distance <=
MapPolicy.clusterRadiusPixels) {
existing = cluster;
break;
}
}
if (existing == null) {
clusters.add(
_MapCluster(
items: [item],
latitude: lat,
longitude: lng,
screenPoint: screenPoint,
),
);
} else {
// Keep the first, deterministically sorted item as the geographic
// anchor. Replacing it with the arithmetic centroid on every rebuild
// makes the visible cluster marker drift while the camera is zoomed
// or panned. The anchor is still a real content Lat/Lon and therefore
// never depends on a screen coordinate.
existing.items.add(item);
}
}
return clusters;
}
void _scheduleCameraRebuild() {
if (!mounted) return;
if (_cameraRebuildScheduled) return;
_cameraRebuildScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_cameraRebuildScheduled = false;
});
});
}
void _handleMapEvent(MapEvent event) {
if (event is MapEventMoveEnd ||
event is MapEventFlingAnimationEnd ||
event is MapEventDoubleTapZoomEnd ||
event is MapEventRotateEnd) {
_scheduleCameraRebuild();
return;
}
// Desktop/web wheel zoom does not have a separate end event. Debounce its
// cluster refresh so tiles and geographic markers remain fluid while the
// wheel is moving, then settle the cluster layout once the gesture stops.
if (event is MapEventScrollWheelZoom) {
_clusterRefreshTimer?.cancel();
_clusterRefreshTimer = Timer(
const Duration(milliseconds: 140),
_scheduleCameraRebuild,
);
}
}
void _handleTileError(TileImage tile, Object error, StackTrace? stackTrace) {
if (!mounted || _tileError) return;
setState(() => _tileError = true);
}
void _retryMap() {
if (mounted) setState(() => _tileError = false);
_tileReset.add(null);
widget.onRetry?.call();
}
Future<void> _openGoogleMaps(GeoConfig geo) async {
final target = _fallbackTarget(geo);
final opened = widget.onOpenGoogleMaps != null
? await widget.onOpenGoogleMaps!(target)
: await ExternalNavigationAdapter.system().openMap(
target,
zoom: geo.defaultMapZoom,
);
if (!opened && mounted) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.linkOpenFailed)));
}
}
NavigationTarget _fallbackTarget(GeoConfig geo) {
final search = _searchCenter;
if (search != null) {
return NavigationTarget(
latitude: search.latitude,
longitude: search.longitude,
);
}
if (widget.userLatitude != null && widget.userLongitude != null) {
return NavigationTarget(
latitude: widget.userLatitude!,
longitude: widget.userLongitude!,
);
}
final detail = widget.initialSelectedItem;
if (detail != null && _isMappable(detail)) {
return NavigationTarget(
latitude: detail.geo.latitude!,
longitude: detail.geo.longitude!,
address: detail.address.formatted,
);
}
for (final item in widget.items) {
if (_isMappable(item)) {
return NavigationTarget(
latitude: item.geo.latitude!,
longitude: item.geo.longitude!,
address: item.address.formatted,
);
}
}
return NavigationTarget(
latitude: geo.defaultLatitude,
longitude: geo.defaultLongitude,
);
}
void _handleMapReady() {
_mapReady = true;
// Wait until Flutter has committed the map's real viewport dimensions.
// Calling fitCamera synchronously from onMapReady can run before the first
// layout on web; the map then paints only after a user pan/zoom causes a
// second camera calculation.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_fitViewport(force: true);
});
}
LatLng? get _searchCenter {
// A compact map embedded in a detail page represents the selected object,
// not a discovery search. Even if a parent retains stale search state,
// never paint the search-loupe marker over the object's own marker.
if (!widget.showSearchCenterMarker) return null;
if (_isEmbeddedDetailMap) return null;
final lat = widget.searchLatitude;
final lng = widget.searchLongitude;
return lat != null && lng != null ? LatLng(lat, lng) : null;
}
bool get _isEmbeddedDetailMap {
final selected = widget.initialSelectedItem;
if (selected == null) return false;
if (widget.compact) return true;
// Keep the invariant robust even if a detail caller forgets the compact
// flag: a single map item that is the selected detail is not a discovery
// result set and must never receive a second search-origin pin.
return widget.items.length == 1 && widget.items.single.id == selected.id;
}
/// A manually selected address is the active discovery context. Do not
/// expose the user's GPS position as a second marker while that context is
/// active; the address must remain the only search origin shown on the map.
bool get _showUserLocation =>
_searchCenter == null &&
widget.userLatitude != null &&
widget.userLongitude != null;
LatLng _priorityCenter(GeoConfig geo) {
final search = _searchCenter;
if (search != null) return search;
final detail = widget.initialSelectedItem;
if (_isEmbeddedDetailMap && detail != null && _isMappable(detail)) {
// A detail map is an object context, not a discovery viewport. Keep
// the selected object under the camera even when a personal GPS point
// is available; the personal marker is still rendered separately.
return LatLng(detail.geo.latitude!, detail.geo.longitude!);
}
if (widget.userLatitude != null && widget.userLongitude != null) {
return LatLng(widget.userLatitude!, widget.userLongitude!);
}
if (detail != null && _isMappable(detail)) {
return LatLng(detail.geo.latitude!, detail.geo.longitude!);
}
return LatLng(geo.defaultLatitude, geo.defaultLongitude);
}
double _initialZoom(GeoConfig geo) => MapPolicy.zoomForRadius(
widget.radiusKm,
fallback: geo.defaultMapZoom.toDouble(),
);
List<LatLng> _viewportPoints(GeoConfig geo) {
final detail = widget.initialSelectedItem;
if (_isEmbeddedDetailMap && detail != null && _isMappable(detail)) {
// Do not fit a detail card between the object and the user's location.
// The object remains the camera target; the GPS pin is an additional
// context marker and does not alter the detail viewport.
return [LatLng(detail.geo.latitude!, detail.geo.longitude!)];
}
final points = <LatLng>[
for (final item in widget.items)
if (_isMappable(item)) LatLng(item.geo.latitude!, item.geo.longitude!),
];
final search = _searchCenter;
if (search != null) points.add(search);
if (_showUserLocation) {
points.add(LatLng(widget.userLatitude!, widget.userLongitude!));
}
if (points.isEmpty) points.add(_priorityCenter(geo));
return points;
}
void _fitViewport({bool force = false}) {
if (!_mapReady || !mounted) return;
final geo = widget.geoConfig ?? GeoConfig.fallback();
final points = _viewportPoints(geo);
final signature = _viewportContextSignature(widget);
if (!force && signature == _lastViewportSignature) return;
_lastViewportSignature = signature;
if (points.length == 1) {
_controller.move(
points.single,
MapPolicy.zoomForRadius(
widget.radiusKm,
fallback: geo.defaultMapZoom.toDouble(),
).clamp(MapPolicy.minZoom, MapPolicy.maxZoom),
);
return;
}
_controller.fitCamera(
CameraFit.coordinates(
coordinates: points,
padding: const EdgeInsets.all(MapPolicy.fitPadding),
minZoom: MapPolicy.minZoom,
maxZoom: MapPolicy.maxZoom,
),
);
}
void _focusItems(List<DiscoveryItem> items) {
final points = [
for (final item in items)
if (_isMappable(item)) LatLng(item.geo.latitude!, item.geo.longitude!),
];
if (points.isNotEmpty) {
_controller.fitCamera(
CameraFit.coordinates(
coordinates: points,
padding: const EdgeInsets.all(MapPolicy.fitPadding),
minZoom: MapPolicy.minZoom,
maxZoom: MapPolicy.maxZoom,
),
);
}
}
void _selectItem(DiscoveryItem item) => setState(() => _selectedItem = item);
bool _isMappable(DiscoveryItem item) =>
item.geo.available &&
item.geo.privacy != 'private' &&
item.geo.latitude != null &&
item.geo.longitude != null;
bool _shouldRefitAfterUpdate(MapSurface oldValue, MapSurface newValue) {
if (!_sameViewportContext(oldValue, newValue)) return true;
if (oldValue.initialSelectedItem?.id != newValue.initialSelectedItem?.id) {
return true;
}
// Loading another page must not undo a user's pan or zoom. A replacement
// result set (new search/filter) still reframes the map because the old
// mappable IDs are no longer all present. An empty-to-populated result
// set also needs one initial fit.
final oldIds = oldValue.items
.where(_isMappable)
.map((item) => item.id)
.toSet();
final newIds = newValue.items
.where(_isMappable)
.map((item) => item.id)
.toSet();
if (oldIds.isEmpty) return newIds.isNotEmpty;
return !newIds.containsAll(oldIds);
}
bool _sameViewportContext(MapSurface oldValue, MapSurface newValue) =>
_viewportContextSignature(oldValue) ==
_viewportContextSignature(newValue);
String _viewportContextSignature(MapSurface value) {
final detail = value.initialSelectedItem;
final embeddedDetail = _isEmbeddedDetailMapFor(value);
final searchActive =
value.showSearchCenterMarker &&
!embeddedDetail &&
value.searchLatitude != null &&
value.searchLongitude != null;
final userActive =
!searchActive &&
!embeddedDetail &&
value.userLatitude != null &&
value.userLongitude != null;
final selectedPoint =
detail != null &&
detail.geo.available &&
detail.geo.privacy != 'private' &&
detail.geo.latitude != null &&
detail.geo.longitude != null
? '${detail.id}:${detail.geo.latitude}:${detail.geo.longitude}'
: '';
final geo = value.geoConfig ?? GeoConfig.fallback();
return [
embeddedDetail,
searchActive ? value.searchLatitude : null,
searchActive ? value.searchLongitude : null,
userActive ? value.userLatitude : null,
userActive ? value.userLongitude : null,
value.radiusKm,
selectedPoint,
geo.defaultLatitude,
geo.defaultLongitude,
geo.defaultMapZoom,
].join('|');
}
bool _isEmbeddedDetailMapFor(MapSurface value) {
final selected = value.initialSelectedItem;
if (selected == null) return false;
if (value.compact) return true;
return value.items.length == 1 && value.items.single.id == selected.id;
}
String _styleLabel(BuildContext context, String style) {
final l10n = AppLocalizations.of(context);
return switch (style) {
'light' => l10n.mapStyleLight,
'dark' => l10n.mapStyleDark,
'satellite' => l10n.mapStyleSatellite,
_ => l10n.mapStyleRoad,
};
}
}
class _MapCluster {
_MapCluster({
required this.items,
required this.latitude,
required this.longitude,
this.screenPoint = Offset.zero,
});
final List<DiscoveryItem> items;
double latitude;
double longitude;
Offset screenPoint;
}
class _MapControls extends StatelessWidget {
const _MapControls({
required this.controller,
required this.styles,
required this.selectedStyle,
required this.onStyleChanged,
required this.styleLabel,
required this.zoomInLabel,
required this.zoomOutLabel,
});
final MapController controller;
final Map<String, String> styles;
final String selectedStyle;
final ValueChanged<String> onStyleChanged;
final String Function(BuildContext, String) styleLabel;
final String zoomInLabel;
final String zoomOutLabel;
@override
Widget build(BuildContext context) {
final options = <String>{
'road',
...styles.keys.where((key) => key != 'road'),
}.toList();
return Card(
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => controller.move(
controller.camera.center,
controller.camera.zoom + 1,
),
tooltip: zoomInLabel,
icon: const Icon(Icons.add),
visualDensity: VisualDensity.compact,
),
IconButton(
onPressed: () => controller.move(
controller.camera.center,
controller.camera.zoom - 1,
),
tooltip: zoomOutLabel,
icon: const Icon(Icons.remove),
visualDensity: VisualDensity.compact,
),
if (options.length > 1)
PopupMenuButton<String>(
tooltip: styleLabel(context, selectedStyle),
icon: const Icon(Icons.layers_outlined),
onSelected: onStyleChanged,
itemBuilder: (context) => [
for (final option in options)
PopupMenuItem(
value: option,
child: Text(styleLabel(context, option)),
),
],
),
],
),
);
}
}
class _MapStatus extends StatelessWidget {
const _MapStatus({
required this.text,
required this.error,
this.retryLabel,
this.onRetry,
});
final String text;
final bool error;
final String? retryLabel;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
margin: EdgeInsets.zero,
color: error
? scheme.errorContainer
: scheme.surface.withValues(alpha: .94),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.map_outlined,
size: 18,
color: error
? scheme.onErrorContainer
: AppTheme.palette(context).muted,
),
const SizedBox(width: 8),
Flexible(
child: Text(text, maxLines: 2, overflow: TextOverflow.ellipsis),
),
if (onRetry != null) ...[
const SizedBox(width: 4),
TextButton(
onPressed: onRetry,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(retryLabel ?? ''),
),
],
],
),
),
);
}
}
class _GoogleMapsFallbackCard extends StatelessWidget {
const _GoogleMapsFallbackCard({
required this.text,
required this.actionLabel,
required this.onOpen,
required this.compact,
});
final String text;
final String actionLabel;
final VoidCallback onOpen;
final bool compact;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
final iconSize = compact ? 26.0 : 34.0;
final spacing = compact ? 6.0 : 10.0;
return Card(
margin: EdgeInsets.all(compact ? 12 : 20),
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
compact ? 12 : 18,
16,
compact ? 10 : 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.map_outlined, size: iconSize, color: palette.brand),
SizedBox(height: spacing),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(text, textAlign: TextAlign.center),
),
SizedBox(height: compact ? 8 : 12),
FilledButton.icon(
onPressed: onOpen,
style: compact
? FilledButton.styleFrom(
minimumSize: const Size(0, 36),
padding: const EdgeInsets.symmetric(horizontal: 12),
)
: null,
icon: const Icon(Icons.open_in_new),
label: Text(actionLabel),
),
],
),
),
);
}
}
class _MapMessage extends StatelessWidget {
const _MapMessage({required this.text, required this.icon});
final String text;
final IconData icon;
@override
Widget build(BuildContext context) => Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18),
const SizedBox(width: 8),
Flexible(child: Text(text)),
],
),
),
);
}
class _ItemPin extends StatelessWidget {
const _ItemPin({required this.item, required this.onTap});
final DiscoveryItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final marker = item.marker;
final child = PublicMediaImage(
url: marker.imageUrl,
alt: marker.alt,
width: 34,
height: 42,
fit: BoxFit.contain,
emptyIcon: Icons.location_on,
errorIcon: Icons.location_on,
iconSize: 36,
iconColor: AppTheme.palette(context).brand,
backgroundColor: Colors.transparent,
borderColor: Colors.transparent,
includeSemantics: false,
);
return Semantics(
button: true,
label: AppLocalizations.of(context).markerLabel(item.title),
child: InkResponse(onTap: onTap, child: child),
);
}
}
class _ClusterPin extends StatelessWidget {
const _ClusterPin({required this.count, required this.onTap});
final int count;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Semantics(
button: true,
label: AppLocalizations.of(context).mapClusterLabel(count),
child: InkResponse(
onTap: onTap,
child: Container(
width: 46,
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
),
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
class _SearchCenterPin extends StatelessWidget {
const _SearchCenterPin({required this.label});
final String label;
@override
Widget build(BuildContext context) => Semantics(
label: label,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
width: 3,
),
),
child: Icon(
Icons.search,
size: 20,
color: Theme.of(context).colorScheme.secondary,
),
),
);
}
class _UserPin extends StatelessWidget {
const _UserPin({required this.label, this.imageUrl = ''});
final String label;
final String imageUrl;
@override
Widget build(BuildContext context) => Semantics(
label: label,
child: SizedBox(
width: 38,
height: 44,
child: Center(
child: imageUrl.isNotEmpty
? PublicMediaImage(
url: imageUrl,
alt: label,
width: 38,
height: 44,
fit: BoxFit.contain,
includeSemantics: false,
fallbackBuilder: (_) => _bundledImage(context, label),
)
: Image.asset(
_bundledUserMarkerAsset,
width: 38,
height: 44,
fit: BoxFit.contain,
errorBuilder: (context, _, _) => _defaultDot(context),
),
),
),
);
Widget _defaultDot(BuildContext context) => DecoratedBox(
decoration: BoxDecoration(
color: AppTheme.palette(context).brand,
shape: BoxShape.circle,
),
child: const SizedBox(width: 10, height: 10),
);
Widget _bundledImage(BuildContext context, String label) => Image.asset(
_bundledUserMarkerAsset,
width: 38,
height: 44,
fit: BoxFit.contain,
semanticLabel: label,
errorBuilder: (context, _, _) => _defaultDot(context),
);
}
class _MapPreviewCard extends StatelessWidget {
const _MapPreviewCard({
required this.item,
required this.onClose,
required this.onOpen,
});
final DiscoveryItem item;
final VoidCallback onClose;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
margin: EdgeInsets.zero,
elevation: 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 8, 10),
child: Row(
children: [
Icon(Icons.place_outlined, color: AppTheme.palette(context).brand),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700),
),
if (item.address.formatted.isNotEmpty)
Text(
item.address.formatted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
),
),
],
),
),
TextButton(onPressed: onOpen, child: Text(l10n.openDetails)),
IconButton(
onPressed: onClose,
tooltip: l10n.close,
icon: const Icon(Icons.close),
),
],
),
),
);
}
}
