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) {