Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/stream_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

### ✨ Features

- Added location-based filtering support with `LocationCoordinate`, `Distance`, `CircularRegion`,
and `BoundingBox`
- Added `insertAt` parameter to `upsert` for controlling insertion position of new elements

## 0.3.1
Expand Down
8 changes: 6 additions & 2 deletions packages/stream_core/lib/src/query.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export 'query/filter.dart';
export 'query/filter_operator.dart';
export 'query/filter/filter.dart';
export 'query/filter/filter_operator.dart';
export 'query/filter/location/bounding_box.dart';
export 'query/filter/location/circular_region.dart';
export 'query/filter/location/distance.dart';
export 'query/filter/location/location_coordinate.dart';
export 'query/sort.dart';
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import '../utils.dart';
import '../../utils.dart';
import 'filter_operation_utils.dart';
import 'filter_operator.dart';
import 'location/bounding_box.dart';
import 'location/circular_region.dart';
import 'location/location_coordinate.dart';

/// Function that extracts a field value from a model instance.
///
Expand Down Expand Up @@ -217,7 +220,13 @@ sealed class ComparisonOperator<T extends Object> extends Filter<T> {
@override
Map<String, Object?> toJson() {
return {
field.remote: {operator: value},
field.remote: {
operator: switch (value) {
final BoundingBox bbox => bbox.toJson(),
final CircularRegion region => region.toJson(),
_ => value,
},
},
};
}
}
Expand All @@ -243,6 +252,15 @@ final class EqualOperator<T extends Object> extends ComparisonOperator<T> {
// NULL values can't be compared.
if (fieldValue == null || comparisonValue == null) return false;

// Special case for location coordinates
if (fieldValue is LocationCoordinate) {
final isNear = fieldValue.isNear(comparisonValue);
final isWithinBounds = fieldValue.isWithinBounds(comparisonValue);

// Match if either near or within bounds
return isNear || isWithinBounds;
}

// Deep equality: order-sensitive for arrays, order-insensitive for objects.
return fieldValue.deepEquals(comparisonValue);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import 'package:collection/collection.dart';

import 'location/bounding_box.dart';
import 'location/circular_region.dart';
import 'location/distance.dart';
import 'location/location_coordinate.dart';

// Deep equality checker.
//
// Maps are always compared with key-order-insensitivity (MapEquality).
Expand Down Expand Up @@ -86,3 +91,69 @@ extension JSONContainmentExtension<K, V> on Map<K, V> {
});
}
}

