map: create shortcut to custom region and filters

This commit is contained in:
Thibault Deckers 2024-10-23 01:09:17 +02:00
parent 5ce8bef9cc
commit b9327db44b
25 changed files with 350 additions and 137 deletions

View file

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
### Added
- Map: create shortcut to custom region and filters
### Fixed
- crash when loading large collection

View file

@ -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"

View file

@ -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<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes")
val route = call.argument<String>("route")
// route dependent arguments
val filters = call.argument<List<String>>("filters")
val explorerPath = call.argument<String>("explorerPath")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (label == null) {
val explorerPath = call.argument<String>("path")
val viewUri = call.argument<String>("viewUri")?.let { Uri.parse(it) }
val geoUri = call.argument<String>("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
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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<bool> shareSingle(String uri, String mimeType);
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? explorerPath, String? uri});
Future<void> pinToHomeScreen(
String label,
AvesEntry? coverEntry, {
required String route,
Set<CollectionFilter>? filters,
String? path,
String? viewUri,
String? geoUri,
});
}
class PlatformAppService implements AppService {
@ -138,13 +146,9 @@ class PlatformAppService implements AppService {
@override
Future<bool> 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', <String, dynamic>{
'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<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? explorerPath, String? uri}) async {
Future<void> pinToHomeScreen(
String label,
AvesEntry? coverEntry, {
required String route,
Set<CollectionFilter>? 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', <String, dynamic>{
'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);

View file

@ -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,
};
}
}

View file

@ -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);
}

View file

