202 lines
6.7 KiB
Dart
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';
|
|
}
|
|
}
|