/// Extension methods for location-based filtering.
extension LocationEqualityExtension on LocationCoordinate {
/// Returns `true` if this coordinate is within a [CircularRegion].
///
/// Supports both [CircularRegion] objects and Map representations with
/// keys: 'lat', 'lng', 'distance' (in kilometers).
bool isNear(Object? other) {
// Check for CircularRegion instance.
if (other is CircularRegion) return other.contains(this);

// Check for Map representation.
if (other is Map) {
final lat = (other['lat'] as num?)?.toDouble();
if (lat == null) return false;

final lng = (other['lng'] as num?)?.toDouble();
if (lng == null) return false;

final distance = (other['distance'] as num?)?.toDouble();
if (distance == null) return false;

final region = CircularRegion(
radius: distance.kilometers,
center: LocationCoordinate(latitude: lat, longitude: lng),
);

return region.contains(this);
}

return false;
}

/// Returns `true` if this coordinate is within a [BoundingBox].
///
/// Supports both [BoundingBox] objects and Map representations with
/// keys: 'ne_lat', 'ne_lng', 'sw_lat', 'sw_lng'.
bool isWithinBounds(Object? other) {
// Check for BoundingBox instance.
if (other is BoundingBox) return other.contains(this);

// Check for Map representation.
if (other is Map) {
final neLat = (other['ne_lat'] as num?)?.toDouble();
if (neLat == null) return false;

final neLng = (other['ne_lng'] as num?)?.toDouble();
if (neLng == null) return false;

final swLat = (other['sw_lat'] as num?)?.toDouble();
if (swLat == null) return false;

final swLng = (other['sw_lng'] as num?)?.toDouble();
if (swLng == null) return false;

final box = BoundingBox(
northEast: LocationCoordinate(latitude: neLat, longitude: neLng),
southWest: LocationCoordinate(latitude: swLat, longitude: swLng),
);

return box.contains(this);
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'package:json_annotation/json_annotation.dart';

import 'location_coordinate.dart';

part 'bounding_box.g.dart';

/// A rectangular geographic region for area-based location filtering.
///
/// Defined by northeast and southwest corners. Used for rectangular
/// area queries such as map viewports or city boundaries.
///
/// ```dart
/// final bbox = BoundingBox(
/// northEast: LocationCoordinate(latitude: 37.8324, longitude: -122.3482),
/// southWest: LocationCoordinate(latitude: 37.7079, longitude: -122.5161),
/// );
///
/// final isInside = bbox.contains(point);
/// ```
@JsonSerializable(createFactory: false)
class BoundingBox {
const BoundingBox({
required this.northEast,
required this.southWest,
});

/// The northeast corner of this bounding box.
@JsonKey(includeToJson: false)
final LocationCoordinate northEast;

/// The southwest corner of this bounding box.
@JsonKey(includeToJson: false)
final LocationCoordinate southWest;

/// The latitude of the northeast corner.
@JsonKey(name: 'ne_lat')
double get neLat => northEast.latitude;

/// The longitude of the northeast corner.
@JsonKey(name: 'ne_lng')
double get neLng => northEast.longitude;

/// The latitude of the southwest corner.
@JsonKey(name: 'sw_lat')
double get swLat => southWest.latitude;

/// The longitude of the southwest corner.
@JsonKey(name: 'sw_lng')
double get swLng => southWest.longitude;

/// Whether [point] is within this bounding box.
bool contains(LocationCoordinate point) {
var withinLatitude = point.latitude <= northEast.latitude;
withinLatitude &= point.latitude >= southWest.latitude;

var withinLongitude = point.longitude <= northEast.longitude;
withinLongitude &= point.longitude >= southWest.longitude;

return withinLatitude && withinLongitude;
}

/// Converts this bounding box to JSON.
Map<String, dynamic> toJson() => _$BoundingBoxToJson(this);
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'package:json_annotation/json_annotation.dart';

import 'distance.dart';
import 'location_coordinate.dart';

part 'circular_region.g.dart';

/// A circular geographic region for proximity-based location filtering.
///
/// Defined by a center point and radius. Used for geofencing and
/// "near" location queries.
///
/// ```dart
/// final region = CircularRegion(
/// radius: 5.kilometers,
/// center: LocationCoordinate(latitude: 37.7749, longitude: -122.4194),
/// );
///
/// final isInside = region.contains(point);
/// ```
@JsonSerializable(createFactory: false)
class CircularRegion {
const CircularRegion({
required this.radius,
required this.center,
});

/// The radius of this circular region.
@JsonKey(includeToJson: false)
final Distance radius;

/// The center coordinate of this circular region.
@JsonKey(includeToJson: false)
final LocationCoordinate center;

/// The latitude of the center point.
@JsonKey(name: 'lat')
double get lat => center.latitude;

/// The longitude of the center point.
@JsonKey(name: 'lng')
double get lng => center.longitude;

/// The radius in kilometers.
@JsonKey(name: 'distance')
double get distance => radius.inKilometers;

/// Whether [point] is within this circular region.
bool contains(LocationCoordinate point) {
final distance = center.distanceTo(point);
return distance <= radius;
}

/// Converts this circular region to JSON.
Map<String, dynamic> toJson() => _$CircularRegionToJson(this);
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// A distance value with convenient unit conversions.
///
/// ```dart
/// final distance1 = 1000.meters; // 1000 meters
/// final distance2 = 5.kilometers; // 5000 meters
/// print(distance1.inKilometers); // 1.0
/// ```
extension type const Distance._(double meters) implements double {
/// Creates a [Distance] from [meters].
const Distance.fromMeters(double meters) : this._(meters);

/// The distance in kilometers.
double get inKilometers => meters / 1000;
}

/// Extension methods on [num] for creating [Distance] values.
extension DistanceExtension on num {
/// This value as a [Distance] in meters.
Distance get meters => Distance.fromMeters(toDouble());

/// This value as a [Distance] in kilometers.
Distance get kilometers => Distance.fromMeters(toDouble() * 1000);
}
Loading