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,27 +1,79 @@
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 {
double x = 0; static String _decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) {
double y = 0; List<int> _split(final double value) {
double z = 0; // 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]),
];
}
points.forEach((point) { final deg = _split(degDecimal)[0];
final lat = point.latitudeInRad; final minDecimal = (degDecimal.abs() - deg) * 60;
final lng = point.longitudeInRad; final min = _split(minDecimal)[0];
x += cos(lat) * cos(lng); final sec = (minDecimal - min) * 60;
y += cos(lat) * sin(lng);
z += sin(lat);
});
final pointCount = points.length; final secRounded = roundToPrecision(sec, decimals: secondDecimals);
x /= pointCount; var minText = '$min';
y /= pointCount; var secText = secRounded.toStringAsFixed(secondDecimals);
z /= pointCount; if (minuteSecondPadding) {
minText = minText.padLeft(2, '0');
secText = secText.padLeft(secondDecimals > 0 ? 3 + secondDecimals : 2, '0');
}
final lng = atan2(y, x); return '$deg° $minText $secText';
final hyp = sqrt(x * x + y * y); }
final lat = atan2(z, hyp);
return LatLng(radianToDeg(lat), radianToDeg(lng)); // 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 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));
}
} }

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( child: content,
padding: EdgeInsets.symmetric(vertical: decoration != null ? 0 : 8),
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,119 +62,126 @@ 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( child: TooltipTheme(
alignment: AlignmentDirectional.centerEnd, data: TooltipTheme.of(context).copyWith(
child: Padding( preferBelow: false,
padding: EdgeInsets.all(padding), ),
child: TooltipTheme( child: SafeArea(
data: TooltipTheme.of(context).copyWith( bottom: false,
preferBelow: false, child: Stack(
), children: [
child: SafeArea( Positioned(
bottom: false, left: padding,
child: Stack( right: padding,
children: [ child: Row(
Positioned( crossAxisAlignment: CrossAxisAlignment.start,
left: 0, children: [
child: Column( Padding(
mainAxisSize: MainAxisSize.min, padding: EdgeInsets.only(top: padding),
children: [ child: Column(
if (navigationButton != null) ...[ mainAxisSize: MainAxisSize.min,
navigationButton, children: [
SizedBox(height: padding), if (navigationButton != null) ...[
], navigationButton,
ValueListenableBuilder<ZoomedBounds>( SizedBox(height: padding),
valueListenable: boundsNotifier, ],
builder: (context, bounds, child) { ValueListenableBuilder<ZoomedBounds>(
final degrees = bounds.rotation; valueListenable: boundsNotifier,
final opacity = degrees == 0 ? .0 : 1.0; builder: (context, bounds, child) {
final animationDuration = context.select<DurationsData, Duration>((v) => v.viewerOverlayAnimation); final degrees = bounds.rotation;
return IgnorePointer( final opacity = degrees == 0 ? .0 : 1.0;
ignoring: opacity == 0, final animationDuration = context.select<DurationsData, Duration>((v) => v.viewerOverlayAnimation);
child: AnimatedOpacity( return IgnorePointer(
opacity: opacity, ignoring: opacity == 0,
duration: animationDuration, child: AnimatedOpacity(
child: MapOverlayButton( opacity: opacity,
icon: Transform( duration: animationDuration,
origin: iconSize.center(Offset.zero), child: MapOverlayButton(
transform: Matrix4.rotationZ(degToRadian(degrees)), icon: Transform(
child: CustomPaint( origin: iconSize.center(Offset.zero),
painter: CompassPainter( transform: Matrix4.rotationZ(degToRadian(degrees)),
color: iconTheme.color!, 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,
), ),
), );
); },
}, ),
), ],
], ),
), ),
), showCoordinateFilter
Positioned( ? Expanded(
right: 0, child: _OverlayCoordinateFilterChip(
child: Column( boundsNotifier: boundsNotifier,
mainAxisSize: MainAxisSize.min, padding: padding,
children: [ ),
MapOverlayButton( )
icon: const Icon(AIcons.layers), : const Spacer(),
onPressed: () async { Padding(
final hasPlayServices = await availability.hasPlayServices; padding: EdgeInsets.only(top: padding),
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices); child: MapOverlayButton(
final preferredStyle = settings.infoMapStyle; icon: const Icon(AIcons.layers),
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first; onPressed: () async {
final style = await showDialog<EntryMapStyle>( final hasPlayServices = await availability.hasPlayServices;
context: context, final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
builder: (context) { final preferredStyle = settings.infoMapStyle;
return AvesSelectionDialog<EntryMapStyle>( final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
initialValue: initialStyle, final style = await showDialog<EntryMapStyle>(
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))), context: context,
title: context.l10n.mapStyleTitle, builder: (context) {
); return AvesSelectionDialog<EntryMapStyle>(
}, initialValue: initialStyle,
); options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
// wait for the dialog to hide as applying the change may block the UI title: context.l10n.mapStyleTitle,
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); );
if (style != null && style != settings.infoMapStyle) { },
settings.infoMapStyle = style; );
} // wait for the dialog to hide as applying the change may block the UI
}, await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
tooltip: context.l10n.mapStyleTooltip, 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<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,49 +158,55 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<Settings, EntryMapStyle>( return NotificationListener<FilterSelectedNotification>(
selector: (context, s) => s.infoMapStyle, onNotification: (notification) {
builder: (context, mapStyle, child) { _goToCollection(notification.filter);
late Widget scroller; return true;
if (mapStyle.isGoogleMaps) {
// the Google map widget is too heavy for a smooth resizing animation
// so we just toggle visibility when overlay animation is done
scroller = ValueListenableBuilder<double>(
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: !_overlayAnimationController.isDismissed,
child: child!,
);
},
child: child,
);
} else {
// the Leaflet map widget is light enough for a smooth resizing animation
scroller = FadeTransition(
opacity: _scrollerSize,
child: SizeTransition(
sizeFactor: _scrollerSize,
axisAlignment: 1.0,
child: child,
),
);
}
return Column(
children: [
Expanded(child: _buildMap()),
scroller,
],
);
}, },
child: Column( child: Selector<Settings, EntryMapStyle>(
mainAxisSize: MainAxisSize.min, selector: (context, s) => s.infoMapStyle,
children: [ builder: (context, mapStyle, child) {
const SizedBox(height: 8), late Widget scroller;
const Divider(height: 0), if (mapStyle.isGoogleMaps) {
_buildScroller(), // the Google map widget is too heavy for a smooth resizing animation
], // so we just toggle visibility when overlay animation is done
scroller = ValueListenableBuilder<double>(
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: !_overlayAnimationController.isDismissed,
child: child!,
);
},
child: child,
);
} else {
// the Leaflet map widget is light enough for a smooth resizing animation
scroller = FadeTransition(
opacity: _scrollerSize,
child: SizeTransition(
sizeFactor: _scrollerSize,
axisAlignment: 1.0,
child: child,
),
);
}
return Column(
children: [
Expanded(child: _buildMap()),
scroller,
],
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
const Divider(height: 0),
_buildScroller(),
],
),
), ),
); );
} }
@ -204,13 +214,14 @@ class _MapPageContentState extends State<MapPageContent> with SingleTickerProvid
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));
}); });
} }