diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b058d5c0..a24f387aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,17 @@ All notable changes to this project will be documented in this file. ### Added -- Map: OpenTopoMap layer +- Map: OpenTopoMap raster layer +- Map: OSM Liberty vector layer (hosted by OSM Americana) +- Interoperability: receiving `geo:` URI generally opens map page at location +- Interoperability: receiving `geo:` URI when editing item location fills in coordinates +- Map basic app shortcut - Enterprise: support for work profile switching from the drawer +### Removed + +- `Safe mode` basic app shortcut + ## [v1.11.13] - 2024-09-17 ### Added diff --git a/lib/geo/uri.dart b/lib/geo/uri.dart new file mode 100644 index 000000000..5374bc9cd --- /dev/null +++ b/lib/geo/uri.dart @@ -0,0 +1,26 @@ +import 'package:latlong2/latlong.dart'; + +// e.g. `geo:44.4361283,26.1027248?z=4.0(Bucharest)` +// cf https://en.wikipedia.org/wiki/Geo_URI_scheme +// cf https://developer.android.com/guide/components/intents-common#ViewMap +(LatLng, double?)? parseGeoUri(String? uri) { + if (uri != null) { + final geoUri = Uri.tryParse(uri); + if (geoUri != null) { + final coordinates = geoUri.path.split(','); + if (coordinates.length == 2) { + final lat = double.tryParse(coordinates[0]); + final lon = double.tryParse(coordinates[1]); + if (lat != null && lon != null) { + double? zoom; + final zoomString = geoUri.queryParameters['z']; + if (zoomString != null) { + zoom = double.tryParse(zoomString); + } + return (LatLng(lat, lon), zoom); + } + } + } + } + return null; +} diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 4af3dbb6d..1f93b682f 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -3,7 +3,9 @@ import 'dart:math'; import 'package:aves/app_flavor.dart'; import 'package:aves/app_mode.dart'; +import 'package:aves/geo/uri.dart'; import 'package:aves/l10n/l10n.dart'; +import 'package:aves/model/app/intent.dart'; import 'package:aves/model/app_inventory.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/filters/recent.dart'; @@ -33,6 +35,7 @@ import 'package:aves/widgets/common/providers/durations_provider.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/viewer_entry_provider.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_location_dialog.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/navigation/tv_page_transitions.dart'; import 'package:aves/widgets/navigation/tv_rail.dart'; @@ -42,11 +45,13 @@ import 'package:aves_utils/aves_utils.dart'; import 'package:collection/collection.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:equatable/equatable.dart'; +import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localization_nn/flutter_localization_nn.dart'; import 'package:intl/intl.dart'; +import 'package:latlong2/latlong.dart'; import 'package:overlay_support/overlay_support.dart'; import 'package:provider/provider.dart'; import 'package:screen_brightness/screen_brightness.dart'; @@ -88,6 +93,8 @@ class AvesApp extends StatefulWidget { static ScreenBrightness? get screenBrightness => _AvesAppState._screenBrightness; + static EventBus get intentEventBus => _AvesAppState._intentEventBus; + const AvesApp({ super.key, required this.flavor, @@ -159,7 +166,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { final MediaStoreSource _mediaStoreSource = MediaStoreSource(); Size? _screenSize; - final ValueNotifier _pageTransitionsBuilderNotifier = ValueNotifier(defaultPageTransitionsBuilder); + final ValueNotifier _pageTransitionsBuilderNotifier = ValueNotifier(_defaultPageTransitionsBuilder); final ValueNotifier _tvMediaQueryModifierNotifier = ValueNotifier(null); final ValueNotifier _appModeNotifier = ValueNotifier(AppMode.initialization); @@ -176,10 +183,11 @@ class _AvesAppState extends State with WidgetsBindingObserver { // - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28 // - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above (default in Flutter v3.22.0) // - `PredictiveBackPageTransitionsBuilder` for Android 15 / API 35 intra-app predictive back - static const defaultPageTransitionsBuilder = FadeUpwardsPageTransitionsBuilder(); + static const _defaultPageTransitionsBuilder = FadeUpwardsPageTransitionsBuilder(); static final GlobalKey _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); static ScreenBrightness? _screenBrightness; static bool _exitedMainByPop = false; + static final EventBus _intentEventBus = EventBus(); @override void initState() { @@ -534,7 +542,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { await windowService.requestOrientation(Orientation.landscape); } } else { - _pageTransitionsBuilderNotifier.value = defaultPageTransitionsBuilder; + _pageTransitionsBuilderNotifier.value = _defaultPageTransitionsBuilder; _tvMediaQueryModifierNotifier.value = null; await windowService.requestOrientation(null); } @@ -627,6 +635,17 @@ class _AvesAppState extends State with WidgetsBindingObserver { ]); } + // at this level `ModalRoute.of(context)` is null, + // so we use the global navigator as a workaround + String? getCurrentRouteName() { + String? currentRoute; + _navigatorKey.currentState?.popUntil((route) { + currentRoute = route.settings.name; + return true; + }); + return currentRoute; + } + void _onNewIntent(Map? intentData) { reportService.log('New intent data=$intentData'); @@ -641,6 +660,20 @@ class _AvesAppState extends State with WidgetsBindingObserver { } } + if (intentData != null) { + final intentAction = intentData[IntentDataKeys.action] as String?; + if (intentAction == IntentActions.viewGeo) { + final locationZoom = parseGeoUri(intentData[IntentDataKeys.uri] as String?); + if (locationZoom != null && getCurrentRouteName() == EditEntryLocationDialog.routeName) { + // do not push a new route but pass the provided location to the dialog + final location = locationZoom.$1; + debugPrint('Use received location $location for input'); + _intentEventBus.fire(LocationReceivedEvent(location)); + return; + } + } + } + _navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute( settings: const RouteSettings(name: HomePage.routeName), builder: (_) => _getFirstPage(intentData: intentData), @@ -688,3 +721,9 @@ class AvesScrollBehavior extends MaterialScrollBehavior { } typedef TvMediaQueryModifier = MediaQueryData Function(MediaQueryData); + +class LocationReceivedEvent { + final LatLng location; + + const LocationReceivedEvent(this.location); +} diff --git a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart index 39e98aae9..a15329c15 100644 --- a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/entry/extensions/metadata_edition.dart'; @@ -10,6 +12,7 @@ import 'package:aves/theme/durations.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/basic/text_dropdown_button.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/transitions.dart'; @@ -42,6 +45,7 @@ class EditEntryLocationDialog extends StatefulWidget { } class _EditEntryLocationDialogState extends State { + final List _subscriptions = []; LocationEditAction _action = LocationEditAction.chooseOnMap; LatLng? _mapCoordinates; late AvesEntry _copyItemSource; @@ -56,6 +60,7 @@ class _EditEntryLocationDialogState extends State { _initMapCoordinates(); _initCopyItem(); _initCustom(); + AvesApp.intentEventBus.on().listen((event) => _setCustomLocation(event.location)); } void _initMapCoordinates() { @@ -82,6 +87,9 @@ class _EditEntryLocationDialogState extends State { @override void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); _latitudeController.dispose(); _longitudeController.dispose(); _isValidNotifier.dispose(); @@ -151,8 +159,6 @@ class _EditEntryLocationDialogState extends State { } Widget _buildChooseOnMapContent(BuildContext context) { - final l10n = context.l10n; - return Padding( padding: const EdgeInsetsDirectional.only(start: 16, end: 8), child: Row( @@ -162,13 +168,21 @@ class _EditEntryLocationDialogState extends State { IconButton( icon: const Icon(AIcons.map), onPressed: _pickLocation, - tooltip: l10n.editEntryLocationDialogChooseOnMap, + tooltip: context.l10n.editEntryLocationDialogChooseOnMap, ), ], ), ); } + void _setCustomLocation(LatLng latLng) { + _latitudeController.text = coordinateFormatter.format(latLng.latitude); + _longitudeController.text = coordinateFormatter.format(latLng.longitude); + _action = LocationEditAction.setCustom; + _validate(); + setState(() {}); + } + CollectionLens? _createPickCollection() { final baseCollection = widget.collection; return baseCollection != null diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 3c86163d1..08612c633 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; +import 'package:aves/geo/uri.dart'; import 'package:aves/model/app/intent.dart'; import 'package:aves/model/app/permissions.dart'; import 'package:aves/model/app_inventory.dart'; @@ -65,7 +66,7 @@ class _HomePageState extends State { String? _initialRouteName, _initialSearchQuery; Set? _initialFilters; String? _initialExplorerPath; - (LatLng, double)? _initialLocationZoom; + (LatLng, double?)? _initialLocationZoom; List? _secureUris; static const allowedShortcutRoutes = [ @@ -126,26 +127,11 @@ class _HomePageState extends State { case IntentActions.viewGeo: error = true; if (intentUri != null) { - final geoUri = Uri.tryParse(intentUri); - if (geoUri != null) { - // e.g. `geo:44.4361283,26.1027248?z=4.0(Bucharest)` - // cf https://en.wikipedia.org/wiki/Geo_URI_scheme - // cf https://developer.android.com/guide/components/intents-common#ViewMap - final coordinates = geoUri.path.split(','); - if (coordinates.length == 2) { - final lat = double.tryParse(coordinates[0]); - final lon = double.tryParse(coordinates[1]); - if (lat != null && lon != null) { - double? zoom; - final zoomString = geoUri.queryParameters['z']; - if (zoomString != null) { - zoom = double.tryParse(zoomString); - } - _initialRouteName = MapPage.routeName; - _initialLocationZoom = (LatLng(lat, lon), zoom ?? settings.infoMapZoom); - error = false; - } - } + final locationZoom = parseGeoUri(intentUri); + if (locationZoom != null) { + _initialRouteName = MapPage.routeName; + _initialLocationZoom = locationZoom; + error = false; } } break; @@ -228,7 +214,6 @@ class _HomePageState extends State { context.read>().value = appMode; unawaited(reportService.setCustomKey('app_mode', appMode.toString())); - debugPrint('Storage check complete in ${stopwatch.elapsed.inMilliseconds}ms'); switch (appMode) { case AppMode.main: @@ -273,6 +258,8 @@ class _HomePageState extends State { break; } + debugPrint('Home setup complete in ${stopwatch.elapsed.inMilliseconds}ms'); + // `pushReplacement` is not enough in some edge cases // e.g. when opening the viewer in `view` mode should replace a viewer in `main` mode unawaited(Navigator.maybeOf(context)?.pushAndRemoveUntil( diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index e4e94f46d..8e123b58b 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -277,6 +277,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { void _onActionDelegateEvent(ActionEvent event) { Future.delayed(ADurations.dialogTransitionLoose).then((_) { + if (!mounted) return; if (event is ActionStartedEvent) { _isEditingMetadataNotifier.value = event.action; } else if (event is ActionEndedEvent) {