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'; class EditEntryLocationDialog extends StatefulWidget { static const routeName = '/dialog/edit_entry_location'; final Set entries; final CollectionLens? collection; const EditEntryLocationDialog({ super.key, required this.entries, this.collection, }); @override State createState() => _EditEntryLocationDialogState(); } class _EditEntryLocationDialogState extends State with FeedbackMixin { final Set _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); late NumberFormat coordinateFormatter; static const _minTimeToGpxPoint = Duration(hours: 1); @override void initState() { super.initState(); final entries = widget.entries; mainEntry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first; _mapCoordinates = mainEntry.latLng; _copyItemSource = mainEntry; WidgetsBinding.instance.addPostFrameCallback((_) { coordinateFormatter = NumberFormat('0.000000', context.locale); final latLng = mainEntry.latLng; if (latLng != null) { _latitudeController.text = coordinateFormatter.format(latLng.latitude); _longitudeController.text = coordinateFormatter.format(latLng.longitude); } else { _latitudeController.text = ''; _longitudeController.text = ''; } setState(_validate); }); _subscriptions.add(AvesApp.intentEventBus.on().listen((event) => _setCustomLocation(event.location))); } @override void dispose() { _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); _latitudeController.dispose(); _longitudeController.dispose(); _isValidNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MediaQueryDataProvider( child: TooltipTheme( data: TooltipTheme.of(context).copyWith( preferBelow: false, ), child: Builder(builder: (context) { final l10n = context.l10n; return AvesDialog( title: l10n.editEntryLocationDialogTitle, scrollableContent: [ Padding( padding: const EdgeInsets.only(left: 16, top: 8, right: 16), child: TextDropdownButton( values: LocationEditAction.values, valueText: (v) => v.getText(context), value: _action, onChanged: (v) => setState(() { _action = v!; _validate(); }), isExpanded: true, dropdownColor: Themes.thirdLayerColor(context), ), ), AnimatedSwitcher( duration: context.read().formTransition, switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, transitionBuilder: AvesTransitions.formTransitionBuilder, child: KeyedSubtree( key: ValueKey(_action), child: _buildContent(), ), ), const SizedBox(height: 8), ], actions: [ const CancelButton(), ValueListenableBuilder( valueListenable: _isValidNotifier, builder: (context, isValid, child) { return TextButton( onPressed: isValid ? () => _submit(context) : null, child: Text(l10n.applyButtonLabel), ); }, ), ], ); }), ), ); } 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: _coordinatesText(context, _mapCoordinates)), const SizedBox(width: 8), IconButton( icon: const Icon(AIcons.map), onPressed: _pickLocation, tooltip: context.l10n.editEntryLocationDialogChooseOnMap, ), ], ), ); } void _setCustomLocation(LatLng latLng) { _latitudeController.text = coordinateFormatter.format(latLng.latitude); _longitudeController.text = coordinateFormatter.format(latLng.longitude); _action = LocationEditAction.setCustom; setState(_validate); } CollectionLens? _createPickCollection() { final baseCollection = widget.collection; return baseCollection != null ? CollectionLens( source: baseCollection.source, filters: { ...baseCollection.filters.whereNot((filter) => filter == LocationFilter.unlocated), LocationFilter.located, }, ) : null; } Future _pickLocation() async { final pickCollection = _createPickCollection(); final latLng = await Navigator.maybeOf(context)?.push( MaterialPageRoute( settings: const RouteSettings(name: LocationPickPage.routeName), builder: (context) => LocationPickPage( collection: pickCollection, initialLocation: _mapCoordinates, ), fullscreenDialog: true, ), ); if (latLng != null) { settings.mapDefaultCenter = latLng; setState(() { _mapCoordinates = latLng; _validate(); }); } } Widget _buildCopyItemContent(BuildContext context) { return Padding( padding: const EdgeInsetsDirectional.only(start: 16, end: 8), child: Row( children: [ Expanded(child: _coordinatesText(context, _copyItemSource.latLng)), const SizedBox(width: 8), ItemPicker( extent: 48, entry: _copyItemSource, onTap: _pickCopyItemSource, ), ], ), ); } Future _pickCopyItemSource() async { final pickCollection = _createPickCollection(); if (pickCollection == null) return; final entry = await Navigator.maybeOf(context)?.push( MaterialPageRoute( settings: const RouteSettings(name: ItemPickPage.routeName), builder: (context) => ItemPickPage( collection: pickCollection, canRemoveFilters: true, ), fullscreenDialog: true, ), ); if (entry != null) { setState(() { _copyItemSource = entry; _validate(); }); } } Widget _buildSetCustomContent(BuildContext context) { final l10n = context.l10n; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( children: [ TextField( controller: _latitudeController, decoration: InputDecoration( labelText: l10n.editEntryLocationDialogLatitude, hintText: coordinateFormatter.format(PointsOfInterest.pointNemo.latitude), ), onChanged: (_) => _validate(), ), TextField( controller: _longitudeController, decoration: InputDecoration( labelText: l10n.editEntryLocationDialogLongitude, hintText: coordinateFormatter.format(PointsOfInterest.pointNemo.longitude), ), onChanged: (_) => _validate(), ), ], ), ), ], ), ); } 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 _unknownText(context); } } LatLng? _parseLatLng() { double? tryParse(String text) { try { return double.tryParse(text) ?? (coordinateFormatter.parse(text).toDouble()); } catch (error) { // ignore return null; } } final lat = tryParse(_latitudeController.text); final lng = tryParse(_longitudeController.text); if (lat == null || lng == null) return null; if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; return LatLng(lat, lng); } void _validate() { switch (_action) { case LocationEditAction.chooseOnMap: _isValidNotifier.value = _mapCoordinates != null; case LocationEditAction.copyItem: _isValidNotifier.value = _copyItemSource.hasGps; case LocationEditAction.setCustom: _isValidNotifier.value = _parseLatLng() != null; case LocationEditAction.importGpx: _isValidNotifier.value = _gpxMap.isNotEmpty; case LocationEditAction.remove: _isValidNotifier.value = true; } } 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: addLocationForAllEntries(_mapCoordinates); case LocationEditAction.copyItem: addLocationForAllEntries(_copyItemSource.latLng); case LocationEditAction.setCustom: addLocationForAllEntries(_parseLatLng()); case LocationEditAction.importGpx: result.addAll(_gpxMap); case LocationEditAction.remove: addLocationForAllEntries(ExtraAvesEntryMetadataEdition.removalLocation); } navigator?.pop(result); } } typedef LocationEditActionResult = Map;