#1397 edit location via GPX

This commit is contained in:
Thibault Deckers 2025-01-27 22:22:12 +01:00
parent a99f4877ce
commit 0301269171
29 changed files with 649 additions and 168 deletions

View file

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
### Added
- edit location via GPX
### Changed
- upgraded Flutter to stable v3.27.3

View file

@ -7,6 +7,7 @@ enum AppMode {
pickFilteredMediaInternal,
pickUnfilteredMediaInternal,
pickFilterInternal,
previewMap,
screenSaver,
setWallpaper,
slideshow,

View file

@ -508,8 +508,10 @@
"editEntryLocationDialogTitle": "Location",
"editEntryLocationDialogSetCustom": "Set custom location",
"editEntryLocationDialogChooseOnMap": "Choose on map",
"editEntryLocationDialogImportGpx": "Import GPX",
"editEntryLocationDialogLatitude": "Latitude",
"editEntryLocationDialogLongitude": "Longitude",
"editEntryLocationDialogTimeShift": "Time shift",
"locationPickerUseThisLocationButton": "Use this location",

View file

@ -333,6 +333,11 @@ class Dependencies {
license: mit,
sourceUrl: 'https://github.com/fluttercommunity/get_it',
),
Dependency(
name: 'GPX',
license: apache2,
sourceUrl: 'https://github.com/kb0/dart-gpx',
),
Dependency(
name: 'HTTP',
license: bsd3,

View file

@ -11,11 +11,18 @@ String formatDateTime(DateTime date, String locale, bool use24hour) => [
].join(AText.separator);
String formatFriendlyDuration(Duration d) {
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
if (d.inHours == 0) return '${d.inMinutes}:$seconds';
final isNegative = d.isNegative;
final sign = isNegative ? '-' : '';
d = d.abs();
final hours = d.inHours;
d -= Duration(hours: hours);
final minutes = d.inMinutes;
d -= Duration(minutes: minutes);
final seconds = d.inSeconds;
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
return '${d.inHours}:$minutes:$seconds';
if (hours == 0) return '$sign$minutes:${seconds.toString().padLeft(2, '0')}';
return '$sign$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
String formatPreciseDuration(Duration d) {

View file

@ -9,6 +9,7 @@ extension ExtraLocationEditActionView on LocationEditAction {
LocationEditAction.chooseOnMap => l10n.editEntryLocationDialogChooseOnMap,
LocationEditAction.copyItem => l10n.editEntryDialogCopyFromItem,
LocationEditAction.setCustom => l10n.editEntryLocationDialogSetCustom,
LocationEditAction.importGpx => l10n.editEntryLocationDialogImportGpx,
LocationEditAction.remove => l10n.actionRemove,
};
}

View file

@ -563,10 +563,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (entries == null || entries.isEmpty) return;
final collection = context.read<CollectionLens>();
final location = await selectLocation(context, entries, collection);
if (location == null) return;
final locationByEntry = await selectLocation(context, entries, collection);
if (locationByEntry == null) return;
await _edit(context, entries, (entry) => entry.editLocation(location));
await _edit(context, locationByEntry.keys.toSet(), (entry) => entry.editLocation(locationByEntry[entry]));
}
Future<LatLng?> editLocationByMap(BuildContext context, Set<AvesEntry> entries, LatLng clusterLocation, CollectionLens mapCollection) async {

View file

@ -1,9 +1,9 @@
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/metadata_edition.dart';
import 'package:aves/model/entry/extensions/multipage.dart';
import 'package:aves/model/filters/covered/tag.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/placeholder.dart';
import 'package:aves/model/filters/covered/tag.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/mime_types.dart';
@ -17,9 +17,7 @@ import 'package:aves/widgets/dialogs/entry_editors/edit_rating_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/remove_metadata_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/tag_editor_page.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
mixin EntryEditorMixin {
Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
@ -35,15 +33,13 @@ mixin EntryEditorMixin {
);
}
Future<LatLng?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
Future<LocationEditActionResult?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
if (entries.isEmpty) return null;
final entry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first;
return showDialog<LatLng>(
return showDialog<LocationEditActionResult>(
context: context,
builder: (context) => EditEntryLocationDialog(
entry: entry,
entries: entries,
collection: collection,
),
routeSettings: const RouteSettings(name: EditEntryLocationDialog.routeName),

View file

@ -0,0 +1,152 @@
import 'package:aves/ref/locales.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/basic/wheel.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class TimeShiftSelector extends StatefulWidget {
final TimeShiftController controller;
const TimeShiftSelector({
super.key,
required this.controller,
});
@override
State<TimeShiftSelector> createState() => _TimeShiftSelectorState();
}
class _TimeShiftSelectorState extends State<TimeShiftSelector> {
late ValueNotifier<int> _shiftHour, _shiftMinute, _shiftSecond;
late ValueNotifier<String> _shiftSign;
static const _positiveSign = '+';
static const _negativeSign = '-';
@override
void initState() {
super.initState();
var initialValue = widget.controller.initialValue;
final sign = initialValue.isNegative ? _negativeSign : _positiveSign;
initialValue = initialValue.abs();
final hours = initialValue.inHours;
initialValue -= Duration(hours: hours);
final minutes = initialValue.inMinutes;
initialValue -= Duration(minutes: minutes);
final seconds = initialValue.inSeconds;
_shiftSign = ValueNotifier(sign);
_shiftHour = ValueNotifier(hours);
_shiftMinute = ValueNotifier(minutes);
_shiftSecond = ValueNotifier(seconds);
_shiftSign.addListener(_updateValue);
_shiftHour.addListener(_updateValue);
_shiftMinute.addListener(_updateValue);
_shiftSecond.addListener(_updateValue);
}
@override
void dispose() {
_shiftSign.dispose();
_shiftHour.dispose();
_shiftMinute.dispose();
_shiftSecond.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final timeComponentFormatter = NumberFormat('0', context.locale);
const textStyle = TextStyle(fontSize: 34);
const digitsAlign = TextAlign.right;
return Center(
child: Table(
textDirection: timeComponentsDirection,
children: [
TableRow(
children: [
const SizedBox(),
Center(child: Text(l10n.durationDialogHours)),
const SizedBox(width: 16),
Center(child: Text(l10n.durationDialogMinutes)),
const SizedBox(width: 16),
Center(child: Text(l10n.durationDialogSeconds)),
],
),
TableRow(
children: [
WheelSelector(
valueNotifier: _shiftSign,
values: const [_positiveSign, _negativeSign],
textStyle: textStyle,
textAlign: TextAlign.center,
format: (v) => v,
),
Align(
alignment: Alignment.centerRight,
child: WheelSelector(
valueNotifier: _shiftHour,
values: List.generate(hoursInDay, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
const Text(
':',
style: textStyle,
),
Align(
alignment: Alignment.centerLeft,
child: WheelSelector(
valueNotifier: _shiftMinute,
values: List.generate(minutesInHour, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
const Text(
':',
style: textStyle,
),
Align(
alignment: Alignment.centerLeft,
child: WheelSelector(
valueNotifier: _shiftSecond,
values: List.generate(secondsInMinute, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
],
)
],
defaultColumnWidth: const IntrinsicColumnWidth(),
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
),
);
}
void _updateValue() {
final sign = _shiftSign.value == _positiveSign ? 1 : -1;
final hours = _shiftHour.value;
final minutes = _shiftMinute.value;
final seconds = _shiftSecond.value;
widget.controller.value = Duration(hours: hours, minutes: minutes, seconds: seconds) * sign;
}
}
class TimeShiftController {
final Duration initialValue;
Duration value;
TimeShiftController({required this.initialValue}) : value = initialValue;
}

View file

@ -42,6 +42,7 @@ class GeoMap extends StatefulWidget {
final ValueNotifier<LatLng?>? dotLocationNotifier;
final ValueNotifier<double>? overlayOpacityNotifier;
final MapOverlay? overlayEntry;
final Set<List<LatLng>>? tracks;
final UserZoomChangeCallback? onUserZoomChange;
final MapTapCallback? onMapTap;
final void Function(
@ -69,6 +70,7 @@ class GeoMap extends StatefulWidget {
this.dotLocationNotifier,
this.overlayOpacityNotifier,
this.overlayEntry,
this.tracks,
this.onUserZoomChange,
this.onMapTap,
this.onMarkerTap,
@ -179,6 +181,7 @@ class _GeoMapState extends State<GeoMap> {
dotLocationNotifier: widget.dotLocationNotifier,
overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
tracks: widget.tracks,
onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,
@ -210,6 +213,7 @@ class _GeoMapState extends State<GeoMap> {
),
overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
tracks: widget.tracks,
onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,

View file

@ -30,6 +30,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
final Size markerSize, dotMarkerSize;
final ValueNotifier<double>? overlayOpacityNotifier;
final MapOverlay? overlayEntry;
final Set<List<LatLng>>? tracks;
final UserZoomChangeCallback? onUserZoomChange;
final MapTapCallback? onMapTap;
final MarkerTapCallback<T>? onMarkerTap;
@ -52,6 +53,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
required this.dotMarkerSize,
this.overlayOpacityNotifier,
this.overlayEntry,
this.tracks,
this.onUserZoomChange,
this.onMapTap,
this.onMarkerTap,
@ -175,6 +177,7 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
children: [
_buildMapLayer(),
if (widget.overlayEntry != null) _buildOverlayImageLayer(),
if (widget.tracks != null) _buildTracksLayer(),
MarkerLayer(
markers: markers,
rotate: true,
@ -243,6 +246,22 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
);
}
Widget _buildTracksLayer() {
final tracks = widget.tracks;
if (tracks == null) return const SizedBox();
final trackColor = Theme.of(context).colorScheme.primary;
return PolylineLayer(
polylines: tracks
.map((v) => Polyline(
points: v,
strokeWidth: MapThemeData.trackWidth.toDouble(),
color: trackColor,
))
.toList(),
);
}
void _onBoundsChanged() => _debouncer(_onIdle);
void _onIdle() {

View file

@ -1,24 +1,21 @@
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/locales.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/view/view.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/basic/wheel.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/transitions.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/common/basic/time_shift_selector.dart';
import 'package:aves/widgets/dialogs/item_picker.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
import 'package:aves_model/aves_model.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class EditEntryDateDialog extends StatefulWidget {
@ -42,17 +39,13 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate;
late AvesEntry _copyItemSource;
late DateTime _customDateTime;
late ValueNotifier<int> _shiftHour, _shiftMinute, _shiftSecond;
late ValueNotifier<String> _shiftSign;
late TimeShiftController _timeShiftController;
bool _showOptions = false;
final Set<MetadataField> _fields = {...DateModifier.writableFields};
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now();
static const _positiveSign = '+';
static const _negativeSign = '-';
@override
void initState() {
super.initState();
@ -65,10 +58,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
@override
void dispose() {
_isValidNotifier.dispose();
_shiftHour.dispose();
_shiftMinute.dispose();
_shiftSecond.dispose();
_shiftSign.dispose();
super.dispose();
}
@ -81,10 +70,9 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
}
void _initShift() {
_shiftHour = ValueNotifier(1);
_shiftMinute = ValueNotifier(0);
_shiftSecond = ValueNotifier(0);
_shiftSign = ValueNotifier(_positiveSign);
_timeShiftController = TimeShiftController(
initialValue: const Duration(hours: 1),
);
}
@override
@ -203,80 +191,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
}
Widget _buildShiftContent(BuildContext context) {
final l10n = context.l10n;
final timeComponentFormatter = NumberFormat('0', context.locale);
const textStyle = TextStyle(fontSize: 34);
const digitsAlign = TextAlign.right;
return Center(
child: Table(
textDirection: timeComponentsDirection,
children: [
TableRow(
children: [
const SizedBox(),
Center(child: Text(l10n.durationDialogHours)),
const SizedBox(width: 16),
Center(child: Text(l10n.durationDialogMinutes)),
const SizedBox(width: 16),
Center(child: Text(l10n.durationDialogSeconds)),
],
),
TableRow(
children: [
WheelSelector(
valueNotifier: _shiftSign,
values: const [_positiveSign, _negativeSign],
textStyle: textStyle,
textAlign: TextAlign.center,
format: (v) => v,
),
Align(
alignment: Alignment.centerRight,
child: WheelSelector(
valueNotifier: _shiftHour,
values: List.generate(hoursInDay, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
const Text(
':',
style: textStyle,
),
Align(
alignment: Alignment.centerLeft,
child: WheelSelector(
valueNotifier: _shiftMinute,
values: List.generate(minutesInHour, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
const Text(
':',
style: textStyle,
),
Align(
alignment: Alignment.centerLeft,
child: WheelSelector(
valueNotifier: _shiftSecond,
values: List.generate(secondsInMinute, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
],
)
],
defaultColumnWidth: const IntrinsicColumnWidth(),
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
),
);
return TimeShiftSelector(controller: _timeShiftController);
}
Widget _buildDestinationFields(BuildContext context) {
@ -368,7 +283,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
fullscreenDialog: true,
),
);
pickCollection.dispose();
if (entry != null) {
setState(() => _copyItemSource = entry);
}
@ -388,8 +302,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
case DateEditAction.extractFromTitle:
return DateModifier.extractFromTitle();
case DateEditAction.shift:
final shiftTotalSeconds = ((_shiftHour.value * minutesInHour + _shiftMinute.value) * secondsInMinute + _shiftSecond.value) * (_shiftSign.value == _positiveSign ? 1 : -1);
return DateModifier.shift(_fields, shiftTotalSeconds);
return DateModifier.shift(_fields, _timeShiftController.value.inSeconds);
case DateEditAction.remove:
return DateModifier.remove(_fields);
}

View file

@ -1,29 +1,40 @@
import 'dart:async';
import 'dart:convert';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/location.dart';
import 'package:aves/model/entry/extensions/metadata_edition.dart';
import 'package:aves/model/entry/sort.dart';
import 'package:aves/model/filters/covered/location.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/settings/enums/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/poi.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/view/view.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/transitions.dart';
import 'package:aves/widgets/common/identity/aves_caption.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_picker.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart';
import 'package:aves/widgets/dialogs/time_shift_dialog.dart';
import 'package:aves/widgets/map/map_page.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:gpx/gpx.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
@ -31,12 +42,12 @@ import 'package:provider/provider.dart';
class EditEntryLocationDialog extends StatefulWidget {
static const routeName = '/dialog/edit_entry_location';
final AvesEntry entry;
final Set<AvesEntry> entries;
final CollectionLens? collection;
const EditEntryLocationDialog({
super.key,
required this.entry,
required this.entries,
this.collection,
});
@ -44,19 +55,26 @@ class EditEntryLocationDialog extends StatefulWidget {
State<EditEntryLocationDialog> createState() => _EditEntryLocationDialogState();
}
class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> with FeedbackMixin {
final List<StreamSubscription> _subscriptions = [];
LocationEditAction _action = LocationEditAction.chooseOnMap;
LatLng? _mapCoordinates;
late final AvesEntry mainEntry;
late AvesEntry _copyItemSource;
Gpx? _gpx;
Duration _gpxShift = Duration.zero;
final Map<AvesEntry, LatLng> _gpxMap = {};
final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
NumberFormat get coordinateFormatter => NumberFormat('0.000000', context.locale);
static const _minTimeToGpxPoint = Duration(hours: 1);
@override
void initState() {
super.initState();
final entries = widget.entries;
mainEntry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first;
_initMapCoordinates();
_initCopyItem();
_initCustom();
@ -64,16 +82,16 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
}
void _initMapCoordinates() {
_mapCoordinates = widget.entry.latLng;
_mapCoordinates = mainEntry.latLng;
}
void _initCopyItem() {
_copyItemSource = widget.entry;
_copyItemSource = mainEntry;
}
void _initCustom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final latLng = widget.entry.latLng;
final latLng = mainEntry.latLng;
if (latLng != null) {
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
@ -128,14 +146,9 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: Column(
child: KeyedSubtree(
key: ValueKey(_action),
mainAxisSize: MainAxisSize.min,
children: [
if (_action == LocationEditAction.chooseOnMap) _buildChooseOnMapContent(context),
if (_action == LocationEditAction.copyItem) _buildCopyItemContent(context),
if (_action == LocationEditAction.setCustom) _buildSetCustomContent(context),
],
child: _buildContent(),
),
),
const SizedBox(height: 8),
@ -158,12 +171,27 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
);
}
Widget _buildContent() {
switch (_action) {
case LocationEditAction.chooseOnMap:
return _buildChooseOnMapContent(context);
case LocationEditAction.copyItem:
return _buildCopyItemContent(context);
case LocationEditAction.setCustom:
return _buildSetCustomContent(context);
case LocationEditAction.importGpx:
return _buildImportGpxContent(context);
case LocationEditAction.remove:
return const SizedBox();
}
}
Widget _buildChooseOnMapContent(BuildContext context) {
return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
child: Row(
children: [
Expanded(child: _toText(context, _mapCoordinates)),
Expanded(child: _coordinatesText(context, _mapCoordinates)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(AIcons.map),
@ -179,8 +207,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
_action = LocationEditAction.setCustom;
_validate();
setState(() {});
setState(_validate);
}
CollectionLens? _createPickCollection() {
@ -208,7 +235,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
fullscreenDialog: true,
),
);
pickCollection?.dispose();
if (latLng != null) {
settings.mapDefaultCenter = latLng;
setState(() {
@ -223,7 +249,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
child: Row(
children: [
Expanded(child: _toText(context, _copyItemSource.latLng)),
Expanded(child: _coordinatesText(context, _copyItemSource.latLng)),
const SizedBox(width: 8),
ItemPicker(
extent: 48,
@ -249,7 +275,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
fullscreenDialog: true,
),
);
pickCollection.dispose();
if (entry != null) {
setState(() {
_copyItemSource = entry;
@ -293,13 +318,207 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
);
}
Text _toText(BuildContext context, LatLng? latLng) {
Widget _buildImportGpxContent(BuildContext context) {
final l10n = context.l10n;
if (latLng != null) {
return Text(
ExtraCoordinateFormat.toDMS(l10n, latLng).join('\n'),
return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: _gpxDateRangeText(context, _gpx)),
const SizedBox(width: 8),
IconButton(
icon: Icon(AIcons.fileImport),
onPressed: _pickGpx,
tooltip: l10n.pickTooltip,
),
],
),
if (_gpx != null) ...[
Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.editEntryLocationDialogTimeShift),
AvesCaption(_formatShiftDuration(_gpxShift)),
],
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(AIcons.edit),
onPressed: _pickGpxShift,
tooltip: l10n.changeTooltip,
),
],
),
Row(
children: [
Expanded(child: Text(l10n.statsWithGps(_gpxMap.length))),
const SizedBox(width: 8),
IconButton(
icon: const Icon(AIcons.map),
onPressed: _previewGpx,
tooltip: l10n.openMapPageTooltip,
),
],
),
],
],
),
);
}
Future<void> _pickGpx() async {
final bytes = await storageService.openFile();
if (bytes.isNotEmpty) {
try {
final allXmlString = utf8.decode(bytes);
final gpx = GpxReader().fromString(allXmlString);
_gpx = gpx;
_gpxShift = Duration.zero;
_updateGpxMapping();
showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback);
} catch (error, stack) {
debugPrint('failed to import GPX, error=$error\n$stack');
showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback);
}
}
}
Future<void> _pickGpxShift() async {
final newShift = await showDialog<Duration>(
context: context,
builder: (context) => TimeShiftDialog(
initialValue: _gpxShift,
),
routeSettings: const RouteSettings(name: TimeShiftDialog.routeName),
);
if (newShift == null) return;
_gpxShift = newShift;
_updateGpxMapping();
}
String _formatShiftDuration(Duration duration) {
final sign = duration.isNegative ? '-' : '+';
duration = duration.abs();
final hours = duration.inHours;
duration -= Duration(hours: hours);
final minutes = duration.inMinutes;
duration -= Duration(minutes: minutes);
final seconds = duration.inSeconds;
return '$sign$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
void _updateGpxMapping() {
_gpxMap.clear();
final gpx = _gpx;
if (gpx == null) return;
final Map<AvesEntry, Wpt> wptByEntry = {};
// dated items and points, oldest first
final sortedEntries = widget.entries.where((v) => v.bestDate != null).sorted(AvesEntrySort.compareByDate).reversed.toList();
final sortedPoints = gpx.trks.expand((trk) => trk.trksegs).expand((trkSeg) => trkSeg.trkpts).where((v) => v.time != null).sortedBy((v) => v.time!);
if (sortedEntries.isNotEmpty && sortedPoints.isNotEmpty) {
int entryIndex = 0;
int pointIndex = 0;
final int maxDurationSecs = const Duration(days: 365).inSeconds;
int smallestDifferenceSecs = maxDurationSecs;
while (entryIndex < sortedEntries.length && pointIndex < sortedPoints.length) {
final entry = sortedEntries[entryIndex];
final point = sortedPoints[pointIndex];
final entryDate = entry.bestDate!;
final pointTime = point.time!.add(_gpxShift);
final differenceSecs = entryDate.difference(pointTime).inSeconds.abs();
if (differenceSecs < smallestDifferenceSecs) {
smallestDifferenceSecs = differenceSecs;
wptByEntry[entry] = point;
pointIndex++;
} else {
smallestDifferenceSecs = maxDurationSecs;
entryIndex++;
}
}
}
_gpxMap.addEntries(wptByEntry.entries.map((kv) {
final entry = kv.key;
final wpt = kv.value;
final timeToPoint = entry.bestDate!.difference(wpt.time!.add(_gpxShift)).abs();
if (timeToPoint < _minTimeToGpxPoint) {
final lat = wpt.lat;
final lon = wpt.lon;
if (lat != null && lon != null) {
return MapEntry(entry, LatLng(lat, lon));
}
}
return null;
}).nonNulls);
setState(_validate);
}
Future<void> _previewGpx() async {
final source = widget.collection?.source;
if (source == null) return;
final previewEntries = _gpxMap.entries.map((kv) {
final entry = kv.key.copyWith();
final latLng = kv.value;
final catalogMetadata = entry.catalogMetadata?.copyWith() ?? CatalogMetadata(id: entry.id);
catalogMetadata.latitude = latLng.latitude;
catalogMetadata.longitude = latLng.longitude;
entry.catalogMetadata = catalogMetadata;
return entry;
}).toList();
final mapCollection = CollectionLens(
source: source,
listenToSource: false,
fixedSelection: previewEntries,
);
final tracks = _gpx?.trks
.expand((trk) => trk.trksegs)
.map((trkSeg) => trkSeg.trkpts
.map((wpt) {
final lat = wpt.lat;
final lon = wpt.lon;
return (lat != null && lon != null) ? LatLng(lat, lon) : null;
})
.nonNulls
.toList())
.toSet();
await Navigator.maybeOf(context)?.push(
MaterialPageRoute(
settings: const RouteSettings(name: LocationPickPage.routeName),
builder: (context) {
return ListenableProvider<ValueNotifier<AppMode>>.value(
value: ValueNotifier(AppMode.previewMap),
child: MapPage(
collection: mapCollection,
tracks: tracks,
),
);
},
fullscreenDialog: true,
),
);
}
Text _unknownText(BuildContext context) {
final l10n = context.l10n;
return Text(
l10n.viewerInfoUnknown,
style: TextStyle(
@ -307,6 +526,39 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
),
);
}
(DateTime, DateTime)? _gpxDateRange(Gpx? gpx) {
final firstDate = gpx?.trks.firstOrNull?.trksegs.firstOrNull?.trkpts.firstOrNull?.time;
final lastDate = gpx?.trks.lastOrNull?.trksegs.lastOrNull?.trkpts.lastOrNull?.time;
return firstDate != null && lastDate != null ? (firstDate, lastDate) : null;
}
Text _gpxDateRangeText(BuildContext context, Gpx? gpx) {
final dateRange = _gpxDateRange(gpx);
if (dateRange != null) {
final (firstDate, lastDate) = dateRange;
final locale = context.locale;
final use24hour = MediaQuery.alwaysUse24HourFormatOf(context);
return Text(
[
formatDateTime(firstDate.toLocal(), locale, use24hour),
formatDateTime(lastDate.toLocal(), locale, use24hour),
].join('\n'),
);
} else {
return _unknownText(context);
}
}
Text _coordinatesText(BuildContext context, LatLng? latLng) {
final l10n = context.l10n;
if (latLng != null) {
return Text(
ExtraCoordinateFormat.toDMS(l10n, latLng).join('\n'),
);
} else {
return _unknownText(context);
}
}
LatLng? _parseLatLng() {
@ -334,6 +586,8 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
_isValidNotifier.value = _copyItemSource.hasGps;
case LocationEditAction.setCustom:
_isValidNotifier.value = _parseLatLng() != null;
case LocationEditAction.importGpx:
_isValidNotifier.value = _gpxMap.isNotEmpty;
case LocationEditAction.remove:
_isValidNotifier.value = true;
}
@ -341,15 +595,23 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
void _submit(BuildContext context) {
final navigator = Navigator.maybeOf(context);
final entries = widget.entries;
final LocationEditActionResult result = {};
void addLocationForAllEntries(LatLng? latLng) => result.addEntries(entries.map((v) => MapEntry(v, latLng)));
switch (_action) {
case LocationEditAction.chooseOnMap:
navigator?.pop(_mapCoordinates);
addLocationForAllEntries(_mapCoordinates);
case LocationEditAction.copyItem:
navigator?.pop(_copyItemSource.latLng);
addLocationForAllEntries(_copyItemSource.latLng);
case LocationEditAction.setCustom:
navigator?.pop(_parseLatLng());
addLocationForAllEntries(_parseLatLng());
case LocationEditAction.importGpx:
result.addAll(_gpxMap);
case LocationEditAction.remove:
navigator?.pop(ExtraAvesEntryMetadataEdition.removalLocation);
addLocationForAllEntries(ExtraAvesEntryMetadataEdition.removalLocation);
}
navigator?.pop(result);
}
}
typedef LocationEditActionResult = Map<AvesEntry, LatLng?>;

View file

@ -33,17 +33,18 @@ class ItemPickPage extends StatefulWidget {
class _ItemPickPageState extends State<ItemPickPage> {
final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.initialization);
CollectionLens get collection => widget.collection;
@override
void dispose() {
collection.dispose();
_appModeNotifier.dispose();
// provided collection should be a new instance specifically created
// for the `ItemPickPage` widget, so it can be safely disposed here
widget.collection.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final collection = widget.collection;
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
_appModeNotifier.value = widget.canRemoveFilters ? AppMode.pickUnfilteredMediaInternal : AppMode.pickFilteredMediaInternal;
return ListenableProvider<ValueNotifier<AppMode>>.value(

View file

@ -99,6 +99,9 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
_isPageAnimatingNotifier.dispose();
_dotLocationNotifier.dispose();
_infoLocationNotifier.dispose();
// provided collection should be a new instance specifically created
// for the `LocationPickPage` widget, so it can be safely disposed here
widget.collection?.dispose();
super.dispose();
}

View file

@ -0,0 +1,52 @@
import 'package:aves/widgets/common/basic/time_shift_selector.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'aves_dialog.dart';
class TimeShiftDialog extends StatefulWidget {
static const routeName = '/dialog/time_shift';
final Duration initialValue;
const TimeShiftDialog({
super.key,
required this.initialValue,
});
@override
State<TimeShiftDialog> createState() => _TimeShiftDialogState();
}
class _TimeShiftDialogState extends State<TimeShiftDialog> {
late TimeShiftController _timeShiftController;
@override
void initState() {
super.initState();
_timeShiftController = TimeShiftController(
initialValue: widget.initialValue,
);
}
@override
Widget build(BuildContext context) {
return AvesDialog(
scrollableContent: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: TimeShiftSelector(controller: _timeShiftController),
),
],
actions: [
const CancelButton(),
TextButton(
onPressed: () => _submit(context),
child: Text(context.l10n.applyButtonLabel),
),
],
);
}
void _submit(BuildContext context) => Navigator.maybeOf(context)?.pop(_timeShiftController.value);
}

View file

@ -50,6 +50,7 @@ class MapPage extends StatelessWidget {
final double? initialZoom;
final AvesEntry? initialEntry;
final MappedGeoTiff? overlayEntry;
final Set<List<LatLng>>? tracks;
const MapPage({
super.key,
@ -58,6 +59,7 @@ class MapPage extends StatelessWidget {
this.initialZoom,
this.initialEntry,
this.overlayEntry,
this.tracks,
});
@override
@ -83,6 +85,7 @@ class MapPage extends StatelessWidget {
initialZoom: initialZoom,
initialEntry: initialEntry,
overlayEntry: overlayEntry,
tracks: tracks,
),
),
),
@ -96,6 +99,7 @@ class _Content extends StatefulWidget {
final double? initialZoom;
final AvesEntry? initialEntry;
final MappedGeoTiff? overlayEntry;
final Set<List<LatLng>>? tracks;
const _Content({
required this.collection,
@ -103,6 +107,7 @@ class _Content extends StatefulWidget {
this.initialZoom,
this.initialEntry,
this.overlayEntry,
this.tracks,
});
@override
@ -266,6 +271,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
}
Widget _buildMap() {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
final canPop = Navigator.maybeOf(context)?.canPop() == true;
Widget child = MapTheme(
interactive: true,
@ -285,6 +291,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
dotLocationNotifier: _dotLocationNotifier,
overlayOpacityNotifier: _overlayOpacityNotifier,
overlayEntry: widget.overlayEntry,
tracks: widget.tracks,
onMapTap: (_) => _toggleOverlay(),
onMarkerTap: (location, entry) async {
final index = regionCollection?.sortedEntries.indexOf(entry);
@ -294,7 +301,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
await Future.delayed(const Duration(milliseconds: 500));
context.read<HighlightInfo>().set(entry);
},
onMarkerLongPress: _onMarkerLongPress,
onMarkerLongPress: appMode.canEditEntry ? _onMarkerLongPress : null,
),
);
if (settings.useTvLayout) {
@ -422,6 +429,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
void _goToViewer(AvesEntry? initialEntry) {
if (initialEntry == null) return;
final appModeNotifier = context.read<ValueNotifier<AppMode>>();
Navigator.maybeOf(context)?.push(
TransparentMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
@ -429,9 +437,14 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
final viewerCollection = regionCollection?.copyWith(
listenToSource: false,
);
return EntryViewerPage(
// propagate app mode from the map page, as it could be locally overridden
// and differ from the real app mode above the `Navigator`
return ListenableProvider<ValueNotifier<AppMode>>.value(
value: appModeNotifier,
child: EntryViewerPage(
collection: viewerCollection,
initialEntry: initialEntry,
),
);
},
),
@ -531,7 +544,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
case MapClusterAction.editLocation:
final regionEntries = regionCollection?.sortedEntries ?? [];
final markerIndex = regionEntries.indexOf(markerEntry);
final location = await delegate.editLocationByMap(context, clusterEntries, markerLocation, openingCollection);
final location = await delegate.editLocationByMap(context, clusterEntries, markerLocation, openingCollection.copyWith());
if (location != null) {
if (markerIndex != -1) {
_selectedIndexNotifier.value = markerIndex;

View file

@ -74,14 +74,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.delete:
case EntryAction.rename:
case EntryAction.move:
return targetEntry.canEdit;
return canWrite && targetEntry.canEdit;
case EntryAction.copy:
return canWrite;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return targetEntry.canRotate;
return canWrite && targetEntry.canRotate;
case EntryAction.flip:
return targetEntry.canFlip;
return canWrite && targetEntry.canFlip;
case EntryAction.convert:
return canWrite && !targetEntry.isPureVideo;
case EntryAction.print:

View file

@ -135,10 +135,12 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
}
Future<void> _editLocation(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final location = await selectLocation(context, {targetEntry}, collection);
if (location == null) return;
final locationByEntry = await selectLocation(context, {targetEntry}, collection);
if (locationByEntry == null) return;
await edit(context, targetEntry, () => targetEntry.editLocation(location));
if (locationByEntry.containsKey(targetEntry)) {
await edit(context, targetEntry, () => targetEntry.editLocation(locationByEntry[targetEntry]));
}
}
Future<void> _editTitleDescription(BuildContext context, AvesEntry targetEntry) async {

View file

@ -3,7 +3,6 @@ import 'dart:math';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/multipage.dart';
import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/settings/enums/accessibility_animations.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
@ -232,6 +231,8 @@ class ViewerButtonRowContent extends StatefulWidget {
class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null);
EntryActionDelegate get actionDelegate => widget.actionDelegate;
AvesEntry get mainEntry => widget.mainEntry;
AvesEntry get pageEntry => widget.pageEntry;
@ -248,10 +249,13 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
final showOrientationActions = EntryActions.orientationActions.any((v) => actionDelegate.isVisible(appMode: appMode, action: v));
final topLevelActions = widget.topLevelActions;
final exportActions = widget.exportActions;
final videoActions = widget.videoActions;
final hasOverflowMenu = pageEntry.canRotate || pageEntry.canFlip || topLevelActions.isNotEmpty || exportActions.isNotEmpty || videoActions.isNotEmpty;
final hasOverflowMenu = showOrientationActions || topLevelActions.isNotEmpty || exportActions.isNotEmpty || videoActions.isNotEmpty;
final animations = context.select<Settings, AccessibilityAnimations>((v) => v.accessibilityAnimations);
return Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(pageEntry),
@ -275,7 +279,7 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList();
final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList();
return [
if (pageEntry.canRotate || pageEntry.canFlip) _buildRotateAndFlipMenuItems(context),
if (showOrientationActions) _buildRotateAndFlipMenuItems(context),
...topLevelActions.map((action) => _buildPopupMenuItem(context, action, videoController)),
if (exportActions.isNotEmpty)
PopupMenuExpansionPanel<EntryAction>(
@ -311,7 +315,7 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
_popupExpandedNotifier.value = null;
// wait for the popup menu to hide before proceeding with the action
await Future.delayed(animations.popUpAnimationDelay * timeDilation);
widget.actionDelegate.onActionSelected(context, action);
actionDelegate.onActionSelected(context, action);
},
onCanceled: () {
_popupExpandedNotifier.value = null;
@ -340,14 +344,14 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
mainEntry: mainEntry,
pageEntry: pageEntry,
videoController: videoController,
actionDelegate: widget.actionDelegate,
actionDelegate: actionDelegate,
),
),
);
}
PopupMenuItem<EntryAction> _buildPopupMenuItem(BuildContext context, EntryAction action, AvesVideoController? videoController) {
var enabled = widget.actionDelegate.canApply(action);
var enabled = actionDelegate.canApply(action);
switch (action) {
case EntryAction.videoCaptureFrame:
enabled &= videoController?.canCaptureFrameNotifier.value ?? false;
@ -406,7 +410,7 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
clipBehavior: Clip.antiAlias,
child: PopupMenuItem(
value: action,
enabled: widget.actionDelegate.canApply(action),
enabled: actionDelegate.canApply(action),
child: Tooltip(
message: action.getText(context),
child: Center(child: action.getIcon()),

View file

@ -25,6 +25,7 @@ class MapThemeData {
static const double markerImageExtent = 48.0;
static const Size markerArrowSize = Size(8, 6);
static const double markerDotDiameter = 16;
static const int trackWidth = 5;
static Color markerThemedOuterBorderColor(bool isDark) => isDark ? Colors.white30 : Colors.black26;

View file

@ -91,20 +91,20 @@ class EntryActions {
static const pageActions = {
EntryAction.videoCaptureFrame,
EntryAction.videoSelectStreams,
EntryAction.videoToggleMute,
EntryAction.videoSetSpeed,
EntryAction.videoABRepeat,
EntryAction.videoToggleMute,
EntryAction.videoSelectStreams,
EntryAction.videoSettings,
EntryAction.videoTogglePlay,
EntryAction.videoReplay10,
EntryAction.videoSkip10,
EntryAction.videoShowPreviousFrame,
EntryAction.videoShowNextFrame,
...videoPlayback,
...orientationActions,
};
static const orientationActions = [
EntryAction.rotateCCW,
EntryAction.rotateCW,
EntryAction.flip,
};
];
static const trashed = [
EntryAction.delete,

View file

@ -23,6 +23,7 @@ enum LocationEditAction {
chooseOnMap,
copyItem,
setCustom,
importGpx,
remove,
}

View file

@ -24,6 +24,7 @@ abstract class MobileServices {
required ValueNotifier<LatLng?>? dotLocationNotifier,
required ValueNotifier<double>? overlayOpacityNotifier,
required MapOverlay? overlayEntry,
required Set<List<LatLng>>? tracks,
required UserZoomChangeCallback? onUserZoomChange,
required MapTapCallback? onMapTap,
required MarkerTapCallback<T>? onMarkerTap,

View file

@ -58,6 +58,7 @@ class PlatformMobileServices extends MobileServices {
required ValueNotifier<ll.LatLng?>? dotLocationNotifier,
required ValueNotifier<double>? overlayOpacityNotifier,
required MapOverlay? overlayEntry,
required Set<List<ll.LatLng>>? tracks,
required UserZoomChangeCallback? onUserZoomChange,
required MapTapCallback? onMapTap,
required MarkerTapCallback<T>? onMarkerTap,
@ -78,6 +79,7 @@ class PlatformMobileServices extends MobileServices {
dotLocationNotifier: dotLocationNotifier,
overlayOpacityNotifier: overlayOpacityNotifier,
overlayEntry: overlayEntry,
tracks: tracks,
onUserZoomChange: onUserZoomChange,
onMapTap: onMapTap,
onMarkerTap: onMarkerTap,

View file

@ -22,6 +22,7 @@ class EntryGoogleMap<T> extends StatefulWidget {
final ValueNotifier<ll.LatLng?>? dotLocationNotifier;
final ValueNotifier<double>? overlayOpacityNotifier;
final MapOverlay? overlayEntry;
final Set<List<ll.LatLng>>? tracks;
final UserZoomChangeCallback? onUserZoomChange;
final MapTapCallback? onMapTap;
final MarkerTapCallback<T>? onMarkerTap;
@ -43,6 +44,7 @@ class EntryGoogleMap<T> extends StatefulWidget {
required this.dotLocationNotifier,
this.overlayOpacityNotifier,
this.overlayEntry,
this.tracks,
this.onUserZoomChange,
this.onMapTap,
this.onMarkerTap,
@ -164,6 +166,8 @@ class _EntryGoogleMapState<T> extends State<EntryGoogleMap<T>> {
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
final overlayEntry = widget.overlayEntry;
final tracks = widget.tracks;
final trackColor = Theme.of(context).colorScheme.primary;
return NullableValueListenableBuilder<ll.LatLng?>(
valueListenable: widget.dotLocationNotifier,
builder: (context, dotLocation, child) {
@ -208,6 +212,16 @@ class _EntryGoogleMapState<T> extends State<EntryGoogleMap<T>> {
zIndex: 1,
)
},
polylines: {
if (tracks != null)
for (final track in tracks)
Polyline(
polylineId: PolylineId(track.hashCode.toString()),
points: track.map(_toServiceLatLng).toList(),
width: MapThemeData.trackWidth,
color: trackColor,
),
},
// TODO TLAD [geotiff] may use ground overlay instead when this is fixed: https://github.com/flutter/flutter/issues/26479
tileOverlays: {
if (overlayEntry != null && overlayEntry.canOverlay)
@ -331,6 +345,7 @@ class _GoogleMap extends StatefulWidget {
final MinMaxZoomPreference minMaxZoomPreference;
final bool interactive;
final Set<Marker> markers;
final Set<Polyline> polylines;
final Set<TileOverlay> tileOverlays;
final CameraPositionCallback? onCameraMove;
final VoidCallback? onCameraIdle;
@ -344,6 +359,7 @@ class _GoogleMap extends StatefulWidget {
required this.minMaxZoomPreference,
required this.interactive,
required this.markers,
required this.polylines,
required this.tileOverlays,
required this.onCameraMove,
required this.onCameraIdle,
@ -414,6 +430,7 @@ class _GoogleMapState extends State<_GoogleMap> {
myLocationEnabled: false,
myLocationButtonEnabled: false,
markers: widget.markers,
polylines: widget.polylines,
tileOverlays: widget.tileOverlays,
onCameraMove: widget.onCameraMove,
onCameraIdle: widget.onCameraIdle,

View file

@ -30,6 +30,7 @@ class PlatformMobileServices extends MobileServices {
required ValueNotifier<LatLng?>? dotLocationNotifier,
required ValueNotifier<double>? overlayOpacityNotifier,
required MapOverlay? overlayEntry,
required Set<List<LatLng>>? tracks,
required UserZoomChangeCallback? onUserZoomChange,
required MapTapCallback? onMapTap,
required MarkerTapCallback<T>? onMarkerTap,

View file

@ -576,6 +576,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.10"
gpx:
dependency: "direct main"
description:
name: gpx
sha256: f5b12b86402c639079243600ee2b3afd85cd08d26117fc8885cf48efce471d8e
url: "https://pub.dev"
source: hosted
version: "2.3.0"
highlight:
dependency: transitive
description:
@ -1188,6 +1196,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
quiver:
dependency: transitive
description:
name: quiver
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.dev"
source: hosted
version: "3.2.2"
safe_local_storage:
dependency: transitive
description:

View file

@ -90,6 +90,7 @@ dependencies:
flutter_markdown:
flutter_staggered_animations:
get_it:
gpx:
http:
intl:
latlong2: