aves_mio0.31/lib/widgets/activity/activity_map_page.dart
2026-07-18 13:39:22 +02:00

202 lines
6.7 KiB
Dart

// 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<ActivityMapPage> createState() => _ActivityMapPageState();
}
class _ActivityMapPageState extends State<ActivityMapPage> {
final AvesMapController _mapController = AvesMapController();
final ValueNotifier<bool> _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 <AvesEntry>[],
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';
}
}