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

189 lines
5.9 KiB
Text

// 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 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 <AvesEntry>[],
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';
}
}