map: changed navigation concept, improved gestures, toggle fullscreen

This commit is contained in:
Thibault Deckers 2021-09-23 18:08:03 +09:00
parent 35b10b3470
commit ea6f5d7df6
36 changed files with 936 additions and 321 deletions

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.5.30'
ext.kotlin_version = '1.5.31'
repositories {
google()
mavenCentral()

View file

@ -103,7 +103,7 @@
"@entryActionOpen": {},
"entryActionSetAs": "Set as…",
"@entryActionSetAs": {},
"entryActionOpenMap": "Show on map…",
"entryActionOpenMap": "Show in map app…",
"@entryActionOpenMap": {},
"entryActionRotateScreen": "Rotate screen",
"@entryActionRotateScreen": {},
@ -871,6 +871,8 @@
"@mapAttributionStamen": {},
"openMapPageTooltip": "View on Map page",
"@openMapPageTooltip": {},
"mapEmptyRegion": "No images in this region",
"@mapEmpty": {},
"viewerInfoOpenEmbeddedFailureFeedback": "Failed to extract embedded data",
"@viewerInfoOpenEmbeddedFailureFeedback": {},

View file

@ -52,7 +52,7 @@
"entryActionEdit": "편집…",
"entryActionOpen": "다른 앱에서 열기…",
"entryActionSetAs": "다음 용도로 사용…",
"entryActionOpenMap": "지도에서 보기…",
"entryActionOpenMap": "지도에서 보기…",
"entryActionRotateScreen": "화면 회전",
"entryActionAddFavourite": "즐겨찾기에 추가",
"entryActionRemoveFavourite": "즐겨찾기에서 삭제",
@ -425,6 +425,7 @@
"mapAttributionOsmHot": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [HOT](https://www.hotosm.org/) • 호스팅 [OSM France](https://openstreetmap.fr/)",
"mapAttributionStamen": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"openMapPageTooltip": "지도 페이지에서 보기",
"mapEmptyRegion": "이 지역의 사진이 없습니다",
"viewerInfoOpenEmbeddedFailureFeedback": "첨부 데이터 추출 오류",
"viewerInfoOpenLinkText": "열기",

View file

@ -28,6 +28,7 @@ class CollectionLens with ChangeNotifier {
final List<StreamSubscription> _subscriptions = [];
int? id;
bool listenToSource;
List<AvesEntry>? fixedSelection;
List<AvesEntry> _filteredSortedEntries = [];
@ -38,6 +39,7 @@ class CollectionLens with ChangeNotifier {
Iterable<CollectionFilter?>? filters,
this.id,
this.listenToSource = true,
this.fixedSelection,
}) : filters = (filters ?? {}).whereNotNull().toSet(),
sectionFactor = settings.collectionSectionFactor,
sortFactor = settings.collectionSortFactor {
@ -118,7 +120,7 @@ class CollectionLens with ChangeNotifier {
final bool groupBursts = true;
void _applyFilters() {
final entries = source.visibleEntries;
final entries = fixedSelection ?? source.visibleEntries;
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
if (groupBursts) {

View file

@ -69,7 +69,7 @@ class Durations {
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 250);
static const contentChangeDebounceDelay = Duration(milliseconds: 1000);
static const mapScrollDebounceDelay = Duration(milliseconds: 150);
static const mapInfoDebounceDelay = Duration(milliseconds: 150);
static const mapIdleDebounceDelay = Duration(milliseconds: 100);
// app life

27
lib/utils/geo_utils.dart Normal file
View file

@ -0,0 +1,27 @@
import 'dart:math';
import 'package:latlong2/latlong.dart';
LatLng getLatLngCenter(List<LatLng> points) {
double x = 0;
double y = 0;
double z = 0;
points.forEach((point) {
final lat = point.latitudeInRad;
final lng = point.longitudeInRad;
x += cos(lat) * cos(lng);
y += cos(lat) * sin(lng);
z += sin(lat);
});
final pointCount = points.length;
x /= pointCount;
y /= pointCount;
z /= pointCount;
final lng = atan2(y, x);
final hyp = sqrt(x * x + y * y);
final lat = atan2(z, hyp);
return LatLng(radianToDeg(lat), radianToDeg(lng));
}

View file

@ -1,11 +1,5 @@
import 'dart:math';
const double _piOver180 = pi / 180.0;
double toDegrees(num radians) => radians / _piOver180;
double toRadians(num degrees) => degrees * _piOver180;
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt();
int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt();

View file

@ -242,9 +242,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
]
];
},
onSelected: (action) {
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onCollectionActionSelected(action));
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
await _onCollectionActionSelected(action);
},
),
);

View file

@ -244,14 +244,19 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
void _goToMap(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>();
final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : context.read<CollectionLens>().sortedEntries;
final collection = context.read<CollectionLens>();
final entries = (selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries);
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
entries: entries.where((entry) => entry.hasGps).toList(),
collection: CollectionLens(
source: collection.source,
filters: collection.filters,
fixedSelection: entries.where((entry) => entry.hasGps).toList(),
),
),
),
);

View file

@ -54,11 +54,14 @@ class InteractiveThumbnail extends StatelessWidget {
child: DecoratedThumbnail(
entry: entry,
tileExtent: tileExtent,
collection: collection,
// when the user is scrolling faster than we can retrieve the thumbnails,
// the retrieval task queue can pile up for thumbnails that got disposed
// in this case we pause the image retrieval task to get it out of the queue
cancellableNotifier: isScrollingNotifier,
// hero tag should include a collection identifier, so that it animates
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
heroTagger: () => Object.hashAll([collection.id, entry.uri]),
),
),
);

View file

@ -82,7 +82,8 @@ class AvesFilterChip extends StatefulWidget {
);
if (selectedAction != null) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => ChipActionDelegate().onActionSelected(context, filter, selectedAction));
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
ChipActionDelegate().onActionSelected(context, filter, selectedAction);
}
}
}

View file

@ -4,12 +4,14 @@ class EmptyContent extends StatelessWidget {
final IconData? icon;
final String text;
final AlignmentGeometry alignment;
final double fontSize;
const EmptyContent({
Key? key,
this.icon,
required this.text,
this.alignment = const FractionalOffset(.5, .35),
this.fontSize = 22,
}) : super(key: key);
@override
@ -30,10 +32,11 @@ class EmptyContent extends StatelessWidget {
],
Text(
text,
style: const TextStyle(
style: TextStyle(
color: color,
fontSize: 22,
fontSize: fontSize,
),
textAlign: TextAlign.center,
),
],
),

View file

@ -25,8 +25,6 @@ class MapButtonPanel extends StatelessWidget {
final MapOpener? openMapPage;
final VoidCallback? resetRotation;
static const double padding = 4;
const MapButtonPanel({
Key? key,
required this.boundsNotifier,
@ -60,111 +58,117 @@ class MapButtonPanel extends StatelessWidget {
break;
}
final visualDensity = context.select<MapThemeData, VisualDensity?>((v) => v.visualDensity);
final double padding = visualDensity == VisualDensity.compact ? 4 : 8;
return Positioned.fill(
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: Padding(
padding: const EdgeInsets.all(padding),
padding: EdgeInsets.all(padding),
child: TooltipTheme(
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: Stack(
children: [
Positioned(
left: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (navigationButton != null) ...[
navigationButton,
const SizedBox(height: padding),
],
ValueListenableBuilder<ZoomedBounds>(
valueListenable: boundsNotifier,
builder: (context, bounds, child) {
final degrees = bounds.rotation;
final opacity = degrees == 0 ? .0 : 1.0;
return IgnorePointer(
ignoring: opacity == 0,
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.viewerOverlayAnimation,
child: MapOverlayButton(
icon: Transform(
origin: iconSize.center(Offset.zero),
transform: Matrix4.rotationZ(degToRadian(degrees)),
child: CustomPaint(
painter: CompassPainter(
color: iconTheme.color!,
child: SafeArea(
bottom: false,
child: Stack(
children: [
Positioned(
left: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (navigationButton != null) ...[
navigationButton,
SizedBox(height: padding),
],
ValueListenableBuilder<ZoomedBounds>(
valueListenable: boundsNotifier,
builder: (context, bounds, child) {
final degrees = bounds.rotation;
final opacity = degrees == 0 ? .0 : 1.0;
return IgnorePointer(
ignoring: opacity == 0,
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.viewerOverlayAnimation,
child: MapOverlayButton(
icon: Transform(
origin: iconSize.center(Offset.zero),
transform: Matrix4.rotationZ(degToRadian(degrees)),
child: CustomPaint(
painter: CompassPainter(
color: iconTheme.color!,
),
size: iconSize,
),
size: iconSize,
),
onPressed: () => resetRotation?.call(),
tooltip: context.l10n.mapPointNorthUpTooltip,
),
onPressed: () => resetRotation?.call(),
tooltip: context.l10n.mapPointNorthUpTooltip,
),
),
);
},
),
],
);
},
),
],
),
),
),
Positioned(
right: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MapOverlayButton(
icon: const Icon(AIcons.layers),
onPressed: () async {
final hasPlayServices = await availability.hasPlayServices;
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
final preferredStyle = settings.infoMapStyle;
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
final style = await showDialog<EntryMapStyle>(
context: context,
builder: (context) {
return AvesSelectionDialog<EntryMapStyle>(
initialValue: initialStyle,
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.mapStyleTitle,
);
},
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (style != null && style != settings.infoMapStyle) {
settings.infoMapStyle = style;
}
},
tooltip: context.l10n.mapStyleTooltip,
),
],
Positioned(
right: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MapOverlayButton(
icon: const Icon(AIcons.layers),
onPressed: () async {
final hasPlayServices = await availability.hasPlayServices;
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
final preferredStyle = settings.infoMapStyle;
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
final style = await showDialog<EntryMapStyle>(
context: context,
builder: (context) {
return AvesSelectionDialog<EntryMapStyle>(
initialValue: initialStyle,
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.mapStyleTitle,
);
},
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (style != null && style != settings.infoMapStyle) {
settings.infoMapStyle = style;
}
},
tooltip: context.l10n.mapStyleTooltip,
),
],
),
),
),
Positioned(
right: 0,
bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MapOverlayButton(
icon: const Icon(AIcons.zoomIn),
onPressed: zoomBy != null ? () => zoomBy?.call(1) : null,
tooltip: context.l10n.mapZoomInTooltip,
),
const SizedBox(height: padding),
MapOverlayButton(
icon: const Icon(AIcons.zoomOut),
onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null,
tooltip: context.l10n.mapZoomOutTooltip,
),
],
Positioned(
right: 0,
bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MapOverlayButton(
icon: const Icon(AIcons.zoomIn),
onPressed: zoomBy != null ? () => zoomBy?.call(1) : null,
tooltip: context.l10n.mapZoomInTooltip,
),
SizedBox(height: padding),
MapOverlayButton(
icon: const Icon(AIcons.zoomOut),
onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null,
tooltip: context.l10n.mapZoomOutTooltip,
),
],
),
),
),
],
],
),
),
),
),
@ -187,24 +191,33 @@ class MapOverlayButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final visualDensity = context.select<MapThemeData, VisualDensity?>((v) => v.visualDensity);
final blurred = settings.enableOverlayBlurEffect;
return BlurredOval(
enabled: blurred,
child: Material(
type: MaterialType.circle,
color: overlayBackgroundColor(blurred: blurred),
child: Ink(
decoration: BoxDecoration(
border: AvesBorder.border,
shape: BoxShape.circle,
),
child: IconButton(
iconSize: 20,
visualDensity: visualDensity,
icon: icon,
onPressed: onPressed,
tooltip: tooltip,
return Selector<MapThemeData, Animation<double>>(
selector: (context, v) => v.scale,
builder: (context, scale, child) => ScaleTransition(
scale: scale,
child: child,
),
child: BlurredOval(
enabled: blurred,
child: Material(
type: MaterialType.circle,
color: overlayBackgroundColor(blurred: blurred),
child: Ink(
decoration: BoxDecoration(
border: AvesBorder.border,
shape: BoxShape.circle,
),
child: Selector<MapThemeData, VisualDensity?>(
selector: (context, v) => v.visualDensity,
builder: (context, visualDensity, child) => IconButton(
iconSize: 20,
visualDensity: visualDensity,
icon: icon,
onPressed: onPressed,
tooltip: tooltip,
),
),
),
),
),

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:latlong2/latlong.dart';
class AvesMapController {
@ -7,13 +8,17 @@ class AvesMapController {
Stream<dynamic> get _events => _streamController.stream;
Stream<MapControllerMoveEvent> get moveEvents => _events.where((event) => event is MapControllerMoveEvent).cast<MapControllerMoveEvent>();
Stream<MapControllerMoveEvent> get moveCommands => _events.where((event) => event is MapControllerMoveEvent).cast<MapControllerMoveEvent>();
Stream<MapIdleUpdate> get idleUpdates => _events.where((event) => event is MapIdleUpdate).cast<MapIdleUpdate>();
void dispose() {
_streamController.close();
}
void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng));
void notifyIdle(ZoomedBounds bounds) => _streamController.add(MapIdleUpdate(bounds));
}
class MapControllerMoveEvent {
@ -21,3 +26,9 @@ class MapControllerMoveEvent {
MapControllerMoveEvent(this.latLng);
}
class MapIdleUpdate {
final ZoomedBounds bounds;
MapIdleUpdate(this.bounds);
}

View file

@ -30,12 +30,14 @@ class GeoMap extends StatefulWidget {
final List<AvesEntry> entries;
final AvesEntry? initialEntry;
final ValueNotifier<bool> isAnimatingNotifier;
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
final UserZoomChangeCallback? onUserZoomChange;
final VoidCallback? onMapTap;
final MarkerTapCallback? onMarkerTap;
final MapOpener? openMapPage;
static const markerImageExtent = 48.0;
static const pointerSize = Size(8, 6);
static const markerArrowSize = Size(8, 6);
const GeoMap({
Key? key,
@ -43,7 +45,9 @@ class GeoMap extends StatefulWidget {
required this.entries,
this.initialEntry,
required this.isAnimatingNotifier,
this.dotEntryNotifier,
this.onUserZoomChange,
this.onMapTap,
this.onMarkerTap,
this.openMapPage,
}) : super(key: key);
@ -126,7 +130,7 @@ class _GeoMapState extends State<GeoMap> {
entry: key.entry,
count: key.count,
extent: GeoMap.markerImageExtent,
pointerSize: GeoMap.pointerSize,
arrowSize: GeoMap.markerArrowSize,
progressive: progressive,
);
@ -139,7 +143,9 @@ class _GeoMapState extends State<GeoMap> {
style: mapStyle,
markerClusterBuilder: _buildMarkerClusters,
markerWidgetBuilder: _buildMarkerWidget,
dotEntryNotifier: widget.dotEntryNotifier,
onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,
openMapPage: widget.openMapPage,
)
@ -151,11 +157,17 @@ class _GeoMapState extends State<GeoMap> {
style: mapStyle,
markerClusterBuilder: _buildMarkerClusters,
markerWidgetBuilder: _buildMarkerWidget,
dotEntryNotifier: widget.dotEntryNotifier,
markerSize: Size(
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2,
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height,
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.markerArrowSize.height,
),
dotMarkerSize: const Size(
DotMarker.diameter + ImageMarker.outerBorderWidth * 2,
DotMarker.diameter + ImageMarker.outerBorderWidth * 2,
),
onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap,
openMapPage: widget.openMapPage,
);
@ -170,7 +182,11 @@ class _GeoMapState extends State<GeoMap> {
child: child,
)
: Expanded(child: child),
Attribution(style: mapStyle),
SafeArea(
top: false,
bottom: false,
child: Attribution(style: mapStyle),
),
],
);

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/utils/change_notifier.dart';
@ -10,6 +11,7 @@ import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/geo_entry.dart';
import 'package:aves/widgets/common/map/geo_map.dart';
import 'package:aves/widgets/common/map/google/marker_generator.dart';
import 'package:aves/widgets/common/map/marker.dart';
import 'package:aves/widgets/common/map/theme.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:flutter/material.dart';
@ -24,7 +26,9 @@ class EntryGoogleMap extends StatefulWidget {
final EntryMapStyle style;
final MarkerClusterBuilder markerClusterBuilder;
final MarkerWidgetBuilder markerWidgetBuilder;
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
final UserZoomChangeCallback? onUserZoomChange;
final VoidCallback? onMapTap;
final void Function(GeoEntry geoEntry)? onMarkerTap;
final MapOpener? openMapPage;
@ -37,7 +41,9 @@ class EntryGoogleMap extends StatefulWidget {
required this.style,
required this.markerClusterBuilder,
required this.markerWidgetBuilder,
required this.dotEntryNotifier,
this.onUserZoomChange,
this.onMapTap,
this.onMarkerTap,
this.openMapPage,
}) : super(key: key);
@ -52,6 +58,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
Map<MarkerKey, GeoEntry> _geoEntryByMarkerKey = {};
final Map<MarkerKey, Uint8List> _markerBitmaps = {};
final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier();
Uint8List? _dotMarkerBitmap;
ValueNotifier<ZoomedBounds> get boundsNotifier => widget.boundsNotifier;
@ -84,7 +91,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
void _registerWidget(EntryGoogleMap widget) {
final avesMapController = widget.controller;
if (avesMapController != null) {
_subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
}
}
@ -113,6 +120,11 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
Widget build(BuildContext context) {
return Stack(
children: [
MarkerGeneratorWidget<Key>(
markers: const [DotMarker(key: Key('dot'))],
isReadyToRender: (key) => true,
onRendered: (key, bitmap) => _dotMarkerBitmap = bitmap,
),
MarkerGeneratorWidget<MarkerKey>(
markers: _geoEntryByMarkerKey.keys.map(widget.markerWidgetBuilder).toList(),
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
@ -154,43 +166,60 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
});
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
return GoogleMap(
initialCameraPosition: CameraPosition(
target: _toGoogleLatLng(bounds.center),
zoom: bounds.zoom,
),
onMapCreated: (controller) async {
_googleMapController = controller;
final zoom = await controller.getZoomLevel();
await _updateVisibleRegion(zoom: zoom, rotation: 0);
setState(() {});
},
// compass disabled to use provider agnostic controls
compassEnabled: false,
mapToolbarEnabled: false,
mapType: _toMapType(widget.style),
minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom),
rotateGesturesEnabled: true,
scrollGesturesEnabled: interactive,
// zoom controls disabled to use provider agnostic controls
zoomControlsEnabled: false,
zoomGesturesEnabled: interactive,
// lite mode disabled because it lacks camera animation
liteModeEnabled: false,
// tilt disabled to match leaflet
tiltGesturesEnabled: false,
myLocationEnabled: false,
myLocationButtonEnabled: false,
markers: markers,
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
onCameraIdle: _updateClusters,
);
return ValueListenableBuilder<AvesEntry?>(
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
builder: (context, dotEntry, child) {
return GoogleMap(
initialCameraPosition: CameraPosition(
target: _toGoogleLatLng(bounds.center),
zoom: bounds.zoom,
),
onMapCreated: (controller) async {
_googleMapController = controller;
final zoom = await controller.getZoomLevel();
await _updateVisibleRegion(zoom: zoom, rotation: 0);
setState(() {});
},
// compass disabled to use provider agnostic controls
compassEnabled: false,
mapToolbarEnabled: false,
mapType: _toMapType(widget.style),
minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom),
rotateGesturesEnabled: true,
scrollGesturesEnabled: interactive,
// zoom controls disabled to use provider agnostic controls
zoomControlsEnabled: false,
zoomGesturesEnabled: interactive,
// lite mode disabled because it lacks camera animation
liteModeEnabled: false,
// tilt disabled to match leaflet
tiltGesturesEnabled: false,
myLocationEnabled: false,
myLocationButtonEnabled: false,
markers: {
...markers,
if (dotEntry != null && _dotMarkerBitmap != null)
Marker(
markerId: const MarkerId('dot'),
anchor: const Offset(.5, .5),
consumeTapEvents: true,
icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!),
position: _toGoogleLatLng(dotEntry.latLng!),
zIndex: 1,
)
},
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
onCameraIdle: _onIdle,
onTap: (position) => widget.onMapTap?.call(),
);
});
},
);
}
void _updateClusters() {
void _onIdle() {
if (!mounted) return;
widget.controller?.notifyIdle(bounds);
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
}
@ -199,11 +228,11 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
final bounds = await _googleMapController?.getVisibleRegion();
if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) {
final sw = bounds.southwest;
final ne = bounds.northeast;
boundsNotifier.value = ZoomedBounds(
west: bounds.southwest.longitude,
south: bounds.southwest.latitude,
east: bounds.northeast.longitude,
north: bounds.northeast.latitude,
sw: ll.LatLng(sw.latitude, sw.longitude),
ne: ll.LatLng(ne.latitude, ne.longitude),
zoom: zoom,
rotation: rotation,
);

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/debouncer.dart';
@ -11,6 +12,7 @@ import 'package:aves/widgets/common/map/geo_map.dart';
import 'package:aves/widgets/common/map/latlng_tween.dart';
import 'package:aves/widgets/common/map/leaflet/scale_layer.dart';
import 'package:aves/widgets/common/map/leaflet/tile_layers.dart';
import 'package:aves/widgets/common/map/marker.dart';
import 'package:aves/widgets/common/map/theme.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:flutter/material.dart';
@ -25,8 +27,10 @@ class EntryLeafletMap extends StatefulWidget {
final EntryMapStyle style;
final MarkerClusterBuilder markerClusterBuilder;
final MarkerWidgetBuilder markerWidgetBuilder;
final Size markerSize;
final ValueNotifier<AvesEntry?>? dotEntryNotifier;
final Size markerSize, dotMarkerSize;
final UserZoomChangeCallback? onUserZoomChange;
final VoidCallback? onMapTap;
final void Function(GeoEntry geoEntry)? onMarkerTap;
final MapOpener? openMapPage;
@ -39,8 +43,11 @@ class EntryLeafletMap extends StatefulWidget {
required this.style,
required this.markerClusterBuilder,
required this.markerWidgetBuilder,
required this.dotEntryNotifier,
required this.markerSize,
required this.dotMarkerSize,
this.onUserZoomChange,
this.onMapTap,
this.onMarkerTap,
this.openMapPage,
}) : super(key: key);
@ -85,7 +92,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
void _registerWidget(EntryLeafletMap widget) {
final avesMapController = widget.controller;
if (avesMapController != null) {
_subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(event.latLng)));
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng)));
}
_subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion()));
boundsNotifier.addListener(_onBoundsChange);
@ -117,6 +124,10 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
Widget _buildMap() {
final markerSize = widget.markerSize;
final dotMarkerSize = widget.dotMarkerSize;
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
final markers = _geoEntryByMarkerKey.entries.map((kv) {
final markerKey = kv.key;
final geoEntry = kv.value;
@ -125,6 +136,9 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
point: latLng,
builder: (context) => GestureDetector(
onTap: () => widget.onMarkerTap?.call(geoEntry),
// marker tap handling prevents the default handling of focal zoom on double tap,
// so we reimplement the double tap gesture here
onDoubleTap: interactive ? () => _zoomBy(1, focalPoint: latLng) : null,
child: widget.markerWidgetBuilder(markerKey),
),
width: markerSize.width,
@ -133,7 +147,6 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
);
}).toList();
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
return FlutterMap(
options: MapOptions(
center: bounds.center,
@ -141,6 +154,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
minZoom: widget.minZoom,
maxZoom: widget.maxZoom,
interactiveFlags: interactive ? InteractiveFlag.all : InteractiveFlag.none,
onTap: (point) => widget.onMapTap?.call(),
controller: _leafletMapController,
),
mapController: _leafletMapController,
@ -158,6 +172,22 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
rotateAlignment: Alignment.bottomCenter,
),
),
ValueListenableBuilder<AvesEntry?>(
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
builder: (context, dotEntry, child) => MarkerLayerWidget(
options: MarkerLayerOptions(
markers: [
if (dotEntry != null)
Marker(
point: dotEntry.latLng!,
builder: (context) => const DotMarker(),
width: dotMarkerSize.width,
height: dotMarkerSize.height,
)
],
),
),
),
],
);
}
@ -175,10 +205,11 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
}
}
void _onBoundsChange() => _debouncer(_updateClusters);
void _onBoundsChange() => _debouncer(_onIdle);
void _updateClusters() {
void _onIdle() {
if (!mounted) return;
widget.controller?.notifyIdle(bounds);
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
}
@ -186,10 +217,8 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
final bounds = _leafletMapController.bounds;
if (bounds != null) {
boundsNotifier.value = ZoomedBounds(
west: bounds.west,
south: bounds.south,
east: bounds.east,
north: bounds.north,
sw: bounds.southWest!,
ne: bounds.northEast!,
zoom: _leafletMapController.zoom,
rotation: _leafletMapController.rotation,
);
@ -201,12 +230,15 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
await _animateCamera((animation) => _leafletMapController.rotate(rotationTween.evaluate(animation)));
}
Future<void> _zoomBy(double amount) async {
Future<void> _zoomBy(double amount, {LatLng? focalPoint}) async {
final endZoom = (_leafletMapController.zoom + amount).clamp(widget.minZoom, widget.maxZoom);
widget.onUserZoomChange?.call(endZoom);
final center = _leafletMapController.center;
final centerTween = LatLngTween(begin: center, end: focalPoint ?? center);
final zoomTween = Tween<double>(begin: _leafletMapController.zoom, end: endZoom);
await _animateCamera((animation) => _leafletMapController.move(_leafletMapController.center, zoomTween.evaluate(animation)));
await _animateCamera((animation) => _leafletMapController.move(centerTween.evaluate(animation)!, zoomTween.evaluate(animation)));
}
Future<void> _moveTo(LatLng point) async {

View file

@ -1,6 +1,5 @@
import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:latlong2/latlong.dart';
class ScaleBarUtils {
@ -15,8 +14,8 @@ class ScaleBarUtils {
var aSquared = a * a;
var bSquared = b * b;
var f = mFlattening;
var phi1 = toRadians(start.latitude);
var alpha1 = toRadians(startBearing);
var phi1 = degToRadian(start.latitude);
var alpha1 = degToRadian(startBearing);
var cosAlpha1 = cos(alpha1);
var sinAlpha1 = sin(alpha1);
var s = distance;
@ -103,8 +102,8 @@ class ScaleBarUtils {
// cosSigma * cosAlpha1);
// build result
var latitude = toDegrees(phi2);
var longitude = start.longitude + toDegrees(L);
var latitude = radianToDeg(phi2);
var longitude = start.longitude + radianToDeg(L);
// if ((endBearing != null) && (endBearing.length > 0)) {
// endBearing[0] = toDegrees(alpha2);

View file

@ -8,7 +8,7 @@ class ImageMarker extends StatelessWidget {
final AvesEntry? entry;
final int? count;
final double extent;
final Size pointerSize;
final Size arrowSize;
final bool progressive;
static const double outerBorderRadiusDim = 8;
@ -25,7 +25,7 @@ class ImageMarker extends StatelessWidget {
required this.entry,
required this.count,
required this.extent,
required this.pointerSize,
required this.arrowSize,
required this.progressive,
}) : super(key: key);
@ -106,14 +106,14 @@ class ImageMarker extends StatelessWidget {
}
return CustomPaint(
foregroundPainter: MarkerPointerPainter(
foregroundPainter: _MarkerArrowPainter(
color: innerBorderColor,
outlineColor: outerBorderColor,
outlineWidth: outerBorderWidth,
size: pointerSize,
size: arrowSize,
),
child: Padding(
padding: EdgeInsets.only(bottom: pointerSize.height),
padding: EdgeInsets.only(bottom: arrowSize.height),
child: Container(
decoration: outerDecoration,
child: child,
@ -123,12 +123,12 @@ class ImageMarker extends StatelessWidget {
}
}
class MarkerPointerPainter extends CustomPainter {
class _MarkerArrowPainter extends CustomPainter {
final Color color, outlineColor;
final double outlineWidth;
final Size size;
const MarkerPointerPainter({
const _MarkerArrowPainter({
required this.color,
required this.outlineColor,
required this.outlineWidth,
@ -137,12 +137,12 @@ class MarkerPointerPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final pointerWidth = this.size.width;
final pointerHeight = this.size.height;
final triangleWidth = this.size.width;
final triangleHeight = this.size.height;
final bottomCenter = Offset(size.width / 2, size.height);
final topLeft = bottomCenter + Offset(-pointerWidth / 2, -pointerHeight);
final topRight = bottomCenter + Offset(pointerWidth / 2, -pointerHeight);
final topLeft = bottomCenter + Offset(-triangleWidth / 2, -triangleHeight);
final topRight = bottomCenter + Offset(triangleWidth / 2, -triangleHeight);
canvas.drawPath(
Path()
@ -165,3 +165,48 @@ class MarkerPointerPainter extends CustomPainter {
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class DotMarker extends StatelessWidget {
const DotMarker({Key? key}) : super(key: key);
static const double diameter = 16;
static const double outerBorderRadiusDim = diameter;
static const outerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim));
static const innerRadius = Radius.circular(outerBorderRadiusDim - ImageMarker.outerBorderWidth);
static const innerBorderRadius = BorderRadius.all(innerRadius);
@override
Widget build(BuildContext context) {
const outerDecoration = BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: ImageMarker.outerBorderColor,
width: ImageMarker.outerBorderWidth,
)),
borderRadius: outerBorderRadius,
);
const innerDecoration = BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: ImageMarker.innerBorderColor,
width: ImageMarker.innerBorderWidth,
)),
borderRadius: innerBorderRadius,
);
return Container(
decoration: outerDecoration,
child: DecoratedBox(
decoration: innerDecoration,
position: DecorationPosition.foreground,
child: ClipRRect(
borderRadius: innerBorderRadius,
child: Container(
width: diameter,
height: diameter,
color: Theme.of(context).colorScheme.secondary,
),
),
),
);
}
}

View file

@ -7,6 +7,7 @@ enum MapNavigationButton { back, map }
class MapTheme extends StatelessWidget {
final bool interactive;
final MapNavigationButton navigationButton;
final Animation<double> scale;
final VisualDensity? visualDensity;
final double? mapHeight;
final Widget child;
@ -15,6 +16,7 @@ class MapTheme extends StatelessWidget {
Key? key,
required this.interactive,
required this.navigationButton,
this.scale = kAlwaysCompleteAnimation,
this.visualDensity,
this.mapHeight,
required this.child,
@ -27,10 +29,9 @@ class MapTheme extends StatelessWidget {
return MapThemeData(
interactive: interactive,
navigationButton: navigationButton,
scale: scale,
visualDensity: visualDensity,
mapHeight: mapHeight,
// TODO TLAD use settings?
// showLocation: showBackButton ?? settings.showThumbnailLocation,
);
},
child: child,
@ -41,13 +42,15 @@ class MapTheme extends StatelessWidget {
class MapThemeData {
final bool interactive;
final MapNavigationButton navigationButton;
final Animation<double> scale;
final VisualDensity? visualDensity;
final double? mapHeight;
const MapThemeData({
required this.interactive,
required this.navigationButton,
this.visualDensity,
this.mapHeight,
required this.scale,
required this.visualDensity,
required this.mapHeight,
});
}

View file

@ -1,25 +1,26 @@
import 'dart:math';
import 'package:aves/utils/geo_utils.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
@immutable
class ZoomedBounds extends Equatable {
final double west, south, east, north, zoom, rotation;
final LatLng sw, ne;
final double zoom, rotation;
List<double> get boundingBox => [west, south, east, north];
// returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster
List<double> get boundingBox => [sw.longitude, sw.latitude, ne.longitude, ne.latitude];
LatLng get center => LatLng((north + south) / 2, (east + west) / 2);
LatLng get center => getLatLngCenter([sw, ne]);
@override
List<Object?> get props => [west, south, east, north, zoom, rotation];
List<Object?> get props => [sw, ne, zoom, rotation];
const ZoomedBounds({
required this.west,
required this.south,
required this.east,
required this.north,
required this.sw,
required this.ne,
required this.zoom,
required this.rotation,
});
@ -55,12 +56,20 @@ class ZoomedBounds extends Equatable {
}
}
return ZoomedBounds(
west: west,
south: south,
east: east,
north: north,
sw: LatLng(south, west),
ne: LatLng(north, east),
zoom: zoom,
rotation: 0,
);
}
bool contains(LatLng point) {
final lat = point.latitude;
final lng = point.longitude;
final south = sw.latitude;
final north = ne.latitude;
final west = sw.longitude;
final east = ne.longitude;
return (south <= lat && lat <= north) && (west <= east ? (west <= lng && lng <= east) : (west <= lng || lng <= east));
}
}

View file

@ -1,5 +1,4 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/grid/overlay.dart';
import 'package:aves/widgets/common/thumbnail/image.dart';
@ -9,9 +8,9 @@ import 'package:flutter/material.dart';
class DecoratedThumbnail extends StatelessWidget {
final AvesEntry entry;
final double tileExtent;
final CollectionLens? collection;
final ValueNotifier<bool>? cancellableNotifier;
final bool selectable, highlightable, hero;
final bool selectable, highlightable;
final Object? Function()? heroTagger;
static final Color borderColor = Colors.grey.shade700;
static final double borderWidth = AvesBorder.borderWidth;
@ -20,27 +19,22 @@ class DecoratedThumbnail extends StatelessWidget {
Key? key,
required this.entry,
required this.tileExtent,
this.collection,
this.cancellableNotifier,
this.selectable = true,
this.highlightable = true,
this.hero = true,
this.heroTagger,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final imageExtent = tileExtent - borderWidth * 2;
// hero tag should include a collection identifier, so that it animates
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
final heroTag = hero ? Object.hashAll([collection?.id, entry.uri]) : null;
final isSvg = entry.isSvg;
Widget child = ThumbnailImage(
entry: entry,
extent: imageExtent,
cancellableNotifier: cancellableNotifier,
heroTag: heroTag,
heroTag: heroTagger?.call(),
);
child = Stack(

View file

@ -12,6 +12,9 @@ class ThumbnailScroller extends StatefulWidget {
final int entryCount;
final AvesEntry? Function(int index) entryBuilder;
final ValueNotifier<int?> indexNotifier;
final void Function(int index)? onTap;
final Object? Function(AvesEntry entry)? heroTagger;
final bool highlightable;
const ThumbnailScroller({
Key? key,
@ -19,6 +22,9 @@ class ThumbnailScroller extends StatefulWidget {
required this.entryCount,
required this.entryBuilder,
required this.indexNotifier,
this.onTap,
this.heroTagger,
this.highlightable = false,
}) : super(key: key);
@override
@ -98,7 +104,10 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
return Stack(
children: [
GestureDetector(
onTap: () => indexNotifier.value = page,
onTap: () {
indexNotifier.value = page;
widget.onTap?.call(page);
},
child: DecoratedThumbnail(
entry: pageEntry,
tileExtent: extent,
@ -107,8 +116,8 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
// so we cancel these requests when possible
cancellableNotifier: _cancellableNotifier,
selectable: false,
highlightable: false,
hero: false,
highlightable: widget.highlightable,
heroTagger: () => widget.heroTagger?.call(pageEntry),
),
),
IgnorePointer(
@ -123,7 +132,7 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
);
},
),
)
),
],
);
},

View file

@ -159,13 +159,14 @@ class AlbumPickAppBar extends StatelessWidget {
),
];
},
onSelected: (action) {
onSelected: (action) async {
// remove focus, if any, to prevent the keyboard from showing up
// after the user is done with the popup menu
FocusManager.instance.primaryFocus?.unfocus();
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action));
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
actionDelegate.onActionSelected(context, {}, action);
},
),
),

View file

@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
@ -142,7 +143,10 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
entries: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList()..sort(AvesEntry.compareByDate),
collection: CollectionLens(
source: context.read<CollectionSource>(),
fixedSelection: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList(),
),
),
),
);

View file

@ -225,9 +225,10 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
return menuItems;
},
onSelected: (action) {
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => applyAction(action));
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
applyAction(action);
},
),
),

View file

@ -0,0 +1,159 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/geocoding_service.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/map/marker.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class MapInfoRow extends StatelessWidget {
final ValueNotifier<AvesEntry?> entryNotifier;
static const double iconPadding = 8.0;
static const double iconSize = 16.0;
static const double _interRowPadding = 2.0;
const MapInfoRow({
Key? key,
required this.entryNotifier,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final orientation = context.select<MediaQueryData, Orientation>((v) => v.orientation);
return ValueListenableBuilder<AvesEntry?>(
valueListenable: entryNotifier,
builder: (context, entry, child) {
final content = orientation == Orientation.portrait
? [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AddressRow(entry: entry),
const SizedBox(height: _interRowPadding),
_buildDate(context, entry),
],
),
),
]
: [
_buildDate(context, entry),
Expanded(
child: _AddressRow(entry: entry),
),
];
return Opacity(
opacity: entry != null ? 1 : 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: iconPadding),
const DotMarker(),
...content,
],
),
);
},
);
}
Widget _buildDate(BuildContext context, AvesEntry? entry) {
final locale = context.l10n.localeName;
final date = entry?.bestDate;
final dateText = date != null ? formatDateTime(date, locale) : Constants.overlayUnknown;
return Row(
children: [
const SizedBox(width: iconPadding),
const DecoratedIcon(AIcons.date, shadows: Constants.embossShadows, size: iconSize),
const SizedBox(width: iconPadding),
Text(
dateText,
strutStyle: Constants.overflowStrutStyle,
),
],
);
}
}
class _AddressRow extends StatefulWidget {
final AvesEntry? entry;
const _AddressRow({
Key? key,
required this.entry,
}) : super(key: key);
@override
_AddressRowState createState() => _AddressRowState();
}
class _AddressRowState extends State<_AddressRow> {
final ValueNotifier<String?> _addressLineNotifier = ValueNotifier(null);
@override
void didUpdateWidget(covariant _AddressRow oldWidget) {
super.didUpdateWidget(oldWidget);
final entry = widget.entry;
if (oldWidget.entry != entry) {
_getAddressLine(entry).then((addressLine) {
if (mounted && entry == widget.entry) {
_addressLineNotifier.value = addressLine;
}
});
}
}
@override
Widget build(BuildContext context) {
final entry = widget.entry;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: MapInfoRow.iconPadding),
const DecoratedIcon(AIcons.location, shadows: Constants.embossShadows, size: MapInfoRow.iconSize),
const SizedBox(width: MapInfoRow.iconPadding),
Expanded(
child: ValueListenableBuilder<String?>(
valueListenable: _addressLineNotifier,
builder: (context, addressLine, child) {
final location = addressLine ??
(entry == null
? Constants.overlayUnknown
: entry.hasAddress
? entry.shortAddress
: settings.coordinateFormat.format(entry.latLng!));
return Text(
location,
strutStyle: Constants.overflowStrutStyle,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
);
},
),
),
],
);
}
Future<String?> _getAddressLine(AvesEntry? entry) async {
if (entry != null && await availability.canLocatePlaces) {
final addresses = await GeocodingService.getAddress(entry.latLng!, entry.geocoderLocale);
if (addresses.isNotEmpty) {
final address = addresses.first;
return address.addressLine;
}
}
return null;
}
}

View file

@ -1,68 +1,152 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/map/controller.dart';
import 'package:aves/widgets/common/map/geo_map.dart';
import 'package:aves/widgets/common/map/theme.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.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/thumbnail/scroller.dart';
import 'package:flutter/foundation.dart';
import 'package:aves/widgets/map/map_info_row.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class MapPage extends StatefulWidget {
class MapPage extends StatelessWidget {
static const routeName = '/collection/map';
final List<AvesEntry> entries;
final CollectionLens collection;
final AvesEntry? initialEntry;
const MapPage({
Key? key,
required this.entries,
required this.collection,
this.initialEntry,
}) : super(key: key);
@override
_MapPageState createState() => _MapPageState();
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(
child: MediaQueryDataProvider(
child: Scaffold(
body: SafeArea(
left: false,
top: false,
right: false,
bottom: true,
child: MapPageContent(
collection: collection,
initialEntry: initialEntry,
),
),
),
),
);
}
}
class _MapPageState extends State<MapPage> {
final AvesMapController _mapController = AvesMapController();
late final ValueNotifier<bool> _isAnimatingNotifier;
final ValueNotifier<int> _selectedIndexNotifier = ValueNotifier(0);
final Debouncer _debouncer = Debouncer(delay: Durations.mapScrollDebounceDelay);
class MapPageContent extends StatefulWidget {
final CollectionLens collection;
final AvesEntry? initialEntry;
List<AvesEntry> get entries => widget.entries;
const MapPageContent({
Key? key,
required this.collection,
this.initialEntry,
}) : super(key: key);
@override
_MapPageContentState createState() => _MapPageContentState();
}
class _MapPageContentState extends State<MapPageContent> with SingleTickerProviderStateMixin {
final List<StreamSubscription> _subscriptions = [];
final AvesMapController _mapController = AvesMapController();
late final ValueNotifier<bool> _isPageAnimatingNotifier;
final ValueNotifier<int?> _selectedIndexNotifier = ValueNotifier(0);
final ValueNotifier<CollectionLens?> _regionCollectionNotifier = ValueNotifier(null);
final ValueNotifier<AvesEntry?> _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null);
final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay);
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
late AnimationController _overlayAnimationController;
late Animation<double> _overlayScale, _scrollerSize;
List<AvesEntry> get entries => widget.collection.sortedEntries;
CollectionLens? get regionCollection => _regionCollectionNotifier.value;
@override
void initState() {
super.initState();
if (settings.infoMapStyle.isGoogleMaps) {
_isAnimatingNotifier = ValueNotifier(true);
_isPageAnimatingNotifier = ValueNotifier(true);
Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) {
if (!mounted) return;
_isAnimatingNotifier.value = false;
_isPageAnimatingNotifier.value = false;
});
} else {
_isAnimatingNotifier = ValueNotifier(false);
_isPageAnimatingNotifier = ValueNotifier(false);
}
final initialEntry = widget.initialEntry;
if (initialEntry != null) {
final index = entries.indexOf(initialEntry);
if (index != -1) {
_selectedIndexNotifier.value = index;
}
}
_dotEntryNotifier.addListener(_updateInfoEntry);
_overlayAnimationController = AnimationController(
duration: Durations.viewerOverlayAnimation,
vsync: this,
);
_overlayScale = CurvedAnimation(
parent: _overlayAnimationController,
curve: Curves.easeOutBack,
);
_scrollerSize = CurvedAnimation(
parent: _overlayAnimationController,
curve: Curves.easeOutQuad,
);
_overlayVisible.addListener(_onOverlayVisibleChange);
_subscriptions.add(_mapController.idleUpdates.listen((event) => _onIdle(event.bounds)));
_selectedIndexNotifier.addListener(_onThumbnailIndexChange);
Future.delayed(Durations.pageTransitionAnimation * timeDilation + const Duration(seconds: 1), () {
final regionEntries = regionCollection?.sortedEntries ?? [];
final initialEntry = widget.initialEntry ?? regionEntries.firstOrNull;
if (initialEntry != null) {
final index = regionEntries.indexOf(initialEntry);
if (index != -1) {
_selectedIndexNotifier.value = index;
}
_onEntrySelected(initialEntry);
}
});
WidgetsBinding.instance!.addPostFrameCallback((_) => _onOverlayVisibleChange(animate: false));
}
@override
void dispose() {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
_dotEntryNotifier.removeListener(_updateInfoEntry);
_overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange);
_mapController.dispose();
_selectedIndexNotifier.removeListener(_onThumbnailIndexChange);
super.dispose();
@ -70,52 +154,211 @@ class _MapPageState extends State<MapPage> {
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: MapTheme(
interactive: true,
navigationButton: MapNavigationButton.back,
child: GeoMap(
controller: _mapController,
entries: entries,
initialEntry: widget.initialEntry,
isAnimatingNotifier: _isAnimatingNotifier,
onMarkerTap: (markerEntry, getClusterEntries) {
final index = entries.indexOf(markerEntry);
if (_selectedIndexNotifier.value != index) {
_selectedIndexNotifier.value = index;
} else {
_moveToEntry(markerEntry);
}
},
),
),
),
const Divider(),
Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) {
return ThumbnailScroller(
availableWidth: mqWidth,
entryCount: entries.length,
entryBuilder: (index) => entries[index],
indexNotifier: _selectedIndexNotifier,
);
},
),
],
),
),
return Selector<Settings, EntryMapStyle>(
selector: (context, s) => s.infoMapStyle,
builder: (context, mapStyle, child) {
late Widget scroller;
if (mapStyle.isGoogleMaps) {
// the Google map widget is too heavy for a smooth resizing animation
// so we just toggle visibility when overlay animation is done
scroller = ValueListenableBuilder<double>(
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: !_overlayAnimationController.isDismissed,
child: child!,
);
},
child: child,
);
} else {
// the Leaflet map widget is light enough for a smooth resizing animation
scroller = FadeTransition(
opacity: _scrollerSize,
child: SizeTransition(
sizeFactor: _scrollerSize,
axisAlignment: 1.0,
child: child,
),
);
}
return Column(
children: [
Expanded(child: _buildMap()),
scroller,
],
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
_buildScroller(),
],
),
);
}
void _onThumbnailIndexChange() => _moveToEntry(widget.entries[_selectedIndexNotifier.value]);
Widget _buildMap() {
return MapTheme(
interactive: true,
navigationButton: MapNavigationButton.back,
scale: _overlayScale,
child: GeoMap(
controller: _mapController,
entries: entries,
initialEntry: widget.initialEntry,
isAnimatingNotifier: _isPageAnimatingNotifier,
dotEntryNotifier: _dotEntryNotifier,
onMapTap: _toggleOverlay,
onMarkerTap: (markerEntry, getClusterEntries) async {
final index = regionCollection?.sortedEntries.indexOf(markerEntry);
if (index != null && _selectedIndexNotifier.value != index) {
_selectedIndexNotifier.value = index;
}
await Future.delayed(const Duration(milliseconds: 500));
context.read<HighlightInfo>().set(markerEntry);
},
),
);
}
void _moveToEntry(AvesEntry entry) => _debouncer(() => _mapController.moveTo(entry.latLng!));
Widget _buildScroller() {
return Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SafeArea(
top: false,
bottom: false,
child: MapInfoRow(entryNotifier: _infoEntryNotifier),
),
const SizedBox(height: 8),
Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) => ValueListenableBuilder<CollectionLens?>(
valueListenable: _regionCollectionNotifier,
builder: (context, regionCollection, child) {
final regionEntries = regionCollection?.sortedEntries ?? [];
return ThumbnailScroller(
availableWidth: mqWidth,
entryCount: regionEntries.length,
entryBuilder: (index) => regionEntries[index],
indexNotifier: _selectedIndexNotifier,
onTap: _onThumbnailTap,
heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.uri]),
highlightable: true,
);
},
),
),
],
),
Positioned.fill(
child: ValueListenableBuilder<CollectionLens?>(
valueListenable: _regionCollectionNotifier,
builder: (context, regionCollection, child) {
return regionCollection != null && regionCollection.isEmpty
? EmptyContent(
text: context.l10n.mapEmptyRegion,
fontSize: 18,
)
: const SizedBox();
},
),
),
],
);
}
void _onIdle(ZoomedBounds bounds) {
AvesEntry? selectedEntry;
if (regionCollection != null) {
final regionEntries = regionCollection!.sortedEntries;
final selectedIndex = _selectedIndexNotifier.value;
selectedEntry = selectedIndex != null && selectedIndex < regionEntries.length ? regionEntries[selectedIndex] : null;
}
_regionCollectionNotifier.value = CollectionLens(
source: widget.collection.source,
listenToSource: false,
fixedSelection: entries.where((entry) => bounds.contains(entry.latLng!)).toList(),
);
// get entries from the new collection, so the entry order is the same
// as the one used by the thumbnail scroller (considering sort/section/group)
final regionEntries = regionCollection!.sortedEntries;
final selectedIndex = (selectedEntry != null && regionEntries.contains(selectedEntry))
? regionEntries.indexOf(selectedEntry)
: regionEntries.isEmpty
? null
: 0;
_selectedIndexNotifier.value = selectedIndex;
// force update, as the region entries may change without a change of index
_onThumbnailIndexChange();
}
AvesEntry? _getRegionEntry(int? index) {
if (index != null && regionCollection != null) {
final regionEntries = regionCollection!.sortedEntries;
if (index < regionEntries.length) {
return regionEntries[index];
}
}
return null;
}
void _onThumbnailTap(int index) => _goToViewer(_getRegionEntry(index));
void _onThumbnailIndexChange() => _onEntrySelected(_getRegionEntry(_selectedIndexNotifier.value));
void _onEntrySelected(AvesEntry? selectedEntry) => _dotEntryNotifier.value = selectedEntry;
void _updateInfoEntry() {
final selectedEntry = _dotEntryNotifier.value;
if (_infoEntryNotifier.value == null || selectedEntry == null) {
_infoEntryNotifier.value = selectedEntry;
} else {
_infoDebouncer(() => _infoEntryNotifier.value = selectedEntry);
}
}
void _goToViewer(AvesEntry? initialEntry) {
if (initialEntry == null) return;
Navigator.push(
context,
TransparentMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (c, a, sa) {
return EntryViewerPage(
collection: regionCollection,
initialEntry: initialEntry,
);
},
),
);
}
// overlay
void _toggleOverlay() => _overlayVisible.value = !_overlayVisible.value;
Future<void> _onOverlayVisibleChange({bool animate = true}) async {
if (_overlayVisible.value) {
if (animate) {
await _overlayAnimationController.forward();
} else {
_overlayAnimationController.value = _overlayAnimationController.upperBound;
}
} else {
if (animate) {
await _overlayAnimationController.reverse();
} else {
_overlayAnimationController.reset();
}
}
}
}

View file

@ -56,9 +56,10 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
),
];
},
onSelected: (action) {
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(action));
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
_onActionSelected(action);
},
),
),

View file

@ -60,9 +60,10 @@ class InfoAppBar extends StatelessWidget {
),
];
},
onSelected: (action) {
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => EntryInfoActionDelegate(entry).onActionSelected(context, action));
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
EntryInfoActionDelegate(entry).onActionSelected(context, action);
},
),
),

View file

@ -93,6 +93,7 @@ class _LocationSectionState extends State<LocationSection> {
entries: [entry],
isAnimatingNotifier: widget.isScrollingNotifier,
onUserZoomChange: (zoom) => settings.infoMapZoom = zoom,
onMarkerTap: collection != null ? (_, __) => _openMapPage(context) : null,
openMapPage: collection != null ? _openMapPage : null,
),
),
@ -116,13 +117,18 @@ class _LocationSectionState extends State<LocationSection> {
}
void _openMapPage(BuildContext context) {
final entries = (collection?.sortedEntries ?? []).where((entry) => entry.hasGps).toList();
final baseCollection = collection;
if (baseCollection == null) return;
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
entries: entries,
collection: CollectionLens(
source: baseCollection.source,
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).toList(),
),
initialEntry: entry,
),
),

View file

@ -233,9 +233,10 @@ class _ButtonRow extends StatelessWidget {
child: MenuIconTheme(
child: AvesPopupMenuButton<VideoAction>(
itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
onSelected: (action) {
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action));
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
onActionSelected(action);
},
onMenuOpened: onActionMenuOpened,
),

View file

@ -12,6 +12,7 @@ import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:tuple/tuple.dart';
class RasterImageView extends StatefulWidget {
@ -156,7 +157,7 @@ class _RasterImageViewState extends State<RasterImageView> {
_tileTransform = Matrix4.identity()
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-toRadians(rotationDegrees.toDouble()))
..rotateZ(-degToRadian(rotationDegrees.toDouble()))
..translate(-_displaySize.width / 2.0, -_displaySize.height / 2.0);
}
_isTilingInitialized = true;

View file

@ -1,5 +1,4 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
@ -9,6 +8,7 @@ import 'package:aves/widgets/viewer/visual/subtitle/style.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:latlong2/latlong.dart' as angles;
import 'package:provider/provider.dart';
class VideoSubtitles extends StatelessWidget {
@ -186,9 +186,9 @@ class VideoSubtitles extends StatelessWidget {
if (extraStyle.rotating) {
// for perspective
transform.setEntry(3, 2, 0.001);
final x = -toRadians(extraStyle.rotationX ?? 0);
final y = -toRadians(extraStyle.rotationY ?? 0);
final z = -toRadians(extraStyle.rotationZ ?? 0);
final x = -angles.degToRadian(extraStyle.rotationX ?? 0);
final y = -angles.degToRadian(extraStyle.rotationY ?? 0);
final z = -angles.degToRadian(extraStyle.rotationZ ?? 0);
if (x != 0) transform.rotateX(x);
if (y != 0) transform.rotateY(y);
if (z != 0) transform.rotateZ(z);

View file

@ -0,0 +1,10 @@
import 'package:aves/utils/geo_utils.dart';
import 'package:latlong2/latlong.dart';
import 'package:test/test.dart';
void main() {
test('bounds center', () {
expect(getLatLngCenter([LatLng(10, 30), LatLng(30, 50)]), LatLng(20.28236664671092, 39.351653000319956));
expect(getLatLngCenter([LatLng(10, -179), LatLng(30, 179)]), LatLng(20.00279344048298, -179.9358157370226));
});
}

View file

@ -1,19 +1,7 @@
import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:test/test.dart';
void main() {
test('convert angles in radians to degrees', () {
expect(toDegrees(pi), 180);
expect(toDegrees(-pi / 2), -90);
});
test('convert angles in degrees to radians', () {
expect(toRadians(180), pi);
expect(toRadians(-270), pi * -3 / 2);
});
test('highest power of 2 that is smaller than or equal to the number', () {
expect(highestPowerOf2(1024), 1024);
expect(highestPowerOf2(42), 32);