diff --git a/CHANGELOG.md b/CHANGELOG.md index 48eae08ac..07fbaf824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Map: create shortcut to custom region and filters + ### Fixed - crash when loading large collection diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 253f9c770..0bf7591ee 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -314,6 +314,7 @@ open class MainActivity : FlutterFragmentActivity() { return hashMapOf( INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW_GEO, INTENT_DATA_KEY_URI to uri.toString(), + INTENT_DATA_KEY_FILTERS to extractFiltersFromIntent(intent), ) } @@ -584,6 +585,8 @@ open class MainActivity : FlutterFragmentActivity() { // dart page routes const val COLLECTION_PAGE_ROUTE_NAME = "/collection" + const val ENTRY_VIEWER_PAGE_ROUTE_NAME = "/viewer" + const val EXPLORER_PAGE_ROUTE_NAME = "/explorer" const val MAP_PAGE_ROUTE_NAME = "/map" const val SEARCH_PAGE_ROUTE_NAME = "/search" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 65c3f38a3..c29b8718f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -23,11 +23,15 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.request.RequestOptions import deckers.thibault.aves.MainActivity +import deckers.thibault.aves.MainActivity.Companion.COLLECTION_PAGE_ROUTE_NAME +import deckers.thibault.aves.MainActivity.Companion.ENTRY_VIEWER_PAGE_ROUTE_NAME +import deckers.thibault.aves.MainActivity.Companion.EXPLORER_PAGE_ROUTE_NAME import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_EXPLORER_PATH import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_ARRAY import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_FILTERS_STRING import deckers.thibault.aves.MainActivity.Companion.EXTRA_KEY_PAGE import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR +import deckers.thibault.aves.MainActivity.Companion.MAP_PAGE_ROUTE_NAME import deckers.thibault.aves.R import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend @@ -354,12 +358,17 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { // shortcuts private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) { + // common arguments val label = call.argument("label") val iconBytes = call.argument("iconBytes") + val route = call.argument("route") + // route dependent arguments val filters = call.argument>("filters") - val explorerPath = call.argument("explorerPath") - val uri = call.argument("uri")?.let { Uri.parse(it) } - if (label == null) { + val explorerPath = call.argument("path") + val viewUri = call.argument("viewUri")?.let { Uri.parse(it) } + val geoUri = call.argument("geoUri")?.let { Uri.parse(it) } + + if (label == null || route == null) { result.error("pin-args", "missing arguments", null) return } @@ -383,24 +392,60 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { // so that foreground is rendered at the intended scale val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection) + val resId = when (route) { + MAP_PAGE_ROUTE_NAME -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_map else R.drawable.ic_shortcut_map + else -> if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection + } + icon = IconCompat.createWithResource(context, resId) } - val intent = when { - filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java) - .putExtra(EXTRA_KEY_PAGE, "/collection") - .putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray()) - // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut - // so we use a joined `String` as fallback - .putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR)) + val intent: Intent = when (route) { + COLLECTION_PAGE_ROUTE_NAME -> { + if (filters == null) { + result.error("pin-filters", "collection shortcut requires filters", null) + return + } + Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java) + .putExtra(EXTRA_KEY_PAGE, route) + .putExtra(EXTRA_KEY_FILTERS_ARRAY, filters.toTypedArray()) + // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut + // so we use a joined `String` as fallback + .putExtra(EXTRA_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR)) + } - explorerPath != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java) - .putExtra(EXTRA_KEY_PAGE, "/explorer") - .putExtra(EXTRA_KEY_EXPLORER_PATH, explorerPath) + ENTRY_VIEWER_PAGE_ROUTE_NAME -> { + if (viewUri == null) { + result.error("pin-viewUri", "viewer shortcut requires URI", null) + return + } + Intent(Intent.ACTION_VIEW, viewUri, context, MainActivity::class.java) + } + + EXPLORER_PAGE_ROUTE_NAME -> { + Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java) + .putExtra(EXTRA_KEY_PAGE, route) + .putExtra(EXTRA_KEY_EXPLORER_PATH, explorerPath) + } + + MAP_PAGE_ROUTE_NAME -> { + if (geoUri == null) { + result.error("pin-geoUri", "map shortcut requires URI", null) + return + } + Intent(Intent.ACTION_VIEW, geoUri, context, MainActivity::class.java).apply { + putExtra(EXTRA_KEY_PAGE, route) + // filters are optional + filters?.let { + putExtra(EXTRA_KEY_FILTERS_ARRAY, it.toTypedArray()) + // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut + // so we use a joined `String` as fallback + putExtra(EXTRA_KEY_FILTERS_STRING, it.joinToString(EXTRA_STRING_ARRAY_SEPARATOR)) + } + } + } - uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java) else -> { - result.error("pin-intent", "failed to build intent", null) + result.error("pin-route", "unsupported shortcut route=$route", null) return } } diff --git a/lib/geo/uri.dart b/lib/geo/uri.dart index 5374bc9cd..3241d0a39 100644 --- a/lib/geo/uri.dart +++ b/lib/geo/uri.dart @@ -1,3 +1,4 @@ +import 'package:aves/utils/math_utils.dart'; import 'package:latlong2/latlong.dart'; // e.g. `geo:44.4361283,26.1027248?z=4.0(Bucharest)` @@ -24,3 +25,13 @@ import 'package:latlong2/latlong.dart'; } return null; } + +String toGeoUri(LatLng latLng, {double? zoom}) { + final latitude = roundToPrecision(latLng.latitude, decimals: 6); + final longitude = roundToPrecision(latLng.longitude, decimals: 6); + var uri = 'geo:$latitude,$longitude?q=$latitude,$longitude'; + if (zoom != null) { + uri += '&z=$zoom'; + } + return uri; +} diff --git a/lib/model/app/contributors.dart b/lib/model/app/contributors.dart index c56ab4269..36f4a4522 100644 --- a/lib/model/app/contributors.dart +++ b/lib/model/app/contributors.dart @@ -106,6 +106,7 @@ class Contributors { Contributor('splice11', 'trenchedgrandpa@protonmail.com'), Contributor('Ihor Hordiichuk', 'igor_ck@outlook.com'), Contributor('João Palmeiro', 'joaommpalmeiro@gmail.com'), + Contributor('Whoever4976', 'wolffjonas47@gmail.com'), // Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali // Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese // Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart index 705cc9b2d..eee552b1c 100644 --- a/lib/services/app_service.dart +++ b/lib/services/app_service.dart @@ -1,11 +1,11 @@ import 'dart:async'; +import 'package:aves/geo/uri.dart'; import 'package:aves/model/app_inventory.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/services/common/services.dart'; -import 'package:aves/utils/math_utils.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; @@ -30,7 +30,15 @@ abstract class AppService { Future shareSingle(String uri, String mimeType); - Future pinToHomeScreen(String label, AvesEntry? coverEntry, {Set? filters, String? explorerPath, String? uri}); + Future pinToHomeScreen( + String label, + AvesEntry? coverEntry, { + required String route, + Set? filters, + String? path, + String? viewUri, + String? geoUri, + }); } class PlatformAppService implements AppService { @@ -138,13 +146,9 @@ class PlatformAppService implements AppService { @override Future openMap(LatLng latLng) async { - final latitude = roundToPrecision(latLng.latitude, decimals: 6); - final longitude = roundToPrecision(latLng.longitude, decimals: 6); - final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude'; - try { final result = await _platform.invokeMethod('openMap', { - 'geoUri': geoUri, + 'geoUri': toGeoUri(latLng), }); if (result != null) return result as bool; } on PlatformException catch (e, stack) { @@ -203,7 +207,15 @@ class PlatformAppService implements AppService { // app shortcuts @override - Future pinToHomeScreen(String label, AvesEntry? coverEntry, {Set? filters, String? explorerPath, String? uri}) async { + Future pinToHomeScreen( + String label, + AvesEntry? coverEntry, { + required String route, + Set? filters, + String? path, + String? viewUri, + String? geoUri, + }) async { Uint8List? iconBytes; if (coverEntry != null) { final size = coverEntry.isVideo ? 0.0 : 256.0; @@ -221,9 +233,11 @@ class PlatformAppService implements AppService { await _platform.invokeMethod('pinShortcut', { 'label': label, 'iconBytes': iconBytes, + 'route': route, 'filters': filters?.map((filter) => filter.toJson()).toList(), - 'explorerPath': explorerPath, - 'uri': uri, + 'path': path, + 'viewUri': viewUri, + 'geoUri': geoUri, }); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); diff --git a/lib/view/src/actions/map.dart b/lib/view/src/actions/map.dart index c4d22a684..5e521a09f 100644 --- a/lib/view/src/actions/map.dart +++ b/lib/view/src/actions/map.dart @@ -11,6 +11,7 @@ extension ExtraMapActionView on MapAction { MapAction.openMapApp => l10n.entryActionOpenMap, MapAction.zoomIn => l10n.mapZoomInTooltip, MapAction.zoomOut => l10n.mapZoomOutTooltip, + MapAction.addShortcut => l10n.collectionActionAddShortcut, }; } @@ -22,6 +23,7 @@ extension ExtraMapActionView on MapAction { MapAction.openMapApp => AIcons.openOutside, MapAction.zoomIn => AIcons.zoomIn, MapAction.zoomOut => AIcons.zoomOut, + MapAction.addShortcut => AIcons.addShortcut, }; } } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index e9cf454bd..e6eb16c1d 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -26,6 +26,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/themes.dart'; import 'package:aves/utils/collection_utils.dart'; import 'package:aves/utils/mime_utils.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -746,7 +747,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final (coverEntry, name) = result; if (name.isEmpty) return; - await appService.pinToHomeScreen(name, coverEntry, filters: filters); + await appService.pinToHomeScreen(name, coverEntry, route: CollectionPage.routeName, filters: filters); if (!device.showPinShortcutFeedback) { showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback); } diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 9f0108004..357a3b759 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -423,17 +423,20 @@ class _AvesFilterChipState extends State { ); final animate = context.select((v) => v.animate); - if (animate && (widget.heroType == HeroType.always || widget.heroType == HeroType.onTap && _tapped)) { - chip = Hero( - tag: filter, - transitionOnUserGestures: true, - child: MediaQueryDataProvider( - child: DefaultTextStyle( - style: const TextStyle(), - child: chip, + if (animate) { + final heroType = widget.heroType; + if (heroType == HeroType.always || (heroType == HeroType.onTap && _tapped)) { + chip = Hero( + tag: filter, + transitionOnUserGestures: true, + child: MediaQueryDataProvider( + child: DefaultTextStyle( + style: const TextStyle(), + child: chip, + ), ), - ), - ); + ); + } } return chip; } diff --git a/lib/widgets/common/map/buttons/button.dart b/lib/widgets/common/map/buttons/button.dart index f939cf96b..0d3f6da7a 100644 --- a/lib/widgets/common/map/buttons/button.dart +++ b/lib/widgets/common/map/buttons/button.dart @@ -4,19 +4,31 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class MapOverlayButton extends StatelessWidget { - final Key? buttonKey; - final Widget icon; - final String tooltip; - final VoidCallback? onPressed; + final ValueWidgetBuilder builder; const MapOverlayButton({ super.key, - this.buttonKey, - required this.icon, - required this.tooltip, - required this.onPressed, + required this.builder, }); + factory MapOverlayButton.icon({ + Key? buttonKey, + required Widget icon, + required String tooltip, + VoidCallback? onPressed, + }) { + return MapOverlayButton( + builder: (context, visualDensity, child) => IconButton( + key: buttonKey, + iconSize: iconSize(visualDensity), + visualDensity: visualDensity, + icon: icon, + onPressed: onPressed, + tooltip: tooltip, + ), + ); + } + @override Widget build(BuildContext context) { return Selector>( @@ -27,15 +39,10 @@ class MapOverlayButton extends StatelessWidget { ), child: Selector( selector: (context, v) => v.visualDensity, - builder: (context, visualDensity, child) => IconButton( - key: buttonKey, - iconSize: 20 + 1.5 * visualDensity.horizontal, - visualDensity: visualDensity, - icon: icon, - onPressed: onPressed, - tooltip: tooltip, - ), + builder: builder, ), ); } + + static double iconSize(VisualDensity visualDensity) => 20 + 1.5 * visualDensity.horizontal; } diff --git a/lib/widgets/common/map/buttons/panel.dart b/lib/widgets/common/map/buttons/panel.dart index b57ce502f..52ded17e4 100644 --- a/lib/widgets/common/map/buttons/panel.dart +++ b/lib/widgets/common/map/buttons/panel.dart @@ -1,7 +1,9 @@ +import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/view/view.dart'; +import 'package:aves/widgets/common/basic/popup/menu_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/map/buttons/button.dart'; import 'package:aves/widgets/common/map/buttons/coordinate_filter.dart'; @@ -10,24 +12,44 @@ import 'package:aves/widgets/common/map/map_action_delegate.dart'; import 'package:aves_map/aves_map.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -class MapButtonPanel extends StatelessWidget { - final AvesMapController? controller; +class MapButtonPanel extends StatefulWidget { + final AvesMapController controller; final ValueNotifier boundsNotifier; final void Function(BuildContext context)? openMapPage; - final VoidCallback? resetRotation; const MapButtonPanel({ super.key, required this.controller, required this.boundsNotifier, this.openMapPage, - this.resetRotation, }); + @override + State createState() => _MapButtonPanelState(); +} + +class _MapButtonPanelState extends State { + late MapActionDelegate _actionDelegate; + + @override + void initState() { + super.initState(); + _updateDelegate(); + } + + @override + void didUpdateWidget(covariant MapButtonPanel oldWidget) { + super.didUpdateWidget(oldWidget); + _updateDelegate(); + } + + void _updateDelegate() => _actionDelegate = MapActionDelegate(widget.controller); + @override Widget build(BuildContext context) { final iconTheme = IconTheme.of(context); @@ -37,23 +59,24 @@ class MapButtonPanel extends StatelessWidget { switch (context.select((v) => v.navigationButton)) { case MapNavigationButton.back: if (!settings.useTvLayout) { - navigationButton = MapOverlayButton( + navigationButton = MapOverlayButton.icon( icon: const BackButtonIcon(), onPressed: () => Navigator.maybeOf(context)?.pop(), tooltip: MaterialLocalizations.of(context).backButtonTooltip, ); } case MapNavigationButton.close: - navigationButton = MapOverlayButton( + navigationButton = MapOverlayButton.icon( icon: const CloseButtonIcon(), onPressed: SystemNavigator.pop, tooltip: MaterialLocalizations.of(context).closeButtonTooltip, ); case MapNavigationButton.map: - if (openMapPage != null) { - navigationButton = MapOverlayButton( + final _openMapPage = widget.openMapPage; + if (_openMapPage != null) { + navigationButton = MapOverlayButton.icon( icon: const Icon(AIcons.showFullscreenCorners), - onPressed: () => openMapPage?.call(context), + onPressed: () => _openMapPage.call(context), tooltip: context.l10n.openMapPageTooltip, ); } @@ -65,6 +88,11 @@ class MapButtonPanel extends StatelessWidget { final visualDensity = context.select((v) => v.visualDensity); final double padding = 8 + visualDensity.horizontal * 2; + final actions = [ + MapAction.openMapApp, + MapAction.addShortcut, + ].where((action) => _actionDelegate.isVisible(context, action)).toList(); + return Positioned.fill( child: TooltipTheme( data: TooltipTheme.of(context).copyWith( @@ -90,7 +118,7 @@ class MapButtonPanel extends StatelessWidget { SizedBox(height: padding), ], ValueListenableBuilder( - valueListenable: boundsNotifier, + valueListenable: widget.boundsNotifier, builder: (context, bounds, child) { final degrees = bounds.rotation; final opacity = degrees == 0 ? .0 : 1.0; @@ -99,7 +127,7 @@ class MapButtonPanel extends StatelessWidget { child: AnimatedOpacity( opacity: opacity, duration: context.select((v) => v.viewerOverlayAnimation), - child: MapOverlayButton( + child: MapOverlayButton.icon( icon: Transform( origin: iconSize.center(Offset.zero), transform: Matrix4.rotationZ(degToRadian(degrees)), @@ -110,7 +138,7 @@ class MapButtonPanel extends StatelessWidget { size: iconSize, ), ), - onPressed: () => resetRotation?.call(), + onPressed: widget.controller.resetRotation, tooltip: context.l10n.mapPointNorthUpTooltip, ), ), @@ -123,7 +151,7 @@ class MapButtonPanel extends StatelessWidget { showCoordinateFilter ? Expanded( child: OverlayCoordinateFilterChip( - boundsNotifier: boundsNotifier, + boundsNotifier: widget.boundsNotifier, padding: padding, ), ) @@ -133,8 +161,31 @@ class MapButtonPanel extends StatelessWidget { // key is expected by test driver child: Column( children: [ - _buildActionButton(context, MapAction.openMapApp), + if (actions.length == 1) _buildActionButton(context, actions.first), + if (actions.length > 1) + MapOverlayButton(builder: (context, visualDensity, child) { + final animations = context.read().accessibilityAnimations; + return PopupMenuButton( + itemBuilder: (context) => actions + .map((action) => PopupMenuItem( + value: action, + child: MenuRow( + text: action.getText(context), + icon: action.getIcon(), + ), + )) + .toList(), + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(animations.popUpAnimationDelay * timeDilation); + _actionDelegate.onActionSelected(context, action); + }, + iconSize: MapOverlayButton.iconSize(visualDensity), + popUpAnimationStyle: animations.popUpAnimationStyle, + ); + }), SizedBox(height: padding), + // key is expected by test driver _buildActionButton(context, MapAction.selectStyle, buttonKey: const Key('map-menu-layers')), ], ), @@ -161,10 +212,10 @@ class MapButtonPanel extends StatelessWidget { ); } - Widget _buildActionButton(BuildContext context, MapAction action, {Key? buttonKey}) => MapOverlayButton( + Widget _buildActionButton(BuildContext context, MapAction action, {Key? buttonKey}) => MapOverlayButton.icon( buttonKey: buttonKey, icon: action.getIcon(), - onPressed: () => MapActionDelegate(controller).onActionSelected(context, action), + onPressed: () => _actionDelegate.onActionSelected(context, action), tooltip: action.getText(context), ); } diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 486a13a9e..7e61d2963 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -33,7 +33,7 @@ import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; class GeoMap extends StatefulWidget { - final AvesMapController? controller; + final AvesMapController controller; final CollectionLens? collection; final List? entries; final Size availableSize; @@ -60,7 +60,7 @@ class GeoMap extends StatefulWidget { const GeoMap({ super.key, - this.controller, + required this.controller, this.collection, this.entries, required this.availableSize, @@ -124,10 +124,7 @@ class _GeoMapState extends State { void _registerWidget(GeoMap widget) { widget.collection?.addListener(_onCollectionChanged); - final controller = widget.controller; - if (controller != null) { - _subscriptions.add(controller.markerLocationChanges.listen((event) => _onCollectionChanged())); - } + _subscriptions.add(widget.controller.markerLocationChanges.listen((event) => _onCollectionChanged())); } void _unregisterWidget(GeoMap widget) { @@ -164,7 +161,6 @@ class _GeoMapState extends State { ); bool _isMarkerImageReady(MarkerKey key) => key.entry.isThumbnailReady(extent: MapThemeData.markerImageExtent); - final controller = widget.controller; Widget child = const SizedBox(); if (mapStyle != null) { switch (mapStyle) { @@ -172,7 +168,7 @@ class _GeoMapState extends State { case EntryMapStyle.googleHybrid: case EntryMapStyle.googleTerrain: child = mobileServices.buildMap( - controller: controller, + controller: widget.controller, clusterListenable: _clusterChangeNotifier, boundsNotifier: _boundsNotifier, style: mapStyle, @@ -194,7 +190,7 @@ class _GeoMapState extends State { case EntryMapStyle.osmHot: case EntryMapStyle.stamenWatercolor: child = EntryLeafletMap( - controller: controller, + controller: widget.controller, clusterListenable: _clusterChangeNotifier, boundsNotifier: _boundsNotifier, minZoom: 2, @@ -324,11 +320,7 @@ class _GeoMapState extends State { Widget replacement = Stack( children: [ const MapDecorator(), - MapButtonPanel( - controller: controller, - boundsNotifier: _boundsNotifier, - openMapPage: widget.openMapPage, - ), + _buildButtonPanel(context), ], ); if (mapHeight != null) { @@ -560,13 +552,12 @@ class _GeoMapState extends State { Widget _decorateMap(BuildContext context, Widget? child) => MapDecorator(child: child); - Widget _buildButtonPanel(VoidCallback resetRotation) { + Widget _buildButtonPanel(BuildContext context) { if (settings.useTvLayout) return const SizedBox(); return MapButtonPanel( controller: widget.controller, boundsNotifier: _boundsNotifier, openMapPage: widget.openMapPage, - resetRotation: resetRotation, ); } } diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index 96f775e2b..10800040f 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -14,13 +14,13 @@ import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; class EntryLeafletMap extends StatefulWidget { - final AvesMapController? controller; + final AvesMapController controller; final Listenable clusterListenable; final ValueNotifier boundsNotifier; final double minZoom, maxZoom; final EntryMapStyle style; final TransitionBuilder decoratorBuilder; - final ButtonPanelBuilder buttonPanelBuilder; + final WidgetBuilder buttonPanelBuilder; final MarkerClusterBuilder markerClusterBuilder; final MarkerWidgetBuilder markerWidgetBuilder; final ValueNotifier? dotLocationNotifier; @@ -34,7 +34,7 @@ class EntryLeafletMap extends StatefulWidget { const EntryLeafletMap({ super.key, - this.controller, + required this.controller, required this.clusterListenable, required this.boundsNotifier, this.minZoom = 0, @@ -94,11 +94,10 @@ class _EntryLeafletMapState extends State> with TickerProv void _registerWidget(EntryLeafletMap widget) { final avesMapController = widget.controller; - if (avesMapController != null) { - _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng))); - _subscriptions.add(avesMapController.zoomCommands.listen((event) => _zoomBy(event.delta))); - } - _subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion())); + _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng))); + _subscriptions.add(avesMapController.zoomCommands.listen((event) => _zoomBy(event.delta))); + _subscriptions.add(avesMapController.rotationResetCommands.listen((_) => _resetRotation())); + _subscriptions.add(_leafletMapController.mapEventStream.listen((_) => _updateVisibleRegion())); widget.clusterListenable.addListener(_updateMarkers); widget.boundsNotifier.addListener(_onBoundsChanged); } @@ -116,7 +115,7 @@ class _EntryLeafletMapState extends State> with TickerProv return Stack( children: [ widget.decoratorBuilder(context, _buildMap()), - widget.buttonPanelBuilder(_resetRotation), + widget.buttonPanelBuilder(context), ], ); } @@ -240,7 +239,7 @@ class _EntryLeafletMapState extends State> with TickerProv void _onIdle() { if (!mounted) return; - widget.controller?.notifyIdle(bounds); + widget.controller.notifyIdle(bounds); _updateMarkers(); } diff --git a/lib/widgets/common/map/map_action_delegate.dart b/lib/widgets/common/map/map_action_delegate.dart index 5c3728e05..b2c1499a6 100644 --- a/lib/widgets/common/map/map_action_delegate.dart +++ b/lib/widgets/common/map/map_action_delegate.dart @@ -1,37 +1,94 @@ +import 'package:aves/geo/uri.dart'; +import 'package:aves/model/device.dart'; +import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/view/view.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/selection_dialogs/common.dart'; import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart'; +import 'package:aves/widgets/map/map_page.dart'; import 'package:aves_map/aves_map.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; -class MapActionDelegate { - final AvesMapController? controller; +class MapActionDelegate with FeedbackMixin { + final AvesMapController controller; const MapActionDelegate(this.controller); + bool isVisible(BuildContext context, MapAction action) { + switch (action) { + case MapAction.selectStyle: + case MapAction.openMapApp: + case MapAction.zoomIn: + case MapAction.zoomOut: + return true; + case MapAction.addShortcut: + return device.canPinShortcut && context.currentRouteName == MapPage.routeName; + } + } + void onActionSelected(BuildContext context, MapAction action) { switch (action) { case MapAction.selectStyle: - showSelectionDialog( - context: context, - builder: (context) => AvesSingleSelectionDialog( - initialValue: settings.mapStyle, - options: Map.fromEntries(availability.mapStyles.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.mapStyleDialogTitle, - ), - onSelection: (v) => settings.mapStyle = v, - ); + _selectStyle(context); case MapAction.openMapApp: OpenMapAppNotification().dispatch(context); case MapAction.zoomIn: - controller?.zoomBy(1); + controller.zoomBy(1); case MapAction.zoomOut: - controller?.zoomBy(-1); + controller.zoomBy(-1); + case MapAction.addShortcut: + _addShortcut(context); + } + } + + Future _selectStyle(BuildContext context) => showSelectionDialog( + context: context, + builder: (context) => AvesSingleSelectionDialog( + initialValue: settings.mapStyle, + options: Map.fromEntries(availability.mapStyles.map((v) => MapEntry(v, v.getName(context)))), + title: context.l10n.mapStyleDialogTitle, + ), + onSelection: (v) => settings.mapStyle = v, + ); + + Future _addShortcut(BuildContext context) async { + final idleBounds = controller.idleBounds; + if (idleBounds == null) { + showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback); + return; + } + + final collection = context.read(); + final result = await showDialog<(AvesEntry?, String)>( + context: context, + builder: (context) => AddShortcutDialog( + defaultName: '', + collection: collection, + ), + routeSettings: const RouteSettings(name: AddShortcutDialog.routeName), + ); + if (result == null) return; + + final (coverEntry, name) = result; + if (name.isEmpty) return; + + final geoUri = toGeoUri(idleBounds.projectedCenter, zoom: idleBounds.zoom); + await appService.pinToHomeScreen( + name, + coverEntry, + route: MapPage.routeName, + filters: collection.filters, + geoUri: geoUri, + ); + if (!device.showPinShortcutFeedback) { + showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback); } } } diff --git a/lib/widgets/explorer/explorer_action_delegate.dart b/lib/widgets/explorer/explorer_action_delegate.dart index eb98352e5..3942ac2dd 100644 --- a/lib/widgets/explorer/explorer_action_delegate.dart +++ b/lib/widgets/explorer/explorer_action_delegate.dart @@ -9,6 +9,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; +import 'package:aves/widgets/explorer/explorer_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; import 'package:aves/widgets/stats/stats_page.dart'; import 'package:aves_model/aves_model.dart'; @@ -84,7 +85,7 @@ class ExplorerActionDelegate with FeedbackMixin { final (coverEntry, name) = result; if (name.isEmpty) return; - await appService.pinToHomeScreen(name, coverEntry, explorerPath: filter.path); + await appService.pinToHomeScreen(name, coverEntry, route: ExplorerPage.routeName, path: filter.path); if (!device.showPinShortcutFeedback) { showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback); } diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 8bbe51b26..9335f675f 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -377,7 +377,10 @@ class _HomePageState extends State { return buildRoute((context) { final mapCollection = CollectionLens( source: source, - filters: {LocationFilter.located}, + filters: { + LocationFilter.located, + if (filters != null) ...filters, + }, ); return MapPage( collection: mapCollection, diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 388612406..0639e3739 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -5,8 +5,9 @@ import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/media/geotiff.dart'; +import 'package:aves/model/filters/location.dart'; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/media/geotiff.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/settings.dart'; @@ -62,10 +63,15 @@ class MapPage extends StatelessWidget { @override Widget build(BuildContext context) { - // do not rely on the `HighlightInfoProvider` app level - // as the map can be stacked on top of other pages - // that catch highlight events and will not let it bubble up - return HighlightInfoProvider( + return MultiProvider( + providers: [ + // do not rely on the `HighlightInfoProvider` app level + // as the map can be stacked on top of other pages + // that catch highlight events and will not let it bubble up + HighlightInfoProvider(), + // opening collection can be used by map actions + ChangeNotifierProvider.value(value: collection), + ], child: AvesScaffold( body: SafeArea( left: false, @@ -442,10 +448,16 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin Navigator.maybeOf(context)?.pushAndRemoveUntil( MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - source: openingCollection.source, - filters: {...openingCollection.filters, filter}, - ), + builder: (context) { + final filters = {...openingCollection.filters, filter}; + if (filter is CoordinateFilter) { + filters.removeWhere((v) => (v is CoordinateFilter && v != filter) || v == LocationFilter.located); + } + return CollectionPage( + source: openingCollection.source, + filters: filters, + ); + }, ), (route) => false, ); diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index ed0fb320c..688b3c64b 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -34,6 +34,7 @@ import 'package:aves/widgets/viewer/action/printer.dart'; import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/controls/notifications.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; +import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; @@ -395,7 +396,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final name = result.$2; if (name.isEmpty) return; - await appService.pinToHomeScreen(name, targetEntry, uri: targetEntry.uri); + await appService.pinToHomeScreen(name, targetEntry, route: EntryViewerPage.routeName, viewUri: targetEntry.uri); if (!device.showPinShortcutFeedback) { showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback); } diff --git a/plugins/aves_map/lib/src/controller.dart b/plugins/aves_map/lib/src/controller.dart index 26d2b56c4..10cdffa0f 100644 --- a/plugins/aves_map/lib/src/controller.dart +++ b/plugins/aves_map/lib/src/controller.dart @@ -16,6 +16,8 @@ class AvesMapController { Stream get zoomCommands => _events.where((event) => event is MapControllerZoomEvent).cast(); + Stream get rotationResetCommands => _events.where((event) => event is MapControllerRotationResetEvent).cast(); + Stream get idleUpdates => _events.where((event) => event is MapIdleUpdate).cast(); Stream get markerLocationChanges => _events.where((event) => event is MapMarkerLocationChangeEvent).cast(); @@ -41,6 +43,8 @@ class AvesMapController { void zoomBy(double delta) => _streamController.add(MapControllerZoomEvent(delta)); + void resetRotation() => _streamController.add(MapControllerRotationResetEvent()); + void notifyIdle(ZoomedBounds bounds) { _idleBounds = bounds; _streamController.add(MapIdleUpdate(bounds)); @@ -61,6 +65,8 @@ class MapControllerZoomEvent { MapControllerZoomEvent(this.delta); } +class MapControllerRotationResetEvent {} + class MapIdleUpdate { final ZoomedBounds bounds; diff --git a/plugins/aves_map/lib/src/interface.dart b/plugins/aves_map/lib/src/interface.dart index 6aedd5397..3f2cc1756 100644 --- a/plugins/aves_map/lib/src/interface.dart +++ b/plugins/aves_map/lib/src/interface.dart @@ -3,7 +3,6 @@ import 'package:aves_map/src/marker/key.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; -typedef ButtonPanelBuilder = Widget Function(VoidCallback resetRotation); typedef MarkerClusterBuilder = Map, GeoEntry> Function(); typedef MarkerWidgetBuilder = Widget Function(MarkerKey key); typedef MarkerImageReadyChecker = bool Function(MarkerKey key); diff --git a/plugins/aves_model/lib/src/actions/map.dart b/plugins/aves_model/lib/src/actions/map.dart index 6d4838cf3..1c71363e9 100644 --- a/plugins/aves_model/lib/src/actions/map.dart +++ b/plugins/aves_model/lib/src/actions/map.dart @@ -1,6 +1,9 @@ enum MapAction { + // any map panel selectStyle, openMapApp, zoomIn, zoomOut, + // full page only + addShortcut, } diff --git a/plugins/aves_services/lib/aves_services.dart b/plugins/aves_services/lib/aves_services.dart index 5fa5b9381..236de5895 100644 --- a/plugins/aves_services/lib/aves_services.dart +++ b/plugins/aves_services/lib/aves_services.dart @@ -12,12 +12,12 @@ abstract class MobileServices { List get mapStyles; Widget buildMap({ - required AvesMapController? controller, + required AvesMapController controller, required Listenable clusterListenable, required ValueNotifier boundsNotifier, required EntryMapStyle style, required TransitionBuilder decoratorBuilder, - required ButtonPanelBuilder buttonPanelBuilder, + required WidgetBuilder buttonPanelBuilder, required MarkerClusterBuilder markerClusterBuilder, required MarkerWidgetBuilder markerWidgetBuilder, required MarkerImageReadyChecker markerImageReadyChecker, diff --git a/plugins/aves_services_google/lib/aves_services_platform.dart b/plugins/aves_services_google/lib/aves_services_platform.dart index 23a5ddbfe..9e665c84f 100644 --- a/plugins/aves_services_google/lib/aves_services_platform.dart +++ b/plugins/aves_services_google/lib/aves_services_platform.dart @@ -46,12 +46,12 @@ class PlatformMobileServices extends MobileServices { @override Widget buildMap({ - required AvesMapController? controller, + required AvesMapController controller, required Listenable clusterListenable, required ValueNotifier boundsNotifier, required EntryMapStyle style, required TransitionBuilder decoratorBuilder, - required ButtonPanelBuilder buttonPanelBuilder, + required WidgetBuilder buttonPanelBuilder, required MarkerClusterBuilder markerClusterBuilder, required MarkerWidgetBuilder markerWidgetBuilder, required MarkerImageReadyChecker markerImageReadyChecker, diff --git a/plugins/aves_services_google/lib/src/map.dart b/plugins/aves_services_google/lib/src/map.dart index 26b29f345..ef3e47ad5 100644 --- a/plugins/aves_services_google/lib/src/map.dart +++ b/plugins/aves_services_google/lib/src/map.dart @@ -9,13 +9,13 @@ import 'package:latlong2/latlong.dart' as ll; import 'package:provider/provider.dart'; class EntryGoogleMap extends StatefulWidget { - final AvesMapController? controller; + final AvesMapController controller; final Listenable clusterListenable; final ValueNotifier boundsNotifier; final double? minZoom, maxZoom; final EntryMapStyle style; final TransitionBuilder decoratorBuilder; - final ButtonPanelBuilder buttonPanelBuilder; + final WidgetBuilder buttonPanelBuilder; final MarkerClusterBuilder markerClusterBuilder; final MarkerWidgetBuilder markerWidgetBuilder; final MarkerImageReadyChecker markerImageReadyChecker; @@ -29,7 +29,7 @@ class EntryGoogleMap extends StatefulWidget { const EntryGoogleMap({ super.key, - this.controller, + required this.controller, required this.clusterListenable, required this.boundsNotifier, this.minZoom, @@ -93,10 +93,9 @@ class _EntryGoogleMapState extends State> { void _registerWidget(EntryGoogleMap widget) { final avesMapController = widget.controller; - if (avesMapController != null) { - _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toServiceLatLng(event.latLng)))); - _subscriptions.add(avesMapController.zoomCommands.listen((event) => _zoomBy(event.delta))); - } + _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toServiceLatLng(event.latLng)))); + _subscriptions.add(avesMapController.zoomCommands.listen((event) => _zoomBy(event.delta))); + _subscriptions.add(avesMapController.rotationResetCommands.listen((_) => _resetRotation())); widget.clusterListenable.addListener(_updateMarkers); } @@ -125,7 +124,7 @@ class _EntryGoogleMapState extends State> { }, ), widget.decoratorBuilder(context, _buildMap()), - widget.buttonPanelBuilder(_resetRotation), + widget.buttonPanelBuilder(context), ], ); } @@ -241,7 +240,7 @@ class _EntryGoogleMapState extends State> { void _onIdle() { if (!mounted) return; - widget.controller?.notifyIdle(bounds); + widget.controller.notifyIdle(bounds); _updateMarkers(); } diff --git a/plugins/aves_services_none/lib/aves_services_platform.dart b/plugins/aves_services_none/lib/aves_services_platform.dart index f7069f805..8f95b7364 100644 --- a/plugins/aves_services_none/lib/aves_services_platform.dart +++ b/plugins/aves_services_none/lib/aves_services_platform.dart @@ -18,12 +18,12 @@ class PlatformMobileServices extends MobileServices { @override Widget buildMap({ - required AvesMapController? controller, + required AvesMapController controller, required Listenable clusterListenable, required ValueNotifier boundsNotifier, required EntryMapStyle style, required TransitionBuilder decoratorBuilder, - required ButtonPanelBuilder buttonPanelBuilder, + required WidgetBuilder buttonPanelBuilder, required MarkerClusterBuilder markerClusterBuilder, required MarkerWidgetBuilder markerWidgetBuilder, required MarkerImageReadyChecker markerImageReadyChecker,