// 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 points = widget.activity.validPoints; final initialCenter = widget.activity.boundsCenter ?? widget.activity.center; return Scaffold( appBar: AppBar( title: Text(widget.activity.name), ), body: Column( children: [ _ActivitySummaryHeader(activity: widget.activity), Expanded( // Importante: uso LayoutBuilder dentro l'Expanded, cosi calcolo lo // zoom sulla dimensione reale della mappa e non su tutto lo schermo. // Prima usavamo MediaQuery.sizeOf(context), che include AppBar e // header: su alcune tracce verticali calcolava uno zoom troppo alto. child: LayoutBuilder( builder: (context, constraints) { final mapSize = Size( math.max(1, constraints.maxWidth), math.max(1, constraints.maxHeight), ); final initialZoom = _initialZoomForActivity( widget.activity, mapSize, fallbackZoom: settings.infoMapZoom, ); return MapTheme( interactive: true, showCoordinateFilter: false, navigationButton: MapNavigationButton.back, child: GeoMap( controller: _mapController, entries: const [], availableSize: mapSize, initialCenter: initialCenter, initialZoom: points.length <= 1 ? settings.infoMapZoom : initialZoom, isAnimatingNotifier: _isAnimatingNotifier, tracks: widget.activity.tracks, ), ); }, ), ), ], ), ); } static double _initialZoomForActivity( ActivityTrack activity, Size mapSize, { 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; // Padding reale in pixel. E' piu prevedibile di sottrarre valori fissi // all'altezza dello schermo, soprattutto con header/AppBar diversi. const paddingPixels = 72.0; final width = math.max(1.0, mapSize.width - paddingPixels * 2); final height = math.max(1.0, mapSize.height - paddingPixels * 2); const tileSize = 256.0; const maxZoom = 18.0; const minZoom = 1.0; final lonFraction = math.max(lonSpan / 360.0, 0.0000001); final zoomLon = math.log(width / (tileSize * lonFraction)) / math.ln2; final yNorth = _mercatorYFraction(north); final ySouth = _mercatorYFraction(south); final latFraction = math.max((ySouth - yNorth).abs(), 0.0000001); final zoomLat = math.log(height / (tileSize * latFraction)) / math.ln2; // Uso il piu basso dei due zoom, cosi entrano sia larghezza sia altezza. // Apro ancora un po' lo zoom per compensare padding interno/controlli mappa // e piccole differenze tra proiezione stimata e rendering effettivo. final rawZoom = math.min(zoomLon, zoomLat); final paddedZoom = rawZoom - 0.75; if (!paddedZoom.isFinite) return fallbackZoom; // Non apro mai piu zoomato del valore impostato dall'utente. final userMaxZoom = fallbackZoom.isFinite ? fallbackZoom : maxZoom; final maxAllowedZoom = math.min(maxZoom, userMaxZoom); return paddedZoom.clamp(minZoom, maxAllowedZoom).toDouble(); } /// Coordinate Web Mercator normalizzate 0..1. static double _mercatorYFraction(double latitude) { final clampedLat = latitude.clamp(-85.05112878, 85.05112878).toDouble(); final latRad = clampedLat * math.pi / 180.0; final mercator = math.log(math.tan(math.pi / 4.0 + latRad / 2.0)); return (1.0 - mercator / math.pi) / 2.0; } } 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'; } }