// lib/widgets/activity/activity_map_page.dart import 'dart:math' as math; import 'package:aves/model/activity/activity_track.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/providers/map_theme_provider.dart'; import 'package:aves_map/aves_map.dart'; import 'package:flutter/material.dart'; class ActivityMapPage extends StatefulWidget { static const routeName = '/activity/map'; final ActivityTrack activity; const ActivityMapPage({ super.key, required this.activity, }); @override State createState() => _ActivityMapPageState(); } class _ActivityMapPageState extends State { final AvesMapController _mapController = AvesMapController(); final ValueNotifier _isAnimatingNotifier = ValueNotifier(false); @override void dispose() { _mapController.dispose(); _isAnimatingNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final availableSize = MediaQuery.sizeOf(context); final points = widget.activity.validPoints; // Per aprire la mappa sull'intera traccia uso il centro del bounding box, // non la media dei punti. La media puo essere sbilanciata se ci sono pause // o molti punti concentrati in una zona. final initialCenter = widget.activity.boundsCenter ?? widget.activity.center; final initialZoom = _initialZoomForActivity( widget.activity, availableSize, fallbackZoom: settings.infoMapZoom, ); return Scaffold( appBar: AppBar( title: Text(widget.activity.name), ), body: Column( children: [ _ActivitySummaryHeader(activity: widget.activity), Expanded( child: MapTheme( interactive: true, showCoordinateFilter: false, navigationButton: MapNavigationButton.back, child: GeoMap( controller: _mapController, entries: const [], availableSize: availableSize, initialCenter: initialCenter, initialZoom: points.length <= 1 ? settings.infoMapZoom : initialZoom, isAnimatingNotifier: _isAnimatingNotifier, tracks: widget.activity.tracks, ), ), ), ], ), ); } static double _initialZoomForActivity( ActivityTrack activity, Size availableSize, { required double fallbackZoom, }) { final points = activity.validPoints; if (points.length < 2) return fallbackZoom; final south = activity.minLatitude; final north = activity.maxLatitude; final west = activity.minLongitude; final east = activity.maxLongitude; if (south == null || north == null || west == null || east == null) { return fallbackZoom; } final latSpan = (north - south).abs(); final lonSpan = (east - west).abs(); if (latSpan == 0 && lonSpan == 0) return fallbackZoom; // Spazio realmente disponibile alla mappa. Tolgo un margine per header, // appbar e padding visivo, cosi la traccia non finisce sui bordi. final width = math.max(1.0, availableSize.width - 64.0); final height = math.max(1.0, availableSize.height - 220.0); const tileSize = 256.0; const maxZoom = 18.0; const minZoom = 1.0; // Padding extra: zoom leggermente piu aperto del minimo teorico. const paddingFactor = 1.30; final paddedLonSpan = math.max(lonSpan * paddingFactor, 0.0001); final zoomLon = math.log(width * 360.0 / (tileSize * paddedLonSpan)) / math.ln2; final mercatorNorth = _mercatorY(north); final mercatorSouth = _mercatorY(south); final mercatorSpan = (mercatorNorth - mercatorSouth).abs(); final paddedMercatorSpan = math.max(mercatorSpan * paddingFactor, 0.0001); final zoomLat = math.log(height * 2.0 / (tileSize * paddedMercatorSpan)) / math.ln2; final zoom = math.min(zoomLon, zoomLat); if (!zoom.isFinite) return fallbackZoom; // Non apro mai piu zoomato del valore impostato dall'utente, cosi il fit // all'intera traccia resta prudente. return zoom.clamp(minZoom, math.min(maxZoom, fallbackZoom)).toDouble(); } static double _mercatorY(double latitude) { final clampedLat = latitude.clamp(-85.05112878, 85.05112878).toDouble(); final sinLat = math.sin(clampedLat * math.pi / 180.0); return math.log((1 + sinLat) / (1 - sinLat)) / (2 * math.pi); } } class _ActivitySummaryHeader extends StatelessWidget { final ActivityTrack activity; const _ActivitySummaryHeader({ required this.activity, }); @override Widget build(BuildContext context) { final distance = activity.distanceMeters; final duration = activity.duration; final elevationGain = activity.elevationGainMeters; return Material( color: Theme.of(context).colorScheme.surface, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Wrap( spacing: 16, runSpacing: 8, children: [ Text('Formato: ${activity.format}'), Text('${activity.pointCount} punti'), if (activity.sport != null) Text('Sport: ${activity.sport}'), if (distance != null) Text('Distanza: ${(distance / 1000).toStringAsFixed(2)} km'), if (elevationGain != null) Text('D+: ${elevationGain.round()} m'), if (duration != null) Text('Durata: ${_formatDuration(duration)}'), ], ), ), ); } static String _formatDuration(Duration duration) { final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60); final seconds = duration.inSeconds.remainder(60); if (hours > 0) { return '${hours}h ${minutes}m ${seconds}s'; } if (minutes > 0) { return '${minutes}m ${seconds}s'; } return '${seconds}s'; } }