map: coordinate filter

This commit is contained in:
Thibault Deckers 2021-10-07 13:11:11 +09:00
parent 10e9caaffe
commit 5338cb47c2
18 changed files with 474 additions and 250 deletions

View file

@ -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<int> _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>[
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<String> toDMS(LatLng latLng) {
final lat = latLng.latitude;
final lng = latLng.longitude;
return [
'${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}',
'${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}',
];
}

View file

@ -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<Object?> get props => [sw, ne];
const CoordinateFilter(this.sw, this.ne, {this.minuteSecondPadding = false});
CoordinateFilter.fromMap(Map<String, dynamic> json)
: this(
LatLng.fromJson(json['sw']),
LatLng.fromJson(json['ne']),
);
@override
Map<String, dynamic> 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<Settings>().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';
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.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/favourite.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
@ -24,6 +25,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
TypeFilter.type, TypeFilter.type,
AlbumFilter.type, AlbumFilter.type,
LocationFilter.type, LocationFilter.type,
CoordinateFilter.type,
TagFilter.type, TagFilter.type,
PathFilter.type, PathFilter.type,
]; ];
@ -35,20 +37,22 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
switch (type) { switch (type) {
case AlbumFilter.type: case AlbumFilter.type:
return AlbumFilter.fromMap(jsonMap); return AlbumFilter.fromMap(jsonMap);
case CoordinateFilter.type:
return CoordinateFilter.fromMap(jsonMap);
case FavouriteFilter.type: case FavouriteFilter.type:
return FavouriteFilter.instance; return FavouriteFilter.instance;
case LocationFilter.type: case LocationFilter.type:
return LocationFilter.fromMap(jsonMap); return LocationFilter.fromMap(jsonMap);
case TypeFilter.type:
return TypeFilter.fromMap(jsonMap);
case MimeFilter.type: case MimeFilter.type:
return MimeFilter.fromMap(jsonMap); return MimeFilter.fromMap(jsonMap);
case PathFilter.type:
return PathFilter.fromMap(jsonMap);
case QueryFilter.type: case QueryFilter.type:
return QueryFilter.fromMap(jsonMap); return QueryFilter.fromMap(jsonMap);
case TagFilter.type: case TagFilter.type:
return TagFilter.fromMap(jsonMap); return TagFilter.fromMap(jsonMap);
case PathFilter.type: case TypeFilter.type:
return PathFilter.fromMap(jsonMap); return TypeFilter.fromMap(jsonMap);
} }
} }
debugPrint('failed to parse filter from json=$jsonString'); debugPrint('failed to parse filter from json=$jsonString');

View file

