#1397 edit location via GPX
This commit is contained in:
parent
a99f4877ce
commit
0301269171
29 changed files with 649 additions and 168 deletions
|
@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## <a id="unreleased"></a>[Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- edit location via GPX
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v3.27.3
|
||||
|
|
|
@ -7,6 +7,7 @@ enum AppMode {
|
|||
pickFilteredMediaInternal,
|
||||
pickUnfilteredMediaInternal,
|
||||
pickFilterInternal,
|
||||
previewMap,
|
||||
screenSaver,
|
||||
setWallpaper,
|
||||
slideshow,
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -563,10 +563,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
if (entries == null || entries.isEmpty) return;
|
||||
|
||||
final collection = context.read<CollectionLens>();
|
||||
final location = await selectLocation(context, entries, collection);
|
||||
if (location == null) return;
|
||||
final locationByEntry = await selectLocation(context, entries, collection);
|
||||
if (locationByEntry == null) return;
|
||||
|
||||
await _edit(context, entries, (entry) => entry.editLocation(location));
|
||||
await _edit(context, locationByEntry.keys.toSet(), (entry) => entry.editLocation(locationByEntry[entry]));
|
||||
}
|
||||
|
||||
Future<LatLng?> editLocationByMap(BuildContext context, Set<AvesEntry> entries, LatLng clusterLocation, CollectionLens mapCollection) async {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:aves/model/entry/entry.dart';
|
||||
import 'package:aves/model/entry/extensions/metadata_edition.dart';
|
||||
import 'package:aves/model/entry/extensions/multipage.dart';
|
||||
import 'package:aves/model/filters/covered/tag.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/placeholder.dart';
|
||||
import 'package:aves/model/filters/covered/tag.dart';
|
||||
import 'package:aves/model/metadata/date_modifier.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
|
@ -17,9 +17,7 @@ import 'package:aves/widgets/dialogs/entry_editors/edit_rating_dialog.dart';
|
|||
import 'package:aves/widgets/dialogs/entry_editors/remove_metadata_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/entry_editors/tag_editor_page.dart';
|
||||
import 'package:aves_model/aves_model.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
mixin EntryEditorMixin {
|
||||
Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
|
||||
|
@ -35,15 +33,13 @@ mixin EntryEditorMixin {
|
|||
);
|
||||
}
|
||||
|
||||
Future<LatLng?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
|
||||
Future<LocationEditActionResult?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
final entry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first;
|
||||
|
||||
return showDialog<LatLng>(
|
||||
return showDialog<LocationEditActionResult>(
|
||||
context: context,
|
||||
builder: (context) => EditEntryLocationDialog(
|
||||
entry: entry,
|
||||
entries: entries,
|
||||
collection: collection,
|
||||
),
|
||||
routeSettings: const RouteSettings(name: EditEntryLocationDialog.routeName),
|
||||
|
|
152
lib/widgets/common/basic/time_shift_selector.dart
Normal file
152
lib/widgets/common/basic/time_shift_selector.dart
Normal file
|
@ -0,0 +1,152 @@
|
|||
import 'package:aves/ref/locales.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:aves/widgets/common/basic/wheel.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class TimeShiftSelector extends StatefulWidget {
|
||||
final TimeShiftController controller;
|
||||
|
||||
const TimeShiftSelector({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TimeShiftSelector> createState() => _TimeShiftSelectorState();
|
||||
}
|
||||
|
||||
class _TimeShiftSelectorState extends State<TimeShiftSelector> {
|
||||
late ValueNotifier<int> _shiftHour, _shiftMinute, _shiftSecond;
|
||||
late ValueNotifier<String> _shiftSign;
|
||||
|
||||
static const _positiveSign = '+';
|
||||
static const _negativeSign = '-';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
var initialValue = widget.controller.initialValue;
|
||||
final sign = initialValue.isNegative ? _negativeSign : _positiveSign;
|
||||
initialValue = initialValue.abs();
|
||||
final hours = initialValue.inHours;
|
||||
initialValue -= Duration(hours: hours);
|
||||
final minutes = initialValue.inMinutes;
|
||||
initialValue -= Duration(minutes: minutes);
|
||||
final seconds = initialValue.inSeconds;
|
||||
|
||||
_shiftSign = ValueNotifier(sign);
|
||||
_shiftHour = ValueNotifier(hours);
|
||||
_shiftMinute = ValueNotifier(minutes);
|
||||
_shiftSecond = ValueNotifier(seconds);
|
||||
|
||||
_shiftSign.addListener(_updateValue);
|
||||
_shiftHour.addListener(_updateValue);
|
||||
_shiftMinute.addListener(_updateValue);
|
||||
_shiftSecond.addListener(_updateValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shiftSign.dispose();
|
||||
_shiftHour.dispose();
|
||||
_shiftMinute.dispose();
|
||||
_shiftSecond.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final timeComponentFormatter = NumberFormat('0', context.locale);
|
||||
|
||||
const textStyle = TextStyle(fontSize: 34);
|
||||
const digitsAlign = TextAlign.right;
|
||||
|
||||
return Center(
|
||||
child: Table(
|
||||
textDirection: timeComponentsDirection,
|
||||
children: [
|
||||
TableRow(
|
||||
children: [
|
||||
const SizedBox(),
|
||||
Center(child: Text(l10n.durationDialogHours)),
|
||||
const SizedBox(width: 16),
|
||||
Center(child: Text(l10n.durationDialogMinutes)),
|
||||
const SizedBox(width: 16),
|
||||
Center(child: Text(l10n.durationDialogSeconds)),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
children: [
|
||||
WheelSelector(
|
||||
valueNotifier: _shiftSign,
|
||||
values: const [_positiveSign, _negativeSign],
|
||||
textStyle: textStyle,
|
||||
textAlign: TextAlign.center,
|
||||
format: (v) => v,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: WheelSelector(
|
||||
valueNotifier: _shiftHour,
|
||||
values: List.generate(hoursInDay, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: digitsAlign,
|
||||
format: timeComponentFormatter.format,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
':',
|
||||
style: textStyle,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: WheelSelector(
|
||||
valueNotifier: _shiftMinute,
|
||||
values: List.generate(minutesInHour, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: digitsAlign,
|
||||
format: timeComponentFormatter.format,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
':',
|
||||
style: textStyle,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: WheelSelector(
|
||||
valueNotifier: _shiftSecond,
|
||||
values: List.generate(secondsInMinute, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: digitsAlign,
|
||||
format: timeComponentFormatter.format,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateValue() {
|
||||
final sign = _shiftSign.value == _positiveSign ? 1 : -1;
|
||||
final hours = _shiftHour.value;
|
||||
final minutes = _shiftMinute.value;
|
||||
final seconds = _shiftSecond.value;
|
||||
widget.controller.value = Duration(hours: hours, minutes: minutes, seconds: seconds) * sign;
|
||||
}
|
||||
}
|
||||
|
||||
class TimeShiftController {
|
||||
final Duration initialValue;
|
||||
Duration value;
|
||||
|
||||
TimeShiftController({required this.initialValue}) : value = initialValue;
|
||||
}
|
|
@ -42,6 +42,7 @@ class GeoMap extends StatefulWidget {
|
|||
final ValueNotifier<LatLng?>? dotLocationNotifier;
|
||||
final ValueNotifier<double>? overlayOpacityNotifier;
|
||||
final MapOverlay? overlayEntry;
|
||||
final Set<List<LatLng>>? tracks;
|
||||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final MapTapCallback? onMapTap;
|
||||
final void Function(
|
||||
|
@ -69,6 +70,7 @@ class GeoMap extends StatefulWidget {
|
|||
this.dotLocationNotifier,
|
||||
this.overlayOpacityNotifier,
|
||||
this.overlayEntry,
|
||||
this.tracks,
|
||||
this.onUserZoomChange,
|
||||
this.onMapTap,
|
||||
this.onMarkerTap,
|
||||
|
@ -179,6 +181,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
dotLocationNotifier: widget.dotLocationNotifier,
|
||||
overlayOpacityNotifier: widget.overlayOpacityNotifier,
|
||||
overlayEntry: widget.overlayEntry,
|
||||
tracks: widget.tracks,
|
||||
onUserZoomChange: widget.onUserZoomChange,
|
||||
onMapTap: widget.onMapTap,
|
||||
onMarkerTap: _onMarkerTap,
|
||||
|
@ -210,6 +213,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
),
|
||||
overlayOpacityNotifier: widget.overlayOpacityNotifier,
|
||||
overlayEntry: widget.overlayEntry,
|
||||
tracks: widget.tracks,
|
||||
onUserZoomChange: widget.onUserZoomChange,
|
||||
onMapTap: widget.onMapTap,
|
||||
onMarkerTap: _onMarkerTap,
|
||||
|
|
|
@ -30,6 +30,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
|
|||
final Size markerSize, dotMarkerSize;
|
||||
final ValueNotifier<double>? overlayOpacityNotifier;
|
||||
final MapOverlay? overlayEntry;
|
||||
final Set<List<LatLng>>? tracks;
|
||||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final MapTapCallback? onMapTap;
|
||||
final MarkerTapCallback<T>? onMarkerTap;
|
||||
|
@ -52,6 +53,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
|
|||
required this.dotMarkerSize,
|
||||
this.overlayOpacityNotifier,
|
||||
this.overlayEntry,
|
||||
this.tracks,
|
||||
this.onUserZoomChange,
|
||||
this.onMapTap,
|
||||
this.onMarkerTap,
|
||||
|
@ -175,6 +177,7 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
|
|||
children: [
|
||||
_buildMapLayer(),
|
||||
if (widget.overlayEntry != null) _buildOverlayImageLayer(),
|
||||
if (widget.tracks != null) _buildTracksLayer(),
|
||||
MarkerLayer(
|
||||
markers: markers,
|
||||
rotate: true,
|
||||
|
@ -243,6 +246,22 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTracksLayer() {
|
||||
final tracks = widget.tracks;
|
||||
if (tracks == null) return const SizedBox();
|
||||
|
||||
final trackColor = Theme.of(context).colorScheme.primary;
|
||||
return PolylineLayer(
|
||||
polylines: tracks
|
||||
.map((v) => Polyline(
|
||||
points: v,
|
||||
strokeWidth: MapThemeData.trackWidth.toDouble(),
|
||||
color: trackColor,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void _onBoundsChanged() => _debouncer(_onIdle);
|
||||
|
||||
void _onIdle() {
|
||||
|
|
|
@ -1,24 +1,21 @@
|
|||
import 'package:aves/model/entry/entry.dart';
|
||||
import 'package:aves/model/metadata/date_modifier.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/ref/locales.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/theme/themes.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:aves/view/view.dart';
|
||||
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
|
||||
import 'package:aves/widgets/common/basic/wheel.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/fx/transitions.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/common/basic/time_shift_selector.dart';
|
||||
import 'package:aves/widgets/dialogs/item_picker.dart';
|
||||
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
|
||||
import 'package:aves_model/aves_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class EditEntryDateDialog extends StatefulWidget {
|
||||
|
@ -42,17 +39,13 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate;
|
||||
late AvesEntry _copyItemSource;
|
||||
late DateTime _customDateTime;
|
||||
late ValueNotifier<int> _shiftHour, _shiftMinute, _shiftSecond;
|
||||
late ValueNotifier<String> _shiftSign;
|
||||
late TimeShiftController _timeShiftController;
|
||||
bool _showOptions = false;
|
||||
final Set<MetadataField> _fields = {...DateModifier.writableFields};
|
||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||
|
||||
DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now();
|
||||
|
||||
static const _positiveSign = '+';
|
||||
static const _negativeSign = '-';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
@ -65,10 +58,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
@override
|
||||
void dispose() {
|
||||
_isValidNotifier.dispose();
|
||||
_shiftHour.dispose();
|
||||
_shiftMinute.dispose();
|
||||
_shiftSecond.dispose();
|
||||
_shiftSign.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -81,10 +70,9 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
}
|
||||
|
||||
void _initShift() {
|
||||
_shiftHour = ValueNotifier(1);
|
||||
_shiftMinute = ValueNotifier(0);
|
||||
_shiftSecond = ValueNotifier(0);
|
||||
_shiftSign = ValueNotifier(_positiveSign);
|
||||
_timeShiftController = TimeShiftController(
|
||||
initialValue: const Duration(hours: 1),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -203,80 +191,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
}
|
||||
|
||||
Widget _buildShiftContent(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final timeComponentFormatter = NumberFormat('0', context.locale);
|
||||
|
||||
const textStyle = TextStyle(fontSize: 34);
|
||||
const digitsAlign = TextAlign.right;
|
||||
|
||||
return Center(
|
||||
child: Table(
|
||||
textDirection: timeComponentsDirection,
|
||||
children: [
|
||||
TableRow(
|
||||
children: [
|
||||
const SizedBox(),
|
||||
Center(child: Text(l10n.durationDialogHours)),
|
||||
const SizedBox(width: 16),
|
||||
Center(child: Text(l10n.durationDialogMinutes)),
|
||||
const SizedBox(width: 16),
|
||||
Center(child: Text(l10n.durationDialogSeconds)),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
children: [
|
||||
WheelSelector(
|
||||
valueNotifier: _shiftSign,
|
||||
values: const [_positiveSign, _negativeSign],
|
||||
textStyle: textStyle,
|
||||
textAlign: TextAlign.center,
|
||||
format: (v) => v,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: WheelSelector(
|
||||
valueNotifier: _shiftHour,
|
||||
values: List.generate(hoursInDay, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: digitsAlign,
|
||||
format: timeComponentFormatter.format,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
':',
|
||||
style: textStyle,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: WheelSelector(
|
||||
valueNotifier: _shiftMinute,
|
||||
values: List.generate(minutesInHour, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: digitsAlign,
|
||||
format: timeComponentFormatter.format,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
':',
|
||||
style: textStyle,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: WheelSelector(
|
||||
valueNotifier: _shiftSecond,
|
||||
values: List.generate(secondsInMinute, (i) => i),
|
||||
textStyle: textStyle,
|
||||
textAlign: digitsAlign,
|
||||
format: timeComponentFormatter.format,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
),
|
||||
);
|
||||
return TimeShiftSelector(controller: _timeShiftController);
|
||||
}
|
||||
|
||||
Widget _buildDestinationFields(BuildContext context) {
|
||||
|
@ -368,7 +283,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
pickCollection.dispose();
|
||||
if (entry != null) {
|
||||
setState(() => _copyItemSource = entry);
|
||||
}
|
||||
|
@ -388,8 +302,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
case DateEditAction.extractFromTitle:
|
||||
return DateModifier.extractFromTitle();
|
||||
case DateEditAction.shift:
|
||||
final shiftTotalSeconds = ((_shiftHour.value * minutesInHour + _shiftMinute.value) * secondsInMinute + _shiftSecond.value) * (_shiftSign.value == _positiveSign ? 1 : -1);
|
||||
return DateModifier.shift(_fields, shiftTotalSeconds);
|
||||
return DateModifier.shift(_fields, _timeShiftController.value.inSeconds);
|
||||
case DateEditAction.remove:
|
||||
return DateModifier.remove(_fields);
|
||||
}
|
||||
|
|
|
@ -1,29 +1,40 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/entry/entry.dart';
|
||||
import 'package:aves/model/entry/extensions/location.dart';
|
||||
import 'package:aves/model/entry/extensions/metadata_edition.dart';
|
||||
import 'package:aves/model/entry/sort.dart';
|
||||
import 'package:aves/model/filters/covered/location.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/settings/enums/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/ref/poi.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/theme/themes.dart';
|
||||
import 'package:aves/view/view.dart';
|
||||
import 'package:aves/widgets/aves_app.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/fx/transitions.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_caption.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/item_picker.dart';
|
||||
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
|
||||
import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart';
|
||||
import 'package:aves/widgets/dialogs/time_shift_dialog.dart';
|
||||
import 'package:aves/widgets/map/map_page.dart';
|
||||
import 'package:aves_model/aves_model.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gpx/gpx.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -31,12 +42,12 @@ import 'package:provider/provider.dart';
|
|||
class EditEntryLocationDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/edit_entry_location';
|
||||
|
||||
final AvesEntry entry;
|
||||
final Set<AvesEntry> entries;
|
||||
final CollectionLens? collection;
|
||||
|
||||
const EditEntryLocationDialog({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.entries,
|
||||
this.collection,
|
||||
});
|
||||
|
||||
|
@ -44,19 +55,26 @@ class EditEntryLocationDialog extends StatefulWidget {
|
|||
State<EditEntryLocationDialog> createState() => _EditEntryLocationDialogState();
|
||||
}
|
||||
|
||||
class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||
class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> with FeedbackMixin {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
LocationEditAction _action = LocationEditAction.chooseOnMap;
|
||||
LatLng? _mapCoordinates;
|
||||
late final AvesEntry mainEntry;
|
||||
late AvesEntry _copyItemSource;
|
||||
Gpx? _gpx;
|
||||
Duration _gpxShift = Duration.zero;
|
||||
final Map<AvesEntry, LatLng> _gpxMap = {};
|
||||
final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController();
|
||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||
|
||||
NumberFormat get coordinateFormatter => NumberFormat('0.000000', context.locale);
|
||||
static const _minTimeToGpxPoint = Duration(hours: 1);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final entries = widget.entries;
|
||||
mainEntry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first;
|
||||
_initMapCoordinates();
|
||||
_initCopyItem();
|
||||
_initCustom();
|
||||
|
@ -64,16 +82,16 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
}
|
||||
|
||||
void _initMapCoordinates() {
|
||||
_mapCoordinates = widget.entry.latLng;
|
||||
_mapCoordinates = mainEntry.latLng;
|
||||
}
|
||||
|
||||
void _initCopyItem() {
|
||||
_copyItemSource = widget.entry;
|
||||
_copyItemSource = mainEntry;
|
||||
}
|
||||
|
||||
void _initCustom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final latLng = widget.entry.latLng;
|
||||
final latLng = mainEntry.latLng;
|
||||
if (latLng != null) {
|
||||
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
|
||||
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
|
||||
|
@ -128,14 +146,9 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
switchInCurve: Curves.easeInOutCubic,
|
||||
switchOutCurve: Curves.easeInOutCubic,
|
||||
transitionBuilder: AvesTransitions.formTransitionBuilder,
|
||||
child: Column(
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(_action),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_action == LocationEditAction.chooseOnMap) _buildChooseOnMapContent(context),
|
||||
if (_action == LocationEditAction.copyItem) _buildCopyItemContent(context),
|
||||
if (_action == LocationEditAction.setCustom) _buildSetCustomContent(context),
|
||||
],
|
||||
child: _buildContent(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
@ -158,12 +171,27 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
switch (_action) {
|
||||
case LocationEditAction.chooseOnMap:
|
||||
return _buildChooseOnMapContent(context);
|
||||
case LocationEditAction.copyItem:
|
||||
return _buildCopyItemContent(context);
|
||||
case LocationEditAction.setCustom:
|
||||
return _buildSetCustomContent(context);
|
||||
case LocationEditAction.importGpx:
|
||||
return _buildImportGpxContent(context);
|
||||
case LocationEditAction.remove:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChooseOnMapContent(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _toText(context, _mapCoordinates)),
|
||||
Expanded(child: _coordinatesText(context, _mapCoordinates)),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.map),
|
||||
|
@ -179,8 +207,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
|
||||
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
|
||||
_action = LocationEditAction.setCustom;
|
||||
_validate();
|
||||
setState(() {});
|
||||
setState(_validate);
|
||||
}
|
||||
|
||||
CollectionLens? _createPickCollection() {
|
||||
|
@ -208,7 +235,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
pickCollection?.dispose();
|
||||
if (latLng != null) {
|
||||
settings.mapDefaultCenter = latLng;
|
||||
setState(() {
|
||||
|
@ -223,7 +249,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _toText(context, _copyItemSource.latLng)),
|
||||
Expanded(child: _coordinatesText(context, _copyItemSource.latLng)),
|
||||
const SizedBox(width: 8),
|
||||
ItemPicker(
|
||||
extent: 48,
|
||||
|
@ -249,7 +275,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
pickCollection.dispose();
|
||||
if (entry != null) {
|
||||
setState(() {
|
||||
_copyItemSource = entry;
|
||||
|
@ -293,13 +318,207 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Text _toText(BuildContext context, LatLng? latLng) {
|
||||
Widget _buildImportGpxContent(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
if (latLng != null) {
|
||||
return Text(
|
||||
ExtraCoordinateFormat.toDMS(l10n, latLng).join('\n'),
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _gpxDateRangeText(context, _gpx)),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: Icon(AIcons.fileImport),
|
||||
onPressed: _pickGpx,
|
||||
tooltip: l10n.pickTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_gpx != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.editEntryLocationDialogTimeShift),
|
||||
AvesCaption(_formatShiftDuration(_gpxShift)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.edit),
|
||||
onPressed: _pickGpxShift,
|
||||
tooltip: l10n.changeTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(l10n.statsWithGps(_gpxMap.length))),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.map),
|
||||
onPressed: _previewGpx,
|
||||
tooltip: l10n.openMapPageTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickGpx() async {
|
||||
final bytes = await storageService.openFile();
|
||||
if (bytes.isNotEmpty) {
|
||||
try {
|
||||
final allXmlString = utf8.decode(bytes);
|
||||
final gpx = GpxReader().fromString(allXmlString);
|
||||
|
||||
_gpx = gpx;
|
||||
_gpxShift = Duration.zero;
|
||||
_updateGpxMapping();
|
||||
|
||||
showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback);
|
||||
} catch (error, stack) {
|
||||
debugPrint('failed to import GPX, error=$error\n$stack');
|
||||
showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickGpxShift() async {
|
||||
final newShift = await showDialog<Duration>(
|
||||
context: context,
|
||||
builder: (context) => TimeShiftDialog(
|
||||
initialValue: _gpxShift,
|
||||
),
|
||||
routeSettings: const RouteSettings(name: TimeShiftDialog.routeName),
|
||||
);
|
||||
if (newShift == null) return;
|
||||
|
||||
_gpxShift = newShift;
|
||||
_updateGpxMapping();
|
||||
}
|
||||
|
||||
String _formatShiftDuration(Duration duration) {
|
||||
final sign = duration.isNegative ? '-' : '+';
|
||||
duration = duration.abs();
|
||||
final hours = duration.inHours;
|
||||
duration -= Duration(hours: hours);
|
||||
final minutes = duration.inMinutes;
|
||||
duration -= Duration(minutes: minutes);
|
||||
final seconds = duration.inSeconds;
|
||||
return '$sign$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _updateGpxMapping() {
|
||||
_gpxMap.clear();
|
||||
|
||||
final gpx = _gpx;
|
||||
if (gpx == null) return;
|
||||
|
||||
final Map<AvesEntry, Wpt> wptByEntry = {};
|
||||
|
||||
// dated items and points, oldest first
|
||||
final sortedEntries = widget.entries.where((v) => v.bestDate != null).sorted(AvesEntrySort.compareByDate).reversed.toList();
|
||||
final sortedPoints = gpx.trks.expand((trk) => trk.trksegs).expand((trkSeg) => trkSeg.trkpts).where((v) => v.time != null).sortedBy((v) => v.time!);
|
||||
if (sortedEntries.isNotEmpty && sortedPoints.isNotEmpty) {
|
||||
int entryIndex = 0;
|
||||
int pointIndex = 0;
|
||||
final int maxDurationSecs = const Duration(days: 365).inSeconds;
|
||||
int smallestDifferenceSecs = maxDurationSecs;
|
||||
while (entryIndex < sortedEntries.length && pointIndex < sortedPoints.length) {
|
||||
final entry = sortedEntries[entryIndex];
|
||||
final point = sortedPoints[pointIndex];
|
||||
final entryDate = entry.bestDate!;
|
||||
final pointTime = point.time!.add(_gpxShift);
|
||||
final differenceSecs = entryDate.difference(pointTime).inSeconds.abs();
|
||||
if (differenceSecs < smallestDifferenceSecs) {
|
||||
smallestDifferenceSecs = differenceSecs;
|
||||
wptByEntry[entry] = point;
|
||||
pointIndex++;
|
||||
} else {
|
||||
smallestDifferenceSecs = maxDurationSecs;
|
||||
entryIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_gpxMap.addEntries(wptByEntry.entries.map((kv) {
|
||||
final entry = kv.key;
|
||||
final wpt = kv.value;
|
||||
final timeToPoint = entry.bestDate!.difference(wpt.time!.add(_gpxShift)).abs();
|
||||
if (timeToPoint < _minTimeToGpxPoint) {
|
||||
final lat = wpt.lat;
|
||||
final lon = wpt.lon;
|
||||
if (lat != null && lon != null) {
|
||||
return MapEntry(entry, LatLng(lat, lon));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}).nonNulls);
|
||||
|
||||
setState(_validate);
|
||||
}
|
||||
|
||||
Future<void> _previewGpx() async {
|
||||
final source = widget.collection?.source;
|
||||
if (source == null) return;
|
||||
|
||||
final previewEntries = _gpxMap.entries.map((kv) {
|
||||
final entry = kv.key.copyWith();
|
||||
final latLng = kv.value;
|
||||
final catalogMetadata = entry.catalogMetadata?.copyWith() ?? CatalogMetadata(id: entry.id);
|
||||
catalogMetadata.latitude = latLng.latitude;
|
||||
catalogMetadata.longitude = latLng.longitude;
|
||||
entry.catalogMetadata = catalogMetadata;
|
||||
return entry;
|
||||
}).toList();
|
||||
|
||||
final mapCollection = CollectionLens(
|
||||
source: source,
|
||||
listenToSource: false,
|
||||
fixedSelection: previewEntries,
|
||||
);
|
||||
|
||||
final tracks = _gpx?.trks
|
||||
.expand((trk) => trk.trksegs)
|
||||
.map((trkSeg) => trkSeg.trkpts
|
||||
.map((wpt) {
|
||||
final lat = wpt.lat;
|
||||
final lon = wpt.lon;
|
||||
return (lat != null && lon != null) ? LatLng(lat, lon) : null;
|
||||
})
|
||||
.nonNulls
|
||||
.toList())
|
||||
.toSet();
|
||||
|
||||
await Navigator.maybeOf(context)?.push(
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: LocationPickPage.routeName),
|
||||
builder: (context) {
|
||||
return ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||
value: ValueNotifier(AppMode.previewMap),
|
||||
child: MapPage(
|
||||
collection: mapCollection,
|
||||
tracks: tracks,
|
||||
),
|
||||
);
|
||||
},
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Text _unknownText(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Text(
|
||||
l10n.viewerInfoUnknown,
|
||||
style: TextStyle(
|
||||
|
@ -307,6 +526,39 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
(DateTime, DateTime)? _gpxDateRange(Gpx? gpx) {
|
||||
final firstDate = gpx?.trks.firstOrNull?.trksegs.firstOrNull?.trkpts.firstOrNull?.time;
|
||||
final lastDate = gpx?.trks.lastOrNull?.trksegs.lastOrNull?.trkpts.lastOrNull?.time;
|
||||
return firstDate != null && lastDate != null ? (firstDate, lastDate) : null;
|
||||
}
|
||||
|
||||
Text _gpxDateRangeText(BuildContext context, Gpx? gpx) {
|
||||
final dateRange = _gpxDateRange(gpx);
|
||||
if (dateRange != null) {
|
||||
final (firstDate, lastDate) = dateRange;
|
||||
final locale = context.locale;
|
||||
final use24hour = MediaQuery.alwaysUse24HourFormatOf(context);
|
||||
return Text(
|
||||
[
|
||||
formatDateTime(firstDate.toLocal(), locale, use24hour),
|
||||
formatDateTime(lastDate.toLocal(), locale, use24hour),
|
||||
].join('\n'),
|
||||
);
|
||||
} else {
|
||||
return _unknownText(context);
|
||||
}
|
||||
}
|
||||
|
||||
Text _coordinatesText(BuildContext context, LatLng? latLng) {
|
||||
final l10n = context.l10n;
|
||||
if (latLng != null) {
|
||||
return Text(
|
||||
ExtraCoordinateFormat.toDMS(l10n, latLng).join('\n'),
|
||||
);
|
||||
} else {
|
||||
return _unknownText(context);
|
||||
}
|
||||
}
|
||||
|
||||
LatLng? _parseLatLng() {
|
||||
|
@ -334,6 +586,8 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
_isValidNotifier.value = _copyItemSource.hasGps;
|
||||
case LocationEditAction.setCustom:
|
||||
_isValidNotifier.value = _parseLatLng() != null;
|
||||
case LocationEditAction.importGpx:
|
||||
_isValidNotifier.value = _gpxMap.isNotEmpty;
|
||||
case LocationEditAction.remove:
|
||||
_isValidNotifier.value = true;
|
||||
}
|
||||
|
@ -341,15 +595,23 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
|||
|
||||
void _submit(BuildContext context) {
|
||||
final navigator = Navigator.maybeOf(context);
|
||||
final entries = widget.entries;
|
||||
final LocationEditActionResult result = {};
|
||||
void addLocationForAllEntries(LatLng? latLng) => result.addEntries(entries.map((v) => MapEntry(v, latLng)));
|
||||
switch (_action) {
|
||||
case LocationEditAction.chooseOnMap:
|
||||
navigator?.pop(_mapCoordinates);
|
||||
addLocationForAllEntries(_mapCoordinates);
|
||||
case LocationEditAction.copyItem:
|
||||
navigator?.pop(_copyItemSource.latLng);
|
||||
addLocationForAllEntries(_copyItemSource.latLng);
|
||||
case LocationEditAction.setCustom:
|
||||
navigator?.pop(_parseLatLng());
|
||||
addLocationForAllEntries(_parseLatLng());
|
||||
case LocationEditAction.importGpx:
|
||||
result.addAll(_gpxMap);
|
||||
case LocationEditAction.remove:
|
||||
navigator?.pop(ExtraAvesEntryMetadataEdition.removalLocation);
|
||||
addLocationForAllEntries(ExtraAvesEntryMetadataEdition.removalLocation);
|
||||
}
|
||||
navigator?.pop(result);
|
||||
}
|
||||
}
|
||||
|
||||
typedef LocationEditActionResult = Map<AvesEntry, LatLng?>;
|
||||
|
|
|
@ -33,17 +33,18 @@ class ItemPickPage extends StatefulWidget {
|
|||
class _ItemPickPageState extends State<ItemPickPage> {
|
||||
final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.initialization);
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
collection.dispose();
|
||||
_appModeNotifier.dispose();
|
||||
// provided collection should be a new instance specifically created
|
||||
// for the `ItemPickPage` widget, so it can be safely disposed here
|
||||
widget.collection.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final collection = widget.collection;
|
||||
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
|
||||
_appModeNotifier.value = widget.canRemoveFilters ? AppMode.pickUnfilteredMediaInternal : AppMode.pickFilteredMediaInternal;
|
||||
return ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
52
lib/widgets/dialogs/time_shift_dialog.dart
Normal file
52
lib/widgets/dialogs/time_shift_dialog.dart
Normal file
|
@ -0,0 +1,52 @@
|
|||
import 'package:aves/widgets/common/basic/time_shift_selector.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'aves_dialog.dart';
|
||||
|
||||
class TimeShiftDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/time_shift';
|
||||
|
||||
final Duration initialValue;
|
||||
|
||||
const TimeShiftDialog({
|
||||
super.key,
|
||||
required this.initialValue,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TimeShiftDialog> createState() => _TimeShiftDialogState();
|
||||
}
|
||||
|
||||
class _TimeShiftDialogState extends State<TimeShiftDialog> {
|
||||
late TimeShiftController _timeShiftController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timeShiftController = TimeShiftController(
|
||||
initialValue: widget.initialValue,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesDialog(
|
||||
scrollableContent: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: TimeShiftSelector(controller: _timeShiftController),
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
TextButton(
|
||||
onPressed: () => _submit(context),
|
||||
child: Text(context.l10n.applyButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _submit(BuildContext context) => Navigator.maybeOf(context)?.pop(_timeShiftController.value);
|
||||
}
|
|
@ -50,6 +50,7 @@ class MapPage extends StatelessWidget {
|
|||
final double? initialZoom;
|
||||
final AvesEntry? initialEntry;
|
||||
final MappedGeoTiff? overlayEntry;
|
||||
final Set<List<LatLng>>? tracks;
|
||||
|
||||
const MapPage({
|
||||
super.key,
|
||||
|
@ -58,6 +59,7 @@ class MapPage extends StatelessWidget {
|
|||
this.initialZoom,
|
||||
this.initialEntry,
|
||||
this.overlayEntry,
|
||||
this.tracks,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -83,6 +85,7 @@ class MapPage extends StatelessWidget {
|
|||
initialZoom: initialZoom,
|
||||
initialEntry: initialEntry,
|
||||
overlayEntry: overlayEntry,
|
||||
tracks: tracks,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -96,6 +99,7 @@ class _Content extends StatefulWidget {
|
|||
final double? initialZoom;
|
||||
final AvesEntry? initialEntry;
|
||||
final MappedGeoTiff? overlayEntry;
|
||||
final Set<List<LatLng>>? tracks;
|
||||
|
||||
const _Content({
|
||||
required this.collection,
|
||||
|
@ -103,6 +107,7 @@ class _Content extends StatefulWidget {
|
|||
this.initialZoom,
|
||||
this.initialEntry,
|
||||
this.overlayEntry,
|
||||
this.tracks,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -266,6 +271,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
|||
}
|
||||
|
||||
Widget _buildMap() {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
final canPop = Navigator.maybeOf(context)?.canPop() == true;
|
||||
Widget child = MapTheme(
|
||||
interactive: true,
|
||||
|
@ -285,6 +291,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
|||
dotLocationNotifier: _dotLocationNotifier,
|
||||
overlayOpacityNotifier: _overlayOpacityNotifier,
|
||||
overlayEntry: widget.overlayEntry,
|
||||
tracks: widget.tracks,
|
||||
onMapTap: (_) => _toggleOverlay(),
|
||||
onMarkerTap: (location, entry) async {
|
||||
final index = regionCollection?.sortedEntries.indexOf(entry);
|
||||
|
@ -294,7 +301,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
|||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
context.read<HighlightInfo>().set(entry);
|
||||
},
|
||||
onMarkerLongPress: _onMarkerLongPress,
|
||||
onMarkerLongPress: appMode.canEditEntry ? _onMarkerLongPress : null,
|
||||
),
|
||||
);
|
||||
if (settings.useTvLayout) {
|
||||
|
@ -422,6 +429,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
|||
void _goToViewer(AvesEntry? initialEntry) {
|
||||
if (initialEntry == null) return;
|
||||
|
||||
final appModeNotifier = context.read<ValueNotifier<AppMode>>();
|
||||
Navigator.maybeOf(context)?.push(
|
||||
TransparentMaterialPageRoute(
|
||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
|
@ -429,9 +437,14 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
|||
final viewerCollection = regionCollection?.copyWith(
|
||||
listenToSource: false,
|
||||
);
|
||||
return EntryViewerPage(
|
||||
// propagate app mode from the map page, as it could be locally overridden
|
||||
// and differ from the real app mode above the `Navigator`
|
||||
return ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||
value: appModeNotifier,
|
||||
child: EntryViewerPage(
|
||||
collection: viewerCollection,
|
||||
initialEntry: initialEntry,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -531,7 +544,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
|||
case MapClusterAction.editLocation:
|
||||
final regionEntries = regionCollection?.sortedEntries ?? [];
|
||||
final markerIndex = regionEntries.indexOf(markerEntry);
|
||||
final location = await delegate.editLocationByMap(context, clusterEntries, markerLocation, openingCollection);
|
||||
final location = await delegate.editLocationByMap(context, clusterEntries, markerLocation, openingCollection.copyWith());
|
||||
if (location != null) {
|
||||
if (markerIndex != -1) {
|
||||
_selectedIndexNotifier.value = markerIndex;
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -135,10 +135,12 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
|||
}
|
||||
|
||||
Future<void> _editLocation(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
|
||||
final location = await selectLocation(context, {targetEntry}, collection);
|
||||
if (location == null) return;
|
||||
final locationByEntry = await selectLocation(context, {targetEntry}, collection);
|
||||
if (locationByEntry == null) return;
|
||||
|
||||
await edit(context, targetEntry, () => targetEntry.editLocation(location));
|
||||
if (locationByEntry.containsKey(targetEntry)) {
|
||||
await edit(context, targetEntry, () => targetEntry.editLocation(locationByEntry[targetEntry]));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editTitleDescription(BuildContext context, AvesEntry targetEntry) async {
|
||||
|
|
|
@ -3,7 +3,6 @@ import 'dart:math';
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/entry/entry.dart';
|
||||
import 'package:aves/model/entry/extensions/multipage.dart';
|
||||
import 'package:aves/model/entry/extensions/props.dart';
|
||||
import 'package:aves/model/settings/enums/accessibility_animations.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
|
@ -232,6 +231,8 @@ class ViewerButtonRowContent extends StatefulWidget {
|
|||
class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
|
||||
final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null);
|
||||
|
||||
EntryActionDelegate get actionDelegate => widget.actionDelegate;
|
||||
|
||||
AvesEntry get mainEntry => widget.mainEntry;
|
||||
|
||||
AvesEntry get pageEntry => widget.pageEntry;
|
||||
|
@ -248,10 +249,13 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
final showOrientationActions = EntryActions.orientationActions.any((v) => actionDelegate.isVisible(appMode: appMode, action: v));
|
||||
final topLevelActions = widget.topLevelActions;
|
||||
final exportActions = widget.exportActions;
|
||||
final videoActions = widget.videoActions;
|
||||
final hasOverflowMenu = pageEntry.canRotate || pageEntry.canFlip || topLevelActions.isNotEmpty || exportActions.isNotEmpty || videoActions.isNotEmpty;
|
||||
|
||||
final hasOverflowMenu = showOrientationActions || topLevelActions.isNotEmpty || exportActions.isNotEmpty || videoActions.isNotEmpty;
|
||||
final animations = context.select<Settings, AccessibilityAnimations>((v) => v.accessibilityAnimations);
|
||||
return Selector<VideoConductor, AvesVideoController?>(
|
||||
selector: (context, vc) => vc.getController(pageEntry),
|
||||
|
@ -275,7 +279,7 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
|
|||
final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList();
|
||||
final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList();
|
||||
return [
|
||||
if (pageEntry.canRotate || pageEntry.canFlip) _buildRotateAndFlipMenuItems(context),
|
||||
if (showOrientationActions) _buildRotateAndFlipMenuItems(context),
|
||||
...topLevelActions.map((action) => _buildPopupMenuItem(context, action, videoController)),
|
||||
if (exportActions.isNotEmpty)
|
||||
PopupMenuExpansionPanel<EntryAction>(
|
||||
|
@ -311,7 +315,7 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
|
|||
_popupExpandedNotifier.value = null;
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(animations.popUpAnimationDelay * timeDilation);
|
||||
widget.actionDelegate.onActionSelected(context, action);
|
||||
actionDelegate.onActionSelected(context, action);
|
||||
},
|
||||
onCanceled: () {
|
||||
_popupExpandedNotifier.value = null;
|
||||
|
@ -340,14 +344,14 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
|
|||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry,
|
||||
videoController: videoController,
|
||||
actionDelegate: widget.actionDelegate,
|
||||
actionDelegate: actionDelegate,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<EntryAction> _buildPopupMenuItem(BuildContext context, EntryAction action, AvesVideoController? videoController) {
|
||||
var enabled = widget.actionDelegate.canApply(action);
|
||||
var enabled = actionDelegate.canApply(action);
|
||||
switch (action) {
|
||||
case EntryAction.videoCaptureFrame:
|
||||
enabled &= videoController?.canCaptureFrameNotifier.value ?? false;
|
||||
|
@ -406,7 +410,7 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
|
|||
clipBehavior: Clip.antiAlias,
|
||||
child: PopupMenuItem(
|
||||
value: action,
|
||||
enabled: widget.actionDelegate.canApply(action),
|
||||
enabled: actionDelegate.canApply(action),
|
||||
child: Tooltip(
|
||||
message: action.getText(context),
|
||||
child: Center(child: action.getIcon()),
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -23,6 +23,7 @@ enum LocationEditAction {
|
|||
chooseOnMap,
|
||||
copyItem,
|
||||
setCustom,
|
||||
importGpx,
|
||||
remove,
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ abstract class MobileServices {
|
|||
required ValueNotifier<LatLng?>? dotLocationNotifier,
|
||||
required ValueNotifier<double>? overlayOpacityNotifier,
|
||||
required MapOverlay? overlayEntry,
|
||||
required Set<List<LatLng>>? tracks,
|
||||
required UserZoomChangeCallback? onUserZoomChange,
|
||||
required MapTapCallback? onMapTap,
|
||||
required MarkerTapCallback<T>? onMarkerTap,
|
||||
|
|
|
@ -58,6 +58,7 @@ class PlatformMobileServices extends MobileServices {
|
|||
required ValueNotifier<ll.LatLng?>? dotLocationNotifier,
|
||||
required ValueNotifier<double>? overlayOpacityNotifier,
|
||||
required MapOverlay? overlayEntry,
|
||||
required Set<List<ll.LatLng>>? tracks,
|
||||
required UserZoomChangeCallback? onUserZoomChange,
|
||||
required MapTapCallback? onMapTap,
|
||||
required MarkerTapCallback<T>? onMarkerTap,
|
||||
|
@ -78,6 +79,7 @@ class PlatformMobileServices extends MobileServices {
|
|||
dotLocationNotifier: dotLocationNotifier,
|
||||
overlayOpacityNotifier: overlayOpacityNotifier,
|
||||
overlayEntry: overlayEntry,
|
||||
tracks: tracks,
|
||||
onUserZoomChange: onUserZoomChange,
|
||||
onMapTap: onMapTap,
|
||||
onMarkerTap: onMarkerTap,
|
||||
|
|
|
@ -22,6 +22,7 @@ class EntryGoogleMap<T> extends StatefulWidget {
|
|||
final ValueNotifier<ll.LatLng?>? dotLocationNotifier;
|
||||
final ValueNotifier<double>? overlayOpacityNotifier;
|
||||
final MapOverlay? overlayEntry;
|
||||
final Set<List<ll.LatLng>>? tracks;
|
||||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final MapTapCallback? onMapTap;
|
||||
final MarkerTapCallback<T>? onMarkerTap;
|
||||
|
@ -43,6 +44,7 @@ class EntryGoogleMap<T> extends StatefulWidget {
|
|||
required this.dotLocationNotifier,
|
||||
this.overlayOpacityNotifier,
|
||||
this.overlayEntry,
|
||||
this.tracks,
|
||||
this.onUserZoomChange,
|
||||
this.onMapTap,
|
||||
this.onMarkerTap,
|
||||
|
@ -164,6 +166,8 @@ class _EntryGoogleMapState<T> extends State<EntryGoogleMap<T>> {
|
|||
|
||||
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
|
||||
final overlayEntry = widget.overlayEntry;
|
||||
final tracks = widget.tracks;
|
||||
final trackColor = Theme.of(context).colorScheme.primary;
|
||||
return NullableValueListenableBuilder<ll.LatLng?>(
|
||||
valueListenable: widget.dotLocationNotifier,
|
||||
builder: (context, dotLocation, child) {
|
||||
|
@ -208,6 +212,16 @@ class _EntryGoogleMapState<T> extends State<EntryGoogleMap<T>> {
|
|||
zIndex: 1,
|
||||
)
|
||||
},
|
||||
polylines: {
|
||||
if (tracks != null)
|
||||
for (final track in tracks)
|
||||
Polyline(
|
||||
polylineId: PolylineId(track.hashCode.toString()),
|
||||
points: track.map(_toServiceLatLng).toList(),
|
||||
width: MapThemeData.trackWidth,
|
||||
color: trackColor,
|
||||
),
|
||||
},
|
||||
// TODO TLAD [geotiff] may use ground overlay instead when this is fixed: https://github.com/flutter/flutter/issues/26479
|
||||
tileOverlays: {
|
||||
if (overlayEntry != null && overlayEntry.canOverlay)
|
||||
|
@ -331,6 +345,7 @@ class _GoogleMap extends StatefulWidget {
|
|||
final MinMaxZoomPreference minMaxZoomPreference;
|
||||
final bool interactive;
|
||||
final Set<Marker> markers;
|
||||
final Set<Polyline> polylines;
|
||||
final Set<TileOverlay> tileOverlays;
|
||||
final CameraPositionCallback? onCameraMove;
|
||||
final VoidCallback? onCameraIdle;
|
||||
|
@ -344,6 +359,7 @@ class _GoogleMap extends StatefulWidget {
|
|||
required this.minMaxZoomPreference,
|
||||
required this.interactive,
|
||||
required this.markers,
|
||||
required this.polylines,
|
||||
required this.tileOverlays,
|
||||
required this.onCameraMove,
|
||||
required this.onCameraIdle,
|
||||
|
@ -414,6 +430,7 @@ class _GoogleMapState extends State<_GoogleMap> {
|
|||
myLocationEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
markers: widget.markers,
|
||||
polylines: widget.polylines,
|
||||
tileOverlays: widget.tileOverlays,
|
||||
onCameraMove: widget.onCameraMove,
|
||||
onCameraIdle: widget.onCameraIdle,
|
||||
|
|
|
@ -30,6 +30,7 @@ class PlatformMobileServices extends MobileServices {
|
|||
required ValueNotifier<LatLng?>? dotLocationNotifier,
|
||||
required ValueNotifier<double>? overlayOpacityNotifier,
|
||||
required MapOverlay? overlayEntry,
|
||||
required Set<List<LatLng>>? tracks,
|
||||
required UserZoomChangeCallback? onUserZoomChange,
|
||||
required MapTapCallback? onMapTap,
|
||||
required MarkerTapCallback<T>? onMarkerTap,
|
||||
|
|
16
pubspec.lock
16
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:
|
||||
|
|
|
@ -90,6 +90,7 @@ dependencies:
|
|||
flutter_markdown:
|
||||
flutter_staggered_animations:
|
||||
get_it:
|
||||
gpx:
|
||||
http:
|
||||
intl:
|
||||
latlong2:
|
||||
|
|
Loading…
Reference in a new issue