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: