189 lines
5.9 KiB
Text
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';
|
|
}
|
|
}
|