@ -1,4 +1,4 @@
import 'package:aves/geo/format.dart'; import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@ -15,10 +15,10 @@ extension ExtraCoordinateFormat on CoordinateFormat {
} }
} }
String format(LatLng latLng) { String format(LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) {
switch (this) { switch (this) {
case CoordinateFormat.dms: case CoordinateFormat.dms:
return toDMS(latLng).join(', '); return GeoUtils.toDMS(latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', ');
case CoordinateFormat.decimal: case CoordinateFormat.decimal:
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', '); return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
} }

View file

@ -142,7 +142,7 @@ class Settings extends ChangeNotifier {
Future<void> setContextualDefaults() async { Future<void> setContextualDefaults() async {
// performance // performance
final performanceClass = await deviceService.getPerformanceClass(); final performanceClass = await deviceService.getPerformanceClass();
enableOverlayBlurEffect = performanceClass >= 30; enableOverlayBlurEffect = performanceClass >= 29;
// availability // availability
final hasPlayServices = await availability.hasPlayServices; final hasPlayServices = await availability.hasPlayServices;

View file

@ -46,6 +46,7 @@ class AIcons {
static const IconData flip = Icons.flip_outlined; static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border; static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite; static const IconData favouriteActive = Icons.favorite;
static const IconData geoBounds = Icons.public_outlined;
static const IconData goUp = Icons.arrow_upward_outlined; static const IconData goUp = Icons.arrow_upward_outlined;
static const IconData group = Icons.group_work_outlined; static const IconData group = Icons.group_work_outlined;
static const IconData hide = Icons.visibility_off_outlined; static const IconData hide = Icons.visibility_off_outlined;

View file

@ -1,8 +1,48 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
LatLng getLatLngCenter(List<LatLng> points) { class GeoUtils {
static String _decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) {
List<int> _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>[
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;
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');
}
return '$deg° $minText $secText';
}
// returns coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']
static List<String> 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<LatLng> points) {
double x = 0; double x = 0;
double y = 0; double y = 0;
double z = 0; double z = 0;
@ -25,3 +65,15 @@ LatLng getLatLngCenter(List<LatLng> points) {
final lat = atan2(z, hyp); final lat = atan2(z, hyp);
return LatLng(radianToDeg(lat), radianToDeg(lng)); 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));
}
}

View file

@ -37,12 +37,11 @@ class AvesFilterDecoration {
class AvesFilterChip extends StatefulWidget { class AvesFilterChip extends StatefulWidget {
final CollectionFilter filter; final CollectionFilter filter;
final bool removable; final bool removable, showGenericIcon, useFilterColor;
final bool showGenericIcon;
final AvesFilterDecoration? decoration; final AvesFilterDecoration? decoration;
final String? banner; final String? banner;
final Widget? details; final Widget? details;
final double padding; final double padding, maxWidth;
final HeroType heroType; final HeroType heroType;
final FilterCallback? onTap; final FilterCallback? onTap;
final OffsetFilterCallback? onLongPress; final OffsetFilterCallback? onLongPress;
@ -52,7 +51,7 @@ class AvesFilterChip extends StatefulWidget {
static const double outlineWidth = 2; static const double outlineWidth = 2;
static const double minChipHeight = kMinInteractiveDimension; static const double minChipHeight = kMinInteractiveDimension;
static const double minChipWidth = 80; static const double minChipWidth = 80;
static const double maxChipWidth = 160; static const double defaultMaxChipWidth = 160;
static const double iconSize = 18; static const double iconSize = 18;
static const double fontSize = 14; static const double fontSize = 14;
static const double decoratedContentVerticalPadding = 5; static const double decoratedContentVerticalPadding = 5;
@ -62,10 +61,12 @@ class AvesFilterChip extends StatefulWidget {
required this.filter, required this.filter,
this.removable = false, this.removable = false,
this.showGenericIcon = true, this.showGenericIcon = true,
this.useFilterColor = true,
this.decoration, this.decoration,
this.banner, this.banner,
this.details, this.details,
this.padding = 6.0, this.padding = 6.0,
this.maxWidth = defaultMaxChipWidth,
this.heroType = HeroType.onTap, this.heroType = HeroType.onTap,
this.onTap, this.onTap,
this.onLongPress = showDefaultLongPressMenu, this.onLongPress = showDefaultLongPressMenu,
@ -181,7 +182,6 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
), ),
softWrap: false, softWrap: false,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
maxLines: 1,
), ),
), ),
if (trailing != null) ...[ if (trailing != null) ...[
@ -216,7 +216,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
); );
} else { } else {
content = Padding( content = Padding(
padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2), padding: EdgeInsets.symmetric(horizontal: padding * 2),
child: content, child: content,
); );
} }
@ -224,9 +224,9 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)); final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
final banner = widget.banner; final banner = widget.banner;
Widget chip = Container( Widget chip = Container(
constraints: const BoxConstraints( constraints: BoxConstraints(
minWidth: AvesFilterChip.minChipWidth, minWidth: AvesFilterChip.minChipWidth,
maxWidth: AvesFilterChip.maxChipWidth, maxWidth: widget.maxWidth,
minHeight: AvesFilterChip.minChipHeight, minHeight: AvesFilterChip.minChipHeight,
), ),
child: Stack( child: Stack(
@ -263,16 +263,13 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide( border: Border.fromBorderSide(BorderSide(
color: _outlineColor, color: widget.useFilterColor ? _outlineColor : AvesFilterChip.defaultOutlineColor,
width: AvesFilterChip.outlineWidth, width: AvesFilterChip.outlineWidth,
)), )),
borderRadius: borderRadius, borderRadius: borderRadius,
), ),
position: DecorationPosition.foreground, position: DecorationPosition.foreground,
child: Padding(
padding: EdgeInsets.symmetric(vertical: decoration != null ? 0 : 8),
child: content, child: content,
),
); );
}, },
), ),

