diff --git a/lib/geo/format.dart b/lib/geo/format.dart deleted file mode 100644 index a52dfd192..000000000 --- a/lib/geo/format.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:aves/utils/math_utils.dart'; -import 'package:intl/intl.dart'; -import 'package:latlong2/latlong.dart'; - -String _decimal2sexagesimal(final double degDecimal) { - List _split(final double value) { - // NumberFormat is necessary to create digit after comma if the value - // has no decimal point (only necessary for browser) - final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.'); - return [ - int.parse(tmp[0]).abs(), - int.parse(tmp[1]), - ]; - } - - final deg = _split(degDecimal)[0]; - final minDecimal = (degDecimal.abs() - deg) * 60; - final min = _split(minDecimal)[0]; - final sec = (minDecimal - min) * 60; - - return '$deg° $min′ ${roundToPrecision(sec, decimals: 2).toStringAsFixed(2)}″'; -} - -// returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E'] -List toDMS(LatLng latLng) { - final lat = latLng.latitude; - final lng = latLng.longitude; - return [ - '${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}', - '${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}', - ]; -} diff --git a/lib/model/filters/coordinate.dart b/lib/model/filters/coordinate.dart new file mode 100644 index 000000000..fed0f5702 --- /dev/null +++ b/lib/model/filters/coordinate.dart @@ -0,0 +1,63 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/coordinate_format.dart'; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/geo_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +class CoordinateFilter extends CollectionFilter { + static const type = 'coordinate'; + + final LatLng sw; + final LatLng ne; + final bool minuteSecondPadding; + + @override + List get props => [sw, ne]; + + const CoordinateFilter(this.sw, this.ne, {this.minuteSecondPadding = false}); + + CoordinateFilter.fromMap(Map json) + : this( + LatLng.fromJson(json['sw']), + LatLng.fromJson(json['ne']), + ); + + @override + Map toMap() => { + 'type': type, + 'sw': sw.toJson(), + 'ne': ne.toJson(), + }; + + @override + EntryFilter get test => (entry) => GeoUtils.contains(sw, ne, entry.latLng); + + String _formatBounds(CoordinateFormat format) { + String s(LatLng latLng) => format.format( + latLng, + minuteSecondPadding: minuteSecondPadding, + dmsSecondDecimals: 0, + ); + return '${s(ne)}\n${s(sw)}'; + } + + @override + String get universalLabel => _formatBounds(CoordinateFormat.decimal); + + @override + String getLabel(BuildContext context) => _formatBounds(context.read().coordinateFormat); + + @override + Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.geoBounds, size: size); + + @override + String get category => type; + + @override + String get key => '$type-$sw-$ne'; +} diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 725085d3a..7fc08a231 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; @@ -24,6 +25,7 @@ abstract class CollectionFilter extends Equatable implements Comparable n.toStringAsFixed(6)).join(', '); } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 128714ee1..d461d42d0 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -142,7 +142,7 @@ class Settings extends ChangeNotifier { Future setContextualDefaults() async { // performance final performanceClass = await deviceService.getPerformanceClass(); - enableOverlayBlurEffect = performanceClass >= 30; + enableOverlayBlurEffect = performanceClass >= 29; // availability final hasPlayServices = await availability.hasPlayServices; diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 8817bcf65..7f3eb7349 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -46,6 +46,7 @@ class AIcons { static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; static const IconData favouriteActive = Icons.favorite; + static const IconData geoBounds = Icons.public_outlined; static const IconData goUp = Icons.arrow_upward_outlined; static const IconData group = Icons.group_work_outlined; static const IconData hide = Icons.visibility_off_outlined; diff --git a/lib/utils/geo_utils.dart b/lib/utils/geo_utils.dart index 66376135c..64d8ed44d 100644 --- a/lib/utils/geo_utils.dart +++ b/lib/utils/geo_utils.dart @@ -1,27 +1,79 @@ import 'dart:math'; +import 'package:aves/utils/math_utils.dart'; +import 'package:intl/intl.dart'; import 'package:latlong2/latlong.dart'; -LatLng getLatLngCenter(List points) { - double x = 0; - double y = 0; - double z = 0; +class GeoUtils { + static String _decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) { + List _split(final double value) { + // NumberFormat is necessary to create digit after comma if the value + // has no decimal point (only necessary for browser) + final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.'); + return [ + int.parse(tmp[0]).abs(), + int.parse(tmp[1]), + ]; + } - 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 deg = _split(degDecimal)[0]; + final minDecimal = (degDecimal.abs() - deg) * 60; + final min = _split(minDecimal)[0]; + final sec = (minDecimal - min) * 60; - final pointCount = points.length; - x /= pointCount; - y /= pointCount; - z /= pointCount; + final secRounded = roundToPrecision(sec, decimals: secondDecimals); + var minText = '$min'; + var secText = secRounded.toStringAsFixed(secondDecimals); + if (minuteSecondPadding) { + minText = minText.padLeft(2, '0'); + secText = secText.padLeft(secondDecimals > 0 ? 3 + secondDecimals : 2, '0'); + } - final lng = atan2(y, x); - final hyp = sqrt(x * x + y * y); - final lat = atan2(z, hyp); - return LatLng(radianToDeg(lat), radianToDeg(lng)); + return '$deg° $minText′ $secText″'; + } + + // returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E'] + static List toDMS(LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) { + final lat = latLng.latitude; + final lng = latLng.longitude; + return [ + '${_decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals)} ${lat < 0 ? 'S' : 'N'}', + '${_decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals)} ${lng < 0 ? 'W' : 'E'}', + ]; + } + + static 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)); + } + + static bool contains(LatLng sw, LatLng ne, LatLng? point) { + if (point == null) return false; + 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/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 859fac9c3..b774dc4c6 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -37,12 +37,11 @@ class AvesFilterDecoration { class AvesFilterChip extends StatefulWidget { final CollectionFilter filter; - final bool removable; - final bool showGenericIcon; + final bool removable, showGenericIcon, useFilterColor; final AvesFilterDecoration? decoration; final String? banner; final Widget? details; - final double padding; + final double padding, maxWidth; final HeroType heroType; final FilterCallback? onTap; final OffsetFilterCallback? onLongPress; @@ -52,7 +51,7 @@ class AvesFilterChip extends StatefulWidget { static const double outlineWidth = 2; static const double minChipHeight = kMinInteractiveDimension; static const double minChipWidth = 80; - static const double maxChipWidth = 160; + static const double defaultMaxChipWidth = 160; static const double iconSize = 18; static const double fontSize = 14; static const double decoratedContentVerticalPadding = 5; @@ -62,10 +61,12 @@ class AvesFilterChip extends StatefulWidget { required this.filter, this.removable = false, this.showGenericIcon = true, + this.useFilterColor = true, this.decoration, this.banner, this.details, this.padding = 6.0, + this.maxWidth = defaultMaxChipWidth, this.heroType = HeroType.onTap, this.onTap, this.onLongPress = showDefaultLongPressMenu, @@ -181,7 +182,6 @@ class _AvesFilterChipState extends State { ), softWrap: false, overflow: TextOverflow.fade, - maxLines: 1, ), ), if (trailing != null) ...[ @@ -216,7 +216,7 @@ class _AvesFilterChipState extends State { ); } else { content = Padding( - padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2), + padding: EdgeInsets.symmetric(horizontal: padding * 2), child: content, ); } @@ -224,9 +224,9 @@ class _AvesFilterChipState extends State { final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)); final banner = widget.banner; Widget chip = Container( - constraints: const BoxConstraints( + constraints: BoxConstraints( minWidth: AvesFilterChip.minChipWidth, - maxWidth: AvesFilterChip.maxChipWidth, + maxWidth: widget.maxWidth, minHeight: AvesFilterChip.minChipHeight, ), child: Stack( @@ -263,16 +263,13 @@ class _AvesFilterChipState extends State { return DecoratedBox( decoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( - color: _outlineColor, + color: widget.useFilterColor ? _outlineColor : AvesFilterChip.defaultOutlineColor, width: AvesFilterChip.outlineWidth, )), borderRadius: borderRadius, ), position: DecorationPosition.foreground, - child: Padding( - padding: EdgeInsets.symmetric(vertical: decoration != null ? 0 : 8), - child: content, - ), + child: content, ); }, ), diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index a4234f602..0745868a2 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -1,16 +1,20 @@ +import 'package:aves/model/filters/coordinate.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/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/map/compass.dart'; import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -58,119 +62,126 @@ class MapButtonPanel extends StatelessWidget { break; } + final showCoordinateFilter = context.select((v) => v.showCoordinateFilter); 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: EdgeInsets.all(padding), - child: TooltipTheme( - data: TooltipTheme.of(context).copyWith( - preferBelow: false, - ), - 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; - final animationDuration = context.select((v) => v.viewerOverlayAnimation); - return IgnorePointer( - ignoring: opacity == 0, - child: AnimatedOpacity( - opacity: opacity, - duration: animationDuration, - child: MapOverlayButton( - icon: Transform( - origin: iconSize.center(Offset.zero), - transform: Matrix4.rotationZ(degToRadian(degrees)), - child: CustomPaint( - painter: CompassPainter( - color: iconTheme.color!, + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: SafeArea( + bottom: false, + child: Stack( + children: [ + Positioned( + left: padding, + right: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(top: padding), + 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; + final animationDuration = context.select((v) => v.viewerOverlayAnimation); + return IgnorePointer( + ignoring: opacity == 0, + child: AnimatedOpacity( + opacity: opacity, + duration: animationDuration, + 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, - ), - ], + showCoordinateFilter + ? Expanded( + child: _OverlayCoordinateFilterChip( + boundsNotifier: boundsNotifier, + padding: padding, + ), + ) + : const Spacer(), + Padding( + padding: EdgeInsets.only(top: padding), + child: 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, - ), - SizedBox(height: padding), - MapOverlayButton( - icon: const Icon(AIcons.zoomOut), - onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null, - tooltip: context.l10n.mapZoomOutTooltip, - ), - ], - ), - ), - ], + ], + ), ), - ), + Positioned( + right: padding, + bottom: padding, + 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, + ), + ], + ), + ), + ], ), ), ), @@ -225,3 +236,102 @@ class MapOverlayButton extends StatelessWidget { ); } } + +class _OverlayCoordinateFilterChip extends StatefulWidget { + final ValueNotifier boundsNotifier; + final double padding; + + const _OverlayCoordinateFilterChip({ + Key? key, + required this.boundsNotifier, + required this.padding, + }) : super(key: key); + + @override + _OverlayCoordinateFilterChipState createState() => _OverlayCoordinateFilterChipState(); +} + +class _OverlayCoordinateFilterChipState extends State<_OverlayCoordinateFilterChip> { + final Debouncer _debouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); + final ValueNotifier _idleBoundsNotifier = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant _OverlayCoordinateFilterChip oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(_OverlayCoordinateFilterChip widget) { + widget.boundsNotifier.addListener(_onBoundsChanged); + } + + void _unregisterWidget(_OverlayCoordinateFilterChip widget) { + widget.boundsNotifier.removeListener(_onBoundsChanged); + } + + @override + Widget build(BuildContext context) { + final blurred = settings.enableOverlayBlurEffect; + return Theme( + data: Theme.of(context).copyWith( + scaffoldBackgroundColor: overlayBackgroundColor(blurred: blurred), + ), + child: Align( + alignment: Alignment.topLeft, + child: Selector>( + selector: (context, v) => v.scale, + builder: (context, scale, child) => SizeTransition( + sizeFactor: scale, + axisAlignment: 1, + child: FadeTransition( + opacity: scale, + child: child, + ), + ), + child: ValueListenableBuilder( + valueListenable: _idleBoundsNotifier, + builder: (context, bounds, child) { + if (bounds == null) return const SizedBox(); + final filter = CoordinateFilter( + bounds.sw, + bounds.ne, + // more stable format when bounds change + minuteSecondPadding: true, + ); + return Padding( + padding: EdgeInsets.all(widget.padding), + child: BlurredRRect( + enabled: blurred, + borderRadius: AvesFilterChip.defaultRadius, + child: AvesFilterChip( + filter: filter, + useFilterColor: false, + maxWidth: double.infinity, + onTap: (filter) => FilterSelectedNotification(CoordinateFilter(bounds.sw, bounds.ne) ).dispatch(context), + ), + ), + ); + }, + ), + ), + ), + ); + } + + void _onBoundsChanged() { + _debouncer(() => _idleBoundsNotifier.value = widget.boundsNotifier.value); + } +} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 028090ee5..975f4d343 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -96,7 +96,7 @@ class _GeoMapState extends State { if (points.length != geoEntry.pointsSize) { // `Fluster.points()` method does not always return all the points contained in a cluster // the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`) - _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(widget.entries.length)); + _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(entries.length)); points = _slowMarkerCluster!.points(clusterId); assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry'); } diff --git a/lib/widgets/common/map/theme.dart b/lib/widgets/common/map/theme.dart index c2690d2f2..b0e70ee40 100644 --- a/lib/widgets/common/map/theme.dart +++ b/lib/widgets/common/map/theme.dart @@ -5,7 +5,7 @@ import 'package:provider/provider.dart'; enum MapNavigationButton { back, map } class MapTheme extends StatelessWidget { - final bool interactive; + final bool interactive, showCoordinateFilter; final MapNavigationButton navigationButton; final Animation scale; final VisualDensity? visualDensity; @@ -15,6 +15,7 @@ class MapTheme extends StatelessWidget { const MapTheme({ Key? key, required this.interactive, + required this.showCoordinateFilter, required this.navigationButton, this.scale = kAlwaysCompleteAnimation, this.visualDensity, @@ -28,6 +29,7 @@ class MapTheme extends StatelessWidget { update: (context, settings, __) { return MapThemeData( interactive: interactive, + showCoordinateFilter: showCoordinateFilter, navigationButton: navigationButton, scale: scale, visualDensity: visualDensity, @@ -40,7 +42,7 @@ class MapTheme extends StatelessWidget { } class MapThemeData { - final bool interactive; + final bool interactive, showCoordinateFilter; final MapNavigationButton navigationButton; final Animation scale; final VisualDensity? visualDensity; @@ -48,6 +50,7 @@ class MapThemeData { const MapThemeData({ required this.interactive, + required this.showCoordinateFilter, required this.navigationButton, required this.scale, required this.visualDensity, diff --git a/lib/widgets/common/map/zoomed_bounds.dart b/lib/widgets/common/map/zoomed_bounds.dart index 1175a62cb..1a277a0fa 100644 --- a/lib/widgets/common/map/zoomed_bounds.dart +++ b/lib/widgets/common/map/zoomed_bounds.dart @@ -13,7 +13,7 @@ class ZoomedBounds extends Equatable { // returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster List get boundingBox => [sw.longitude, sw.latitude, ne.longitude, ne.latitude]; - LatLng get center => getLatLngCenter([sw, ne]); + LatLng get center => GeoUtils.getLatLngCenter([sw, ne]); @override List get props => [sw, ne, zoom, rotation]; @@ -63,13 +63,5 @@ class ZoomedBounds extends Equatable { ); } - 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)); - } + bool contains(LatLng point) => GeoUtils.contains(sw, ne, point); } diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index c48bfecce..4960fe20e 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/coordinate.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/map_style.dart'; @@ -8,6 +10,7 @@ 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/collection/collection_page.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'; @@ -20,6 +23,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:aves/widgets/map/map_info_row.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -87,10 +91,10 @@ class _MapPageContentState extends State with SingleTickerProvid late AnimationController _overlayAnimationController; late Animation _overlayScale, _scrollerSize; - List get entries => widget.collection.sortedEntries; - CollectionLens? get regionCollection => _regionCollectionNotifier.value; + CollectionLens get openingCollection => widget.collection; + @override void initState() { super.initState(); @@ -154,49 +158,55 @@ class _MapPageContentState extends State with SingleTickerProvid @override Widget build(BuildContext context) { - 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, - ], - ); + return NotificationListener( + onNotification: (notification) { + _goToCollection(notification.filter); + return true; }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - const Divider(height: 0), - _buildScroller(), - ], + child: 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 SizedBox(height: 8), + const Divider(height: 0), + _buildScroller(), + ], + ), ), ); } @@ -204,13 +214,14 @@ class _MapPageContentState extends State with SingleTickerProvid Widget _buildMap() { return MapTheme( interactive: true, + showCoordinateFilter: true, navigationButton: MapNavigationButton.back, scale: _overlayScale, child: GeoMap( // key is expected by test driver key: const Key('map_view'), controller: _mapController, - entries: entries, + entries: openingCollection.sortedEntries, initialEntry: widget.initialEntry, isAnimatingNotifier: _isPageAnimatingNotifier, dotEntryNotifier: _dotEntryNotifier, @@ -285,9 +296,13 @@ class _MapPageContentState extends State with SingleTickerProvid } _regionCollectionNotifier.value = CollectionLens( - source: widget.collection.source, + source: openingCollection.source, listenToSource: false, - fixedSelection: entries.where((entry) => bounds.contains(entry.latLng!)).toList(), + fixedSelection: openingCollection.sortedEntries, + filters: [ + ...openingCollection.filters.whereNot((v) => v is CoordinateFilter), + CoordinateFilter(bounds.sw, bounds.ne), + ], ); // get entries from the new collection, so the entry order is the same @@ -345,6 +360,24 @@ class _MapPageContentState extends State with SingleTickerProvid ); } + void _goToCollection(CollectionFilter filter) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) { + return CollectionPage( + collection: CollectionLens( + source: openingCollection.source, + filters: openingCollection.filters, + )..addFilter(filter), + ); + }, + ), + (route) => false, + ); + } + // overlay void _toggleOverlay() => _overlayVisible.value = !_overlayVisible.value; diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index 36403ffb7..2d5754440 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -21,7 +21,7 @@ class FilterTable extends StatelessWidget { required this.onFilterSelection, }) : super(key: key); - static const chipWidth = AvesFilterChip.maxChipWidth; + static const chipWidth = AvesFilterChip.defaultMaxChipWidth; static const countWidth = 32.0; static const percentIndicatorMinWidth = 80.0; diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index 670d2f6d2..3272d3e21 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -86,6 +86,7 @@ class _LocationSectionState extends State { if (widget.showTitle) const SectionRow(icon: AIcons.location), MapTheme( interactive: false, + showCoordinateFilter: false, navigationButton: MapNavigationButton.map, visualDensity: VisualDensity.compact, mapHeight: 200, diff --git a/test/geo/format_test.dart b/test/geo/format_test.dart deleted file mode 100644 index 4c97054ab..000000000 --- a/test/geo/format_test.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:aves/geo/format.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:test/test.dart'; - -void main() { - test('Decimal degrees to DMS (sexagesimal)', () { - expect(toDMS(LatLng(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam - expect(toDMS(LatLng(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund - expect(toDMS(LatLng(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo - expect(toDMS(LatLng(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio - expect(toDMS(LatLng(0, 0)), ['0° 0′ 0.00″ N', '0° 0′ 0.00″ E']); - }); -} diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart index b111cc957..af979fad6 100644 --- a/test/model/filters_test.dart +++ b/test/model/filters_test.dart @@ -1,4 +1,5 @@ import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; @@ -8,6 +9,7 @@ import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/services/common/services.dart'; +import 'package:latlong2/latlong.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; @@ -30,6 +32,9 @@ void main() { const album = AlbumFilter('path/to/album', 'album'); expect(album, jsonRoundTrip(album)); + final bounds = CoordinateFilter(LatLng(29.979167, 28.223615), LatLng(36.451000, 31.134167)); + expect(bounds, jsonRoundTrip(bounds)); + const fav = FavouriteFilter.instance; expect(fav, jsonRoundTrip(fav)); diff --git a/test/utils/geo_utils_test.dart b/test/utils/geo_utils_test.dart index 0b2875ab9..29e242bed 100644 --- a/test/utils/geo_utils_test.dart +++ b/test/utils/geo_utils_test.dart @@ -3,8 +3,16 @@ import 'package:latlong2/latlong.dart'; import 'package:test/test.dart'; void main() { + test('Decimal degrees to DMS (sexagesimal)', () { + expect(GeoUtils.toDMS(LatLng(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam + expect(GeoUtils.toDMS(LatLng(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund + expect(GeoUtils.toDMS(LatLng(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo + expect(GeoUtils.toDMS(LatLng(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio + expect(GeoUtils.toDMS(LatLng(0, 0)), ['0° 0′ 0.00″ N', '0° 0′ 0.00″ E']); + }); + 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)); + expect(GeoUtils.getLatLngCenter([LatLng(10, 30), LatLng(30, 50)]), LatLng(20.28236664671092, 39.351653000319956)); + expect(GeoUtils.getLatLngCenter([LatLng(10, -179), LatLng(30, 179)]), LatLng(20.00279344048298, -179.9358157370226)); }); }