@ -423,17 +423,20 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
);
final animate = context.select<Settings, bool>((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;
}

View file

@ -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<VisualDensity> 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<MapThemeData, Animation<double>>(
@ -27,15 +39,10 @@ class MapOverlayButton extends StatelessWidget {
),
child: Selector<MapThemeData, VisualDensity>(
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;
}

View file

@ -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<ZoomedBounds> 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<MapButtonPanel> createState() => _MapButtonPanelState();
}
class _MapButtonPanelState extends State<MapButtonPanel> {
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<MapThemeData, MapNavigationButton>((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<MapThemeData, VisualDensity>((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<ZoomedBounds>(
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<DurationsData, Duration>((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<Settings>().accessibilityAnimations;
return PopupMenuButton<MapAction>(
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),
);
}

View file

@ -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<AvesEntry>? 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<GeoMap> {
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<GeoMap> {
);
bool _isMarkerImageReady(MarkerKey<AvesEntry> 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<GeoMap> {
case EntryMapStyle.googleHybrid:
case EntryMapStyle.googleTerrain:
child = mobileServices.buildMap<AvesEntry>(
controller: controller,
controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier,
style: mapStyle,
@ -194,7 +190,7 @@ class _GeoMapState extends State<GeoMap> {
case EntryMapStyle.osmHot:
case EntryMapStyle.stamenWatercolor:
child = EntryLeafletMap<AvesEntry>(
controller: controller,
controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier,
minZoom: 2,
@ -324,11 +320,7 @@ class _GeoMapState extends State<GeoMap> {
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<GeoMap> {
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,
);
}
}

View file

@ -14,13 +14,13 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
class EntryLeafletMap<T> extends StatefulWidget {
final AvesMapController? controller;
final AvesMapController controller;
final Listenable clusterListenable;
final ValueNotifier<ZoomedBounds> boundsNotifier;
final double minZoom, maxZoom;
final EntryMapStyle style;
final TransitionBuilder decoratorBuilder;
final ButtonPanelBuilder buttonPanelBuilder;
final WidgetBuilder buttonPanelBuilder;
final MarkerClusterBuilder<T> markerClusterBuilder;
final MarkerWidgetBuilder<T> markerWidgetBuilder;
final ValueNotifier<LatLng?>? dotLocationNotifier;
@ -34,7 +34,7 @@ class EntryLeafletMap<T> 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<T> extends State<EntryLeafletMap<T>> with TickerProv
void _registerWidget(EntryLeafletMap<T> 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<T> extends State<EntryLeafletMap<T>> with TickerProv
return Stack(
children: [
widget.decoratorBuilder(context, _buildMap()),
widget.buttonPanelBuilder(_resetRotation),
widget.buttonPanelBuilder(context),
],
);
}
@ -240,7 +239,7 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
void _onIdle() {
if (!mounted) return;
widget.controller?.notifyIdle(bounds);
widget.controller.notifyIdle(bounds);
_updateMarkers();
}

View file

@ -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<EntryMapStyle>(
context: context,
builder: (context) => AvesSingleSelectionDialog<EntryMapStyle?>(
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<void> _selectStyle(BuildContext context) => showSelectionDialog<EntryMapStyle>(
context: context,
builder: (context) => AvesSingleSelectionDialog<EntryMapStyle?>(
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<void> _addShortcut(BuildContext context) async {
final idleBounds = controller.idleBounds;
if (idleBounds == null) {
showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback);
return;
}
final collection = context.read<CollectionLens>();
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);
}
}
}

View file

@ -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);
}

View file

@ -377,7 +377,10 @@ class _HomePageState extends State<HomePage> {
return buildRoute((context) {
final mapCollection = CollectionLens(
source: source,
filters: {LocationFilter.located},
filters: {
LocationFilter.located,
if (filters != null) ...filters,
},
);
return MapPage(
collection: mapCollection,

View file

@ -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<CollectionLens>.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,
);

View file

@ -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);
}

View file

@ -16,6 +16,8 @@ class AvesMapController {
Stream<MapControllerZoomEvent> get zoomCommands => _events.where((event) => event is MapControllerZoomEvent).cast<MapControllerZoomEvent>();
Stream<MapControllerRotationResetEvent> get rotationResetCommands => _events.where((event) => event is MapControllerRotationResetEvent).cast<MapControllerRotationResetEvent>();
Stream<MapIdleUpdate> get idleUpdates => _events.where((event) => event is MapIdleUpdate).cast<MapIdleUpdate>();
Stream<MapMarkerLocationChangeEvent> get markerLocationChanges => _events.where((event) => event is MapMarkerLocationChangeEvent).cast<MapMarkerLocationChangeEvent>();
@ -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;

View file

@ -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<T> = Map<MarkerKey<T>, GeoEntry<T>> Function();
typedef MarkerWidgetBuilder<T> = Widget Function(MarkerKey<T> key);
typedef MarkerImageReadyChecker<T> = bool Function(MarkerKey<T> key);

View file

@ -1,6 +1,9 @@
enum MapAction {
// any map panel
selectStyle,
openMapApp,
zoomIn,
zoomOut,
// full page only
addShortcut,
}

View file

@ -12,12 +12,12 @@ abstract class MobileServices {
List<EntryMapStyle> get mapStyles;
Widget buildMap<T>({
required AvesMapController? controller,
required AvesMapController controller,
required Listenable clusterListenable,
required ValueNotifier<ZoomedBounds> boundsNotifier,
required EntryMapStyle style,
required TransitionBuilder decoratorBuilder,
required ButtonPanelBuilder buttonPanelBuilder,
required WidgetBuilder buttonPanelBuilder,
required MarkerClusterBuilder<T> markerClusterBuilder,
required MarkerWidgetBuilder<T> markerWidgetBuilder,
required MarkerImageReadyChecker<T> markerImageReadyChecker,

View file

@ -46,12 +46,12 @@ class PlatformMobileServices extends MobileServices {
@override
Widget buildMap<T>({
required AvesMapController? controller,
required AvesMapController controller,
required Listenable clusterListenable,
required ValueNotifier<ZoomedBounds> boundsNotifier,
required EntryMapStyle style,
required TransitionBuilder decoratorBuilder,
required ButtonPanelBuilder buttonPanelBuilder,
required WidgetBuilder buttonPanelBuilder,
required MarkerClusterBuilder<T> markerClusterBuilder,
required MarkerWidgetBuilder<T> markerWidgetBuilder,
required MarkerImageReadyChecker<T> markerImageReadyChecker,

View file

@ -9,13 +9,13 @@ import 'package:latlong2/latlong.dart' as ll;
import 'package:provider/provider.dart';
class EntryGoogleMap<T> extends StatefulWidget {
final AvesMapController? controller;
final AvesMapController controller;
final Listenable clusterListenable;
final ValueNotifier<ZoomedBounds> boundsNotifier;
final double? minZoom, maxZoom;
final EntryMapStyle style;
final TransitionBuilder decoratorBuilder;
final ButtonPanelBuilder buttonPanelBuilder;
final WidgetBuilder buttonPanelBuilder;
final MarkerClusterBuilder<T> markerClusterBuilder;
final MarkerWidgetBuilder<T> markerWidgetBuilder;
final MarkerImageReadyChecker<T> markerImageReadyChecker;
@ -29,7 +29,7 @@ class EntryGoogleMap<T> 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<T> extends State<EntryGoogleMap<T>> {
void _registerWidget(EntryGoogleMap<T> 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<T> extends State<EntryGoogleMap<T>> {
},
),
widget.decoratorBuilder(context, _buildMap()),
widget.buttonPanelBuilder(_resetRotation),
widget.buttonPanelBuilder(context),
],
);
}
@ -241,7 +240,7 @@ class _EntryGoogleMapState<T> extends State<EntryGoogleMap<T>> {
void _onIdle() {
if (!mounted) return;
widget.controller?.notifyIdle(bounds);
widget.controller.notifyIdle(bounds);
_updateMarkers();
}

View file

@ -18,12 +18,12 @@ class PlatformMobileServices extends MobileServices {
@override
Widget buildMap<T>({
required AvesMapController? controller,
required AvesMapController controller,
required Listenable clusterListenable,
required ValueNotifier<ZoomedBounds> boundsNotifier,
required EntryMapStyle style,
required TransitionBuilder decoratorBuilder,
required ButtonPanelBuilder buttonPanelBuilder,
required WidgetBuilder buttonPanelBuilder,
required MarkerClusterBuilder<T> markerClusterBuilder,
required MarkerWidgetBuilder<T> markerWidgetBuilder,
required MarkerImageReadyChecker<T> markerImageReadyChecker,