From 0301269171419c393250ed4d9ec7f83b9fe7f021 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 27 Jan 2025 22:22:12 +0100 Subject: [PATCH] #1397 edit location via GPX --- CHANGELOG.md | 4 + lib/app_mode.dart | 1 + lib/l10n/app_en.arb | 2 + lib/model/app/dependencies.dart | 5 + lib/theme/format.dart | 15 +- .../src/metadata/location_edit_action.dart | 1 + .../collection/entry_set_action_delegate.dart | 6 +- .../common/action_mixins/entry_editor.dart | 12 +- .../common/basic/time_shift_selector.dart | 152 +++++++++ lib/widgets/common/map/geo_map.dart | 4 + lib/widgets/common/map/leaflet/map.dart | 19 ++ .../entry_editors/edit_date_dialog.dart | 101 +----- .../entry_editors/edit_location_dialog.dart | 322 ++++++++++++++++-- .../dialogs/pick_dialogs/item_pick_page.dart | 7 +- .../pick_dialogs/location_pick_page.dart | 3 + lib/widgets/dialogs/time_shift_dialog.dart | 52 +++ lib/widgets/map/map_page.dart | 23 +- .../viewer/action/entry_action_delegate.dart | 6 +- .../action/entry_info_action_delegate.dart | 8 +- .../viewer/overlay/viewer_buttons.dart | 18 +- plugins/aves_map/lib/src/theme.dart | 1 + plugins/aves_model/lib/src/actions/entry.dart | 16 +- .../aves_model/lib/src/metadata/enums.dart | 1 + plugins/aves_services/lib/aves_services.dart | 1 + .../lib/aves_services_platform.dart | 2 + plugins/aves_services_google/lib/src/map.dart | 17 + .../lib/aves_services_platform.dart | 1 + pubspec.lock | 16 + pubspec.yaml | 1 + 29 files changed, 649 insertions(+), 168 deletions(-) create mode 100644 lib/widgets/common/basic/time_shift_selector.dart create mode 100644 lib/widgets/dialogs/time_shift_dialog.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9402a6e39..3a4155969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- edit location via GPX + ### Changed - upgraded Flutter to stable v3.27.3 diff --git a/lib/app_mode.dart b/lib/app_mode.dart index 34b16f9b5..4c4aa8a23 100644 --- a/lib/app_mode.dart +++ b/lib/app_mode.dart @@ -7,6 +7,7 @@ enum AppMode { pickFilteredMediaInternal, pickUnfilteredMediaInternal, pickFilterInternal, + previewMap, screenSaver, setWallpaper, slideshow, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2cced4ee7..937f33be2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/model/app/dependencies.dart b/lib/model/app/dependencies.dart index 5171e7fbb..efbbd1bee 100644 --- a/lib/model/app/dependencies.dart +++ b/lib/model/app/dependencies.dart @@ -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, diff --git a/lib/theme/format.dart b/lib/theme/format.dart index 89ef8ed03..4bb272ea6 100644 --- a/lib/theme/format.dart +++ b/lib/theme/format.dart @@ -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) { diff --git a/lib/view/src/metadata/location_edit_action.dart b/lib/view/src/metadata/location_edit_action.dart index ad8b54004..a48e29a7a 100644 --- a/lib/view/src/metadata/location_edit_action.dart +++ b/lib/view/src/metadata/location_edit_action.dart @@ -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, }; } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index b9bb0c8b2..d3c94fad5 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -563,10 +563,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware if (entries == null || entries.isEmpty) return; final collection = context.read(); - 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 editLocationByMap(BuildContext context, Set entries, LatLng clusterLocation, CollectionLens mapCollection) async { diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 43319fc3c..4e3c937c1 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -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 selectDateModifier(BuildContext context, Set entries, CollectionLens? collection) async { @@ -35,15 +33,13 @@ mixin EntryEditorMixin { ); } - Future selectLocation(BuildContext context, Set entries, CollectionLens? collection) async { + Future selectLocation(BuildContext context, Set entries, CollectionLens? collection) async { if (entries.isEmpty) return null; - final entry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first; - - return showDialog( + return showDialog( context: context, builder: (context) => EditEntryLocationDialog( - entry: entry, + entries: entries, collection: collection, ), routeSettings: const RouteSettings(name: EditEntryLocationDialog.routeName), diff --git a/lib/widgets/common/basic/time_shift_selector.dart b/lib/widgets/common/basic/time_shift_selector.dart new file mode 100644 index 000000000..da0aafcd2 --- /dev/null +++ b/lib/widgets/common/basic/time_shift_selector.dart @@ -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 createState() => _TimeShiftSelectorState(); +} + +class _TimeShiftSelectorState extends State { + late ValueNotifier _shiftHour, _shiftMinute, _shiftSecond; + late ValueNotifier _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; +} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index bb0ba1d36..24a9194f3 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -42,6 +42,7 @@ class GeoMap extends StatefulWidget { final ValueNotifier? dotLocationNotifier; final ValueNotifier? overlayOpacityNotifier; final MapOverlay? overlayEntry; + final Set>? 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 { 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 { ), overlayOpacityNotifier: widget.overlayOpacityNotifier, overlayEntry: widget.overlayEntry, + tracks: widget.tracks, onUserZoomChange: widget.onUserZoomChange, onMapTap: widget.onMapTap, onMarkerTap: _onMarkerTap, diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index d48d1e00f..39ee02248 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -30,6 +30,7 @@ class EntryLeafletMap extends StatefulWidget { final Size markerSize, dotMarkerSize; final ValueNotifier? overlayOpacityNotifier; final MapOverlay? overlayEntry; + final Set>? tracks; final UserZoomChangeCallback? onUserZoomChange; final MapTapCallback? onMapTap; final MarkerTapCallback? onMarkerTap; @@ -52,6 +53,7 @@ class EntryLeafletMap extends StatefulWidget { required this.dotMarkerSize, this.overlayOpacityNotifier, this.overlayEntry, + this.tracks, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, @@ -175,6 +177,7 @@ class _EntryLeafletMapState extends State> 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 extends State> 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() { diff --git a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart index fa23a57eb..c8a3b57b2 100644 --- a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart @@ -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 { DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate; late AvesEntry _copyItemSource; late DateTime _customDateTime; - late ValueNotifier _shiftHour, _shiftMinute, _shiftSecond; - late ValueNotifier _shiftSign; + late TimeShiftController _timeShiftController; bool _showOptions = false; final Set _fields = {...DateModifier.writableFields}; final ValueNotifier _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 { @override void dispose() { _isValidNotifier.dispose(); - _shiftHour.dispose(); - _shiftMinute.dispose(); - _shiftSecond.dispose(); - _shiftSign.dispose(); super.dispose(); } @@ -81,10 +70,9 @@ class _EditEntryDateDialogState extends State { } 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 { } 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 { fullscreenDialog: true, ), ); - pickCollection.dispose(); if (entry != null) { setState(() => _copyItemSource = entry); } @@ -388,8 +302,7 @@ class _EditEntryDateDialogState extends State { 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); } diff --git a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart index d9ed88020..cea7ec997 100644 --- a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart @@ -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 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 createState() => _EditEntryLocationDialogState(); } -class _EditEntryLocationDialogState extends State { +class _EditEntryLocationDialogState extends State with FeedbackMixin { final List _subscriptions = []; LocationEditAction _action = LocationEditAction.chooseOnMap; LatLng? _mapCoordinates; + late final AvesEntry mainEntry; late AvesEntry _copyItemSource; + Gpx? _gpx; + Duration _gpxShift = Duration.zero; + final Map _gpxMap = {}; final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController(); final ValueNotifier _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 { } 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 { 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 { ); } + 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 { _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 { fullscreenDialog: true, ), ); - pickCollection?.dispose(); if (latLng != null) { settings.mapDefaultCenter = latLng; setState(() { @@ -223,7 +249,7 @@ class _EditEntryLocationDialogState extends State { 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 { fullscreenDialog: true, ), ); - pickCollection.dispose(); if (entry != null) { setState(() { _copyItemSource = entry; @@ -293,19 +318,246 @@ class _EditEntryLocationDialogState extends State { ); } - Text _toText(BuildContext context, LatLng? latLng) { + Widget _buildImportGpxContent(BuildContext context) { + final l10n = context.l10n; + 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 _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 _pickGpxShift() async { + final newShift = await showDialog( + 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 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 _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>.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( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + (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 Text( - l10n.viewerInfoUnknown, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ); + return _unknownText(context); } } @@ -334,6 +586,8 @@ class _EditEntryLocationDialogState extends State { _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 { 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; diff --git a/lib/widgets/dialogs/pick_dialogs/item_pick_page.dart b/lib/widgets/dialogs/pick_dialogs/item_pick_page.dart index f7abf4b8d..25d36075f 100644 --- a/lib/widgets/dialogs/pick_dialogs/item_pick_page.dart +++ b/lib/widgets/dialogs/pick_dialogs/item_pick_page.dart @@ -33,17 +33,18 @@ class ItemPickPage extends StatefulWidget { class _ItemPickPageState extends State { final ValueNotifier _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>.value( diff --git a/lib/widgets/dialogs/pick_dialogs/location_pick_page.dart b/lib/widgets/dialogs/pick_dialogs/location_pick_page.dart index 8dbbc5f1c..217e2b282 100644 --- a/lib/widgets/dialogs/pick_dialogs/location_pick_page.dart +++ b/lib/widgets/dialogs/pick_dialogs/location_pick_page.dart @@ -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(); } diff --git a/lib/widgets/dialogs/time_shift_dialog.dart b/lib/widgets/dialogs/time_shift_dialog.dart new file mode 100644 index 000000000..92f3a7491 --- /dev/null +++ b/lib/widgets/dialogs/time_shift_dialog.dart @@ -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 createState() => _TimeShiftDialogState(); +} + +class _TimeShiftDialogState extends State { + 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); +} diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 0dad07d20..0131ea2b4 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -50,6 +50,7 @@ class MapPage extends StatelessWidget { final double? initialZoom; final AvesEntry? initialEntry; final MappedGeoTiff? overlayEntry; + final Set>? 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>? 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>().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().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>(); 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( - collection: viewerCollection, - initialEntry: initialEntry, + // 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>.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; diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 1d2cba339..9d169ead7 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -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: diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 5d011c49e..cb5c9e743 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -135,10 +135,12 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi } Future _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 _editTitleDescription(BuildContext context, AvesEntry targetEntry) async { diff --git a/lib/widgets/viewer/overlay/viewer_buttons.dart b/lib/widgets/viewer/overlay/viewer_buttons.dart index 4591d8ae4..20e6548a5 100644 --- a/lib/widgets/viewer/overlay/viewer_buttons.dart +++ b/lib/widgets/viewer/overlay/viewer_buttons.dart @@ -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 { final ValueNotifier _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 { @override Widget build(BuildContext context) { + final appMode = context.watch>().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((v) => v.accessibilityAnimations); return Selector( selector: (context, vc) => vc.getController(pageEntry), @@ -275,7 +279,7 @@ class _ViewerButtonRowContentState extends State { 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( @@ -311,7 +315,7 @@ class _ViewerButtonRowContentState extends State { _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 { mainEntry: mainEntry, pageEntry: pageEntry, videoController: videoController, - actionDelegate: widget.actionDelegate, + actionDelegate: actionDelegate, ), ), ); } PopupMenuItem _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 { 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()), diff --git a/plugins/aves_map/lib/src/theme.dart b/plugins/aves_map/lib/src/theme.dart index 97679da69..521d94de4 100644 --- a/plugins/aves_map/lib/src/theme.dart +++ b/plugins/aves_map/lib/src/theme.dart @@ -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; diff --git a/plugins/aves_model/lib/src/actions/entry.dart b/plugins/aves_model/lib/src/actions/entry.dart index a46f30076..02becf1a3 100644 --- a/plugins/aves_model/lib/src/actions/entry.dart +++ b/plugins/aves_model/lib/src/actions/entry.dart @@ -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, diff --git a/plugins/aves_model/lib/src/metadata/enums.dart b/plugins/aves_model/lib/src/metadata/enums.dart index 061cf02b1..2137d4484 100644 --- a/plugins/aves_model/lib/src/metadata/enums.dart +++ b/plugins/aves_model/lib/src/metadata/enums.dart @@ -23,6 +23,7 @@ enum LocationEditAction { chooseOnMap, copyItem, setCustom, + importGpx, remove, } diff --git a/plugins/aves_services/lib/aves_services.dart b/plugins/aves_services/lib/aves_services.dart index 236de5895..b91d62e08 100644 --- a/plugins/aves_services/lib/aves_services.dart +++ b/plugins/aves_services/lib/aves_services.dart @@ -24,6 +24,7 @@ abstract class MobileServices { required ValueNotifier? dotLocationNotifier, required ValueNotifier? overlayOpacityNotifier, required MapOverlay? overlayEntry, + required Set>? tracks, required UserZoomChangeCallback? onUserZoomChange, required MapTapCallback? onMapTap, required MarkerTapCallback? onMarkerTap, diff --git a/plugins/aves_services_google/lib/aves_services_platform.dart b/plugins/aves_services_google/lib/aves_services_platform.dart index 9e665c84f..7441e8165 100644 --- a/plugins/aves_services_google/lib/aves_services_platform.dart +++ b/plugins/aves_services_google/lib/aves_services_platform.dart @@ -58,6 +58,7 @@ class PlatformMobileServices extends MobileServices { required ValueNotifier? dotLocationNotifier, required ValueNotifier? overlayOpacityNotifier, required MapOverlay? overlayEntry, + required Set>? tracks, required UserZoomChangeCallback? onUserZoomChange, required MapTapCallback? onMapTap, required MarkerTapCallback? onMarkerTap, @@ -78,6 +79,7 @@ class PlatformMobileServices extends MobileServices { dotLocationNotifier: dotLocationNotifier, overlayOpacityNotifier: overlayOpacityNotifier, overlayEntry: overlayEntry, + tracks: tracks, onUserZoomChange: onUserZoomChange, onMapTap: onMapTap, onMarkerTap: onMarkerTap, diff --git a/plugins/aves_services_google/lib/src/map.dart b/plugins/aves_services_google/lib/src/map.dart index ef3e47ad5..5c4f4eb72 100644 --- a/plugins/aves_services_google/lib/src/map.dart +++ b/plugins/aves_services_google/lib/src/map.dart @@ -22,6 +22,7 @@ class EntryGoogleMap extends StatefulWidget { final ValueNotifier? dotLocationNotifier; final ValueNotifier? overlayOpacityNotifier; final MapOverlay? overlayEntry; + final Set>? tracks; final UserZoomChangeCallback? onUserZoomChange; final MapTapCallback? onMapTap; final MarkerTapCallback? onMarkerTap; @@ -43,6 +44,7 @@ class EntryGoogleMap extends StatefulWidget { required this.dotLocationNotifier, this.overlayOpacityNotifier, this.overlayEntry, + this.tracks, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, @@ -164,6 +166,8 @@ class _EntryGoogleMapState extends State> { final interactive = context.select((v) => v.interactive); final overlayEntry = widget.overlayEntry; + final tracks = widget.tracks; + final trackColor = Theme.of(context).colorScheme.primary; return NullableValueListenableBuilder( valueListenable: widget.dotLocationNotifier, builder: (context, dotLocation, child) { @@ -208,6 +212,16 @@ class _EntryGoogleMapState extends State> { 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 markers; + final Set polylines; final Set 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, diff --git a/plugins/aves_services_none/lib/aves_services_platform.dart b/plugins/aves_services_none/lib/aves_services_platform.dart index 8f95b7364..7783d6fa7 100644 --- a/plugins/aves_services_none/lib/aves_services_platform.dart +++ b/plugins/aves_services_none/lib/aves_services_platform.dart @@ -30,6 +30,7 @@ class PlatformMobileServices extends MobileServices { required ValueNotifier? dotLocationNotifier, required ValueNotifier? overlayOpacityNotifier, required MapOverlay? overlayEntry, + required Set>? tracks, required UserZoomChangeCallback? onUserZoomChange, required MapTapCallback? onMapTap, required MarkerTapCallback? onMarkerTap, diff --git a/pubspec.lock b/pubspec.lock index 9ce41037d..bae35b232 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index eb99d5a74..5530ae4ab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -90,6 +90,7 @@ dependencies: flutter_markdown: flutter_staggered_animations: get_it: + gpx: http: intl: latlong2: