diff --git a/android/build.gradle b/android/build.gradle index 316cfb5db..f4a02033b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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() diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7ae044544..f052c630c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 41f9434c4..761025db0 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -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": "열기", diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 2bd814aa3..8936faa5e 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -28,6 +28,7 @@ class CollectionLens with ChangeNotifier { final List _subscriptions = []; int? id; bool listenToSource; + List? fixedSelection; List _filteredSortedEntries = []; @@ -38,6 +39,7 @@ class CollectionLens with ChangeNotifier { Iterable? 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) { diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 29ae6881c..868f069ca 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -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 diff --git a/lib/utils/geo_utils.dart b/lib/utils/geo_utils.dart new file mode 100644 index 000000000..66376135c --- /dev/null +++ b/lib/utils/geo_utils.dart @@ -0,0 +1,27 @@ +import 'dart:math'; + +import 'package:latlong2/latlong.dart'; + +LatLng getLatLngCenter(List 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)); +} diff --git a/lib/utils/math_utils.dart b/lib/utils/math_utils.dart index 583d131e9..89a0e6ed5 100644 --- a/lib/utils/math_utils.dart +++ b/lib/utils/math_utils.dart @@ -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(); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index fa9f6381d..6ec76cd79 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -242,9 +242,10 @@ class _CollectionAppBarState extends State 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); }, ), ); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 7fb7d2d67..c672758b8 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -244,14 +244,19 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware void _goToMap(BuildContext context) { final selection = context.read>(); - final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : context.read().sortedEntries; + final collection = context.read(); + 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(), + ), ), ), ); diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index 4e2bb857f..6e9c51380 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -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]), ), ), ); diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 9a3d18bc8..e9df80381 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -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); } } } diff --git a/lib/widgets/common/identity/empty.dart b/lib/widgets/common/identity/empty.dart index 5e54ce377..8a1c22c68 100644 --- a/lib/widgets/common/identity/empty.dart +++ b/lib/widgets/common/identity/empty.dart @@ -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, ), ], ), diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index cba10e573..37aea47a2 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -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((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( - 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( + 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( - context: context, - builder: (context) { - return AvesSelectionDialog( - 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( + context: context, + builder: (context) { + return AvesSelectionDialog( + 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((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>( + 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( + selector: (context, v) => v.visualDensity, + builder: (context, visualDensity, child) => IconButton( + iconSize: 20, + visualDensity: visualDensity, + icon: icon, + onPressed: onPressed, + tooltip: tooltip, + ), + ), ), ), ), diff --git a/lib/widgets/common/map/controller.dart b/lib/widgets/common/map/controller.dart index 584f6ea7d..d47cd92d1 100644 --- a/lib/widgets/common/map/controller.dart +++ b/lib/widgets/common/map/controller.dart @@ -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 get _events => _streamController.stream; - Stream get moveEvents => _events.where((event) => event is MapControllerMoveEvent).cast(); + Stream get moveCommands => _events.where((event) => event is MapControllerMoveEvent).cast(); + + Stream get idleUpdates => _events.where((event) => event is MapIdleUpdate).cast(); 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); +} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 05f2d9453..028090ee5 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -30,12 +30,14 @@ class GeoMap extends StatefulWidget { final List entries; final AvesEntry? initialEntry; final ValueNotifier isAnimatingNotifier; + final ValueNotifier? 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 { 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 { 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 { 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 { child: child, ) : Expanded(child: child), - Attribution(style: mapStyle), + SafeArea( + top: false, + bottom: false, + child: Attribution(style: mapStyle), + ), ], ); diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index 301dacef8..0b844b1d4 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -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? 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 with WidgetsBindingObse Map _geoEntryByMarkerKey = {}; final Map _markerBitmaps = {}; final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier(); + Uint8List? _dotMarkerBitmap; ValueNotifier get boundsNotifier => widget.boundsNotifier; @@ -84,7 +91,7 @@ class _EntryGoogleMapState extends State 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 with WidgetsBindingObse Widget build(BuildContext context) { return Stack( children: [ + MarkerGeneratorWidget( + markers: const [DotMarker(key: Key('dot'))], + isReadyToRender: (key) => true, + onRendered: (key, bitmap) => _dotMarkerBitmap = bitmap, + ), MarkerGeneratorWidget( markers: _geoEntryByMarkerKey.keys.map(widget.markerWidgetBuilder).toList(), isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent), @@ -154,43 +166,60 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse }); final interactive = context.select((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( + 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 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, ); diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index b27893e06..6540ea727 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -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? 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 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 with TickerProviderSt Widget _buildMap() { final markerSize = widget.markerSize; + final dotMarkerSize = widget.dotMarkerSize; + + final interactive = context.select((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 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 with TickerProviderSt ); }).toList(); - final interactive = context.select((v) => v.interactive); return FlutterMap( options: MapOptions( center: bounds.center, @@ -141,6 +154,7 @@ class _EntryLeafletMapState extends State 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 with TickerProviderSt rotateAlignment: Alignment.bottomCenter, ), ), + ValueListenableBuilder( + 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 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 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 with TickerProviderSt await _animateCamera((animation) => _leafletMapController.rotate(rotationTween.evaluate(animation))); } - Future _zoomBy(double amount) async { + Future _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(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 _moveTo(LatLng point) async { diff --git a/lib/widgets/common/map/leaflet/scalebar_utils.dart b/lib/widgets/common/map/leaflet/scalebar_utils.dart index b3d852241..8cdc50ca4 100644 --- a/lib/widgets/common/map/leaflet/scalebar_utils.dart +++ b/lib/widgets/common/map/leaflet/scalebar_utils.dart @@ -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); diff --git a/lib/widgets/common/map/marker.dart b/lib/widgets/common/map/marker.dart index 0eef09423..6a82a2814 100644 --- a/lib/widgets/common/map/marker.dart +++ b/lib/widgets/common/map/marker.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/common/map/theme.dart b/lib/widgets/common/map/theme.dart index 22d064d71..570813bca 100644 --- a/lib/widgets/common/map/theme.dart +++ b/lib/widgets/common/map/theme.dart @@ -7,6 +7,7 @@ enum MapNavigationButton { back, map } class MapTheme extends StatelessWidget { final bool interactive; final MapNavigationButton navigationButton; + final Animation 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 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, }); } diff --git a/lib/widgets/common/map/zoomed_bounds.dart b/lib/widgets/common/map/zoomed_bounds.dart index 2faca0e46..1175a62cb 100644 --- a/lib/widgets/common/map/zoomed_bounds.dart +++ b/lib/widgets/common/map/zoomed_bounds.dart @@ -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 get boundingBox => [west, south, east, north]; + // returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster + List 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 get props => [west, south, east, north, zoom, rotation]; + List 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)); + } } diff --git a/lib/widgets/common/thumbnail/decorated.dart b/lib/widgets/common/thumbnail/decorated.dart index 50f52623c..ebb815eea 100644 --- a/lib/widgets/common/thumbnail/decorated.dart +++ b/lib/widgets/common/thumbnail/decorated.dart @@ -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? 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( diff --git a/lib/widgets/common/thumbnail/scroller.dart b/lib/widgets/common/thumbnail/scroller.dart index dc13d0698..4991b5941 100644 --- a/lib/widgets/common/thumbnail/scroller.dart +++ b/lib/widgets/common/thumbnail/scroller.dart @@ -12,6 +12,9 @@ class ThumbnailScroller extends StatefulWidget { final int entryCount; final AvesEntry? Function(int index) entryBuilder; final ValueNotifier 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 { 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 { // 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 { ); }, ), - ) + ), ], ); }, diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 0c8cbd395..71d80804f 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -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); }, ), ), diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index f19673c07..9f18ae074 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -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 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(), + fixedSelection: _selectedEntries(context, filters).where((entry) => entry.hasGps).toList(), + ), ), ), ); diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index c60169bb7..dac903429 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -225,9 +225,10 @@ class _FilterGridAppBarState extends State applyAction(action)); + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + applyAction(action); }, ), ), diff --git a/lib/widgets/map/map_info_row.dart b/lib/widgets/map/map_info_row.dart new file mode 100644 index 000000000..1cf9b4681 --- /dev/null +++ b/lib/widgets/map/map_info_row.dart @@ -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 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((v) => v.orientation); + + return ValueListenableBuilder( + 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 _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( + 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 _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; + } +} diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index c59e7215e..845a4f5d6 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -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 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 { - final AvesMapController _mapController = AvesMapController(); - late final ValueNotifier _isAnimatingNotifier; - final ValueNotifier _selectedIndexNotifier = ValueNotifier(0); - final Debouncer _debouncer = Debouncer(delay: Durations.mapScrollDebounceDelay); +class MapPageContent extends StatefulWidget { + final CollectionLens collection; + final AvesEntry? initialEntry; - List get entries => widget.entries; + const MapPageContent({ + Key? key, + required this.collection, + this.initialEntry, + }) : super(key: key); + + @override + _MapPageContentState createState() => _MapPageContentState(); +} + +class _MapPageContentState extends State with SingleTickerProviderStateMixin { + final List _subscriptions = []; + final AvesMapController _mapController = AvesMapController(); + late final ValueNotifier _isPageAnimatingNotifier; + final ValueNotifier _selectedIndexNotifier = ValueNotifier(0); + final ValueNotifier _regionCollectionNotifier = ValueNotifier(null); + final ValueNotifier _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null); + final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); + final ValueNotifier _overlayVisible = ValueNotifier(true); + late AnimationController _overlayAnimationController; + late Animation _overlayScale, _scrollerSize; + + List 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 { @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( - 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( + 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( + 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().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( + selector: (c, mq) => mq.size.width, + builder: (c, mqWidth, child) => ValueListenableBuilder( + 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( + 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 _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(); + } + } + } } diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index d1ff30060..a2bd060f8 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -56,9 +56,10 @@ class _SettingsPageState extends State 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); }, ), ), diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index c2c472060..e948e4904 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -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); }, ), ), diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index a76dad72b..670d2f6d2 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -93,6 +93,7 @@ class _LocationSectionState extends State { 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 { } 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, ), ), diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index a0ba9ee3b..7ba77ab50 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -233,9 +233,10 @@ class _ButtonRow extends StatelessWidget { child: MenuIconTheme( child: AvesPopupMenuButton( 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, ), diff --git a/lib/widgets/viewer/visual/raster.dart b/lib/widgets/viewer/visual/raster.dart index 5873b1305..5e1ac19f3 100644 --- a/lib/widgets/viewer/visual/raster.dart +++ b/lib/widgets/viewer/visual/raster.dart @@ -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 { _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; diff --git a/lib/widgets/viewer/visual/subtitle/subtitle.dart b/lib/widgets/viewer/visual/subtitle/subtitle.dart index 3dd359ac3..280038833 100644 --- a/lib/widgets/viewer/visual/subtitle/subtitle.dart +++ b/lib/widgets/viewer/visual/subtitle/subtitle.dart @@ -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); diff --git a/test/utils/geo_utils_test.dart b/test/utils/geo_utils_test.dart new file mode 100644 index 000000000..0b2875ab9 --- /dev/null +++ b/test/utils/geo_utils_test.dart @@ -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)); + }); +} diff --git a/test/utils/math_utils_test.dart b/test/utils/math_utils_test.dart index f0986f22a..bb8f5cae2 100644 --- a/test/utils/math_utils_test.dart +++ b/test/utils/math_utils_test.dart @@ -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);