View file

@ -1,16 +1,20 @@
import 'package:aves/model/filters/coordinate.dart';
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.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/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.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/compass.dart';
import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/common/map/theme.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.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:aves/widgets/viewer/overlay/common.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -58,14 +62,11 @@ class MapButtonPanel extends StatelessWidget {
break; break;
} }
final showCoordinateFilter = context.select<MapThemeData, bool>((v) => v.showCoordinateFilter);
final visualDensity = context.select<MapThemeData, VisualDensity?>((v) => v.visualDensity); final visualDensity = context.select<MapThemeData, VisualDensity?>((v) => v.visualDensity);
final double padding = visualDensity == VisualDensity.compact ? 4 : 8; final double padding = visualDensity == VisualDensity.compact ? 4 : 8;
return Positioned.fill( return Positioned.fill(
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: Padding(
padding: EdgeInsets.all(padding),
child: TooltipTheme( child: TooltipTheme(
data: TooltipTheme.of(context).copyWith( data: TooltipTheme.of(context).copyWith(
preferBelow: false, preferBelow: false,
@ -75,7 +76,13 @@ class MapButtonPanel extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
Positioned( Positioned(
left: 0, left: padding,
right: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: padding),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -115,12 +122,17 @@ class MapButtonPanel extends StatelessWidget {
], ],
), ),
), ),
Positioned( showCoordinateFilter
right: 0, ? Expanded(
child: Column( child: _OverlayCoordinateFilterChip(
mainAxisSize: MainAxisSize.min, boundsNotifier: boundsNotifier,
children: [ padding: padding,
MapOverlayButton( ),
)
: const Spacer(),
Padding(
padding: EdgeInsets.only(top: padding),
child: MapOverlayButton(
icon: const Icon(AIcons.layers), icon: const Icon(AIcons.layers),
onPressed: () async { onPressed: () async {
final hasPlayServices = await availability.hasPlayServices; final hasPlayServices = await availability.hasPlayServices;
@ -145,12 +157,13 @@ class MapButtonPanel extends StatelessWidget {
}, },
tooltip: context.l10n.mapStyleTooltip, tooltip: context.l10n.mapStyleTooltip,
), ),
),
], ],
), ),
), ),
Positioned( Positioned(
right: 0, right: padding,
bottom: 0, bottom: padding,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -172,8 +185,6 @@ class MapButtonPanel extends StatelessWidget {
), ),
), ),
), ),
),
),
); );
} }
} }
@ -225,3 +236,102 @@ class MapOverlayButton extends StatelessWidget {
); );
} }
} }
class _OverlayCoordinateFilterChip extends StatefulWidget {
final ValueNotifier<ZoomedBounds> 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<ZoomedBounds?> _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<MapThemeData, Animation<double>>(
selector: (context, v) => v.scale,
builder: (context, scale, child) => SizeTransition(
sizeFactor: scale,
axisAlignment: 1,
child: FadeTransition(
opacity: scale,
child: child,
),
),
child: ValueListenableBuilder<ZoomedBounds?>(
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);
}
}

View file

@ -96,7 +96,7 @@ class _GeoMapState extends State<GeoMap> {
if (points.length != geoEntry.pointsSize) { if (points.length != geoEntry.pointsSize) {
// `Fluster.points()` method does not always return all the points contained in a cluster // `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`) // 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); points = _slowMarkerCluster!.points(clusterId);
assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry'); assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry');
} }

View file

@ -5,7 +5,7 @@ import 'package:provider/provider.dart';
enum MapNavigationButton { back, map } enum MapNavigationButton { back, map }
class MapTheme extends StatelessWidget { class MapTheme extends StatelessWidget {
final bool interactive; final bool interactive, showCoordinateFilter;
final MapNavigationButton navigationButton; final MapNavigationButton navigationButton;
final Animation<double> scale; final Animation<double> scale;
final VisualDensity? visualDensity; final VisualDensity? visualDensity;
@ -15,6 +15,7 @@ class MapTheme extends StatelessWidget {
const MapTheme({ const MapTheme({
Key? key, Key? key,
required this.interactive, required this.interactive,
required this.showCoordinateFilter,
required this.navigationButton, required this.navigationButton,
this.scale = kAlwaysCompleteAnimation, this.scale = kAlwaysCompleteAnimation,
this.visualDensity, this.visualDensity,
@ -28,6 +29,7 @@ class MapTheme extends StatelessWidget {
update: (context, settings, __) { update: (context, settings, __) {
return MapThemeData( return MapThemeData(
interactive: interactive, interactive: interactive,
showCoordinateFilter: showCoordinateFilter,
navigationButton: navigationButton, navigationButton: navigationButton,
scale: scale, scale: scale,
visualDensity: visualDensity, visualDensity: visualDensity,
@ -40,7 +42,7 @@ class MapTheme extends StatelessWidget {
} }
class MapThemeData { class MapThemeData {
final bool interactive; final bool interactive, showCoordinateFilter;
final MapNavigationButton navigationButton; final MapNavigationButton navigationButton;
final Animation<double> scale; final Animation<double> scale;
final VisualDensity? visualDensity; final VisualDensity? visualDensity;
@ -48,6 +50,7 @@ class MapThemeData {
const MapThemeData({ const MapThemeData({
required this.interactive, required this.interactive,
required this.showCoordinateFilter,
required this.navigationButton, required this.navigationButton,
required this.scale, required this.scale,
required this.visualDensity, required this.visualDensity,

View file

@ -13,7 +13,7 @@ class ZoomedBounds extends Equatable {
// returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster // returns [southwestLng, southwestLat, northeastLng, northeastLat], as expected by Fluster
List<double> get boundingBox => [sw.longitude, sw.latitude, ne.longitude, ne.latitude]; List<double> get boundingBox => [sw.longitude, sw.latitude, ne.longitude, ne.latitude];
LatLng get center => getLatLngCenter([sw, ne]); LatLng get center => GeoUtils.getLatLngCenter([sw, ne]);
@override @override
List<Object?> get props => [sw, ne, zoom, rotation]; List<Object?> get props => [sw, ne, zoom, rotation];
@ -63,13 +63,5 @@ class ZoomedBounds extends Equatable {
); );
} }
bool contains(LatLng point) { bool contains(LatLng point) => GeoUtils.contains(sw, ne, point);
final lat = point.latitude;
final lng = point.longitude;
final south = sw.latitude;
final north = ne.latitude;
final west = sw.longitude;
final east = ne.longitude;
return (south <= lat && lat <= north) && (west <= east ? (west <= lng && lng <= east) : (west <= lng || lng <= east));
}
} }

View file

@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry.dart'; 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/highlight.dart';
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.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/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/debouncer.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/behaviour/routes.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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/common/thumbnail/scroller.dart';
import 'package:aves/widgets/map/map_info_row.dart'; import 'package:aves/widgets/map/map_info_row.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.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:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -87,10 +91,10 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
late AnimationController _overlayAnimationController; late AnimationController _overlayAnimationController;
late Animation<double> _overlayScale, _scrollerSize; late Animation<double> _overlayScale, _scrollerSize;
List<AvesEntry> get entries => widget.collection.sortedEntries;
CollectionLens? get regionCollection => _regionCollectionNotifier.value; CollectionLens? get regionCollection => _regionCollectionNotifier.value;
CollectionLens get openingCollection => widget.collection;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -154,7 +158,12 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<Settings, EntryMapStyle>( return NotificationListener<FilterSelectedNotification>(
onNotification: (notification) {
_goToCollection(notification.filter);
return true;
},
child: Selector<Settings, EntryMapStyle>(
selector: (context, s) => s.infoMapStyle, selector: (context, s) => s.infoMapStyle,
builder: (context, mapStyle, child) { builder: (context, mapStyle, child) {
late Widget scroller; late Widget scroller;
@ -198,19 +207,21 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
_buildScroller(), _buildScroller(),
], ],
), ),
),
); );
} }
Widget _buildMap() { Widget _buildMap() {
return MapTheme( return MapTheme(
interactive: true, interactive: true,
showCoordinateFilter: true,
navigationButton: MapNavigationButton.back, navigationButton: MapNavigationButton.back,
scale: _overlayScale, scale: _overlayScale,
child: GeoMap( child: GeoMap(
// key is expected by test driver // key is expected by test driver
key: const Key('map_view'), key: const Key('map_view'),
controller: _mapController, controller: _mapController,
entries: entries, entries: openingCollection.sortedEntries,
initialEntry: widget.initialEntry, initialEntry: widget.initialEntry,
isAnimatingNotifier: _isPageAnimatingNotifier, isAnimatingNotifier: _isPageAnimatingNotifier,
dotEntryNotifier: _dotEntryNotifier, dotEntryNotifier: _dotEntryNotifier,
@ -285,9 +296,13 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
} }
_regionCollectionNotifier.value = CollectionLens( _regionCollectionNotifier.value = CollectionLens(
source: widget.collection.source, source: openingCollection.source,
listenToSource: false, 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 // get entries from the new collection, so the entry order is the same
@ -345,6 +360,24 @@ class _MapPageContentState extends State<MapPageContent> 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 // overlay
void _toggleOverlay() => _overlayVisible.value = !_overlayVisible.value; void _toggleOverlay() => _overlayVisible.value = !_overlayVisible.value;

View file

@ -21,7 +21,7 @@ class FilterTable extends StatelessWidget {
required this.onFilterSelection, required this.onFilterSelection,
}) : super(key: key); }) : super(key: key);
static const chipWidth = AvesFilterChip.maxChipWidth; static const chipWidth = AvesFilterChip.defaultMaxChipWidth;
static const countWidth = 32.0; static const countWidth = 32.0;
static const percentIndicatorMinWidth = 80.0; static const percentIndicatorMinWidth = 80.0;

View file

@ -86,6 +86,7 @@ class _LocationSectionState extends State<LocationSection> {
if (widget.showTitle) const SectionRow(icon: AIcons.location), if (widget.showTitle) const SectionRow(icon: AIcons.location),
MapTheme( MapTheme(
interactive: false, interactive: false,
showCoordinateFilter: false,
navigationButton: MapNavigationButton.map, navigationButton: MapNavigationButton.map,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
mapHeight: 200, mapHeight: 200,

View file

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

View file

@ -1,4 +1,5 @@
import 'package:aves/model/filters/album.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/favourite.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.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/tag.dart';
import 'package:aves/model/filters/type.dart'; import 'package:aves/model/filters/type.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -30,6 +32,9 @@ void main() {
const album = AlbumFilter('path/to/album', 'album'); const album = AlbumFilter('path/to/album', 'album');
expect(album, jsonRoundTrip(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; const fav = FavouriteFilter.instance;
expect(fav, jsonRoundTrip(fav)); expect(fav, jsonRoundTrip(fav));

View file

@ -3,8 +3,16 @@ import 'package:latlong2/latlong.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { 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', () { test('bounds center', () {
expect(getLatLngCenter([LatLng(10, 30), LatLng(30, 50)]), LatLng(20.28236664671092, 39.351653000319956)); expect(GeoUtils.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, -179), LatLng(30, 179)]), LatLng(20.00279344048298, -179.9358157370226));
}); });
} }