Skip to content

Commit 4be039e

Browse files
authored
feat(llc): Add location-based filtering support (#22)
1 parent 25ecdbc commit 4be039e

File tree

13 files changed

+822
-4
lines changed

13 files changed

+822
-4
lines changed

packages/stream_core/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
### ✨ Features
44

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

79
## 0.3.1
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1-
export 'query/filter.dart';
2-
export 'query/filter_operator.dart';
1+
export 'query/filter/filter.dart';
2+
export 'query/filter/filter_operator.dart';
3+
export 'query/filter/location/bounding_box.dart';
4+
export 'query/filter/location/circular_region.dart';
5+
export 'query/filter/location/distance.dart';
6+
export 'query/filter/location/location_coordinate.dart';
37
export 'query/sort.dart';

packages/stream_core/lib/src/query/filter.dart renamed to packages/stream_core/lib/src/query/filter/filter.dart

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import '../utils.dart';
1+
import '../../utils.dart';
22
import 'filter_operation_utils.dart';
33
import 'filter_operator.dart';
4+
import 'location/bounding_box.dart';
5+
import 'location/circular_region.dart';
6+
import 'location/location_coordinate.dart';
47

58
/// Function that extracts a field value from a model instance.
69
///
@@ -217,7 +220,13 @@ sealed class ComparisonOperator<T extends Object> extends Filter<T> {
217220
@override
218221
Map<String, Object?> toJson() {
219222
return {
220-
field.remote: {operator: value},
223+
field.remote: {
224+
operator: switch (value) {
225+
final BoundingBox bbox => bbox.toJson(),
226+
final CircularRegion region => region.toJson(),
227+
_ => value,
228+
},
229+
},
221230
};
222231
}
223232
}
@@ -243,6 +252,15 @@ final class EqualOperator<T extends Object> extends ComparisonOperator<T> {
243252
// NULL values can't be compared.
244253
if (fieldValue == null || comparisonValue == null) return false;
245254

255+
// Special case for location coordinates
256+
if (fieldValue is LocationCoordinate) {
257+
final isNear = fieldValue.isNear(comparisonValue);
258+
final isWithinBounds = fieldValue.isWithinBounds(comparisonValue);
259+
260+
// Match if either near or within bounds
261+
return isNear || isWithinBounds;
262+
}
263+
246264
// Deep equality: order-sensitive for arrays, order-insensitive for objects.
247265
return fieldValue.deepEquals(comparisonValue);
248266
}

packages/stream_core/lib/src/query/filter_operation_utils.dart renamed to packages/stream_core/lib/src/query/filter/filter_operation_utils.dart

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import 'package:collection/collection.dart';
22

3+
import 'location/bounding_box.dart';
4+
import 'location/circular_region.dart';
5+
import 'location/distance.dart';
6+
import 'location/location_coordinate.dart';
7+
38
// Deep equality checker.
49
//
510
// Maps are always compared with key-order-insensitivity (MapEquality).
@@ -86,3 +91,69 @@ extension JSONContainmentExtension<K, V> on Map<K, V> {
8691
});
8792
}
8893
}
94+
95+
/// Extension methods for location-based filtering.
96+
extension LocationEqualityExtension on LocationCoordinate {
97+
/// Returns `true` if this coordinate is within a [CircularRegion].
98+
///
99+
/// Supports both [CircularRegion] objects and Map representations with
100+
/// keys: 'lat', 'lng', 'distance' (in kilometers).
101+
bool isNear(Object? other) {
102+
// Check for CircularRegion instance.
103+
if (other is CircularRegion) return other.contains(this);
104+
105+
// Check for Map representation.
106+
if (other is Map) {
107+
final lat = (other['lat'] as num?)?.toDouble();
108+
if (lat == null) return false;
109+
110+
final lng = (other['lng'] as num?)?.toDouble();
111+
if (lng == null) return false;
112+
113+
final distance = (other['distance'] as num?)?.toDouble();
114+
if (distance == null) return false;
115+
116+
final region = CircularRegion(
117+
radius: distance.kilometers,
118+
center: LocationCoordinate(latitude: lat, longitude: lng),
119+
);
120+
121+
return region.contains(this);
122+
}
123+
124+
return false;
125+
}
126+
127+
/// Returns `true` if this coordinate is within a [BoundingBox].
128+
///
129+
/// Supports both [BoundingBox] objects and Map representations with
130+
/// keys: 'ne_lat', 'ne_lng', 'sw_lat', 'sw_lng'.
131+
bool isWithinBounds(Object? other) {
132+
// Check for BoundingBox instance.
133+
if (other is BoundingBox) return other.contains(this);
134+
135+
// Check for Map representation.
136+
if (other is Map) {
137+
final neLat = (other['ne_lat'] as num?)?.toDouble();
138+
if (neLat == null) return false;
139+
140+
final neLng = (other['ne_lng'] as num?)?.toDouble();
141+
if (neLng == null) return false;
142+
143+
final swLat = (other['sw_lat'] as num?)?.toDouble();
144+
if (swLat == null) return false;
145+
146+
final swLng = (other['sw_lng'] as num?)?.toDouble();
147+
if (swLng == null) return false;
148+
149+
final box = BoundingBox(
150+
northEast: LocationCoordinate(latitude: neLat, longitude: neLng),
151+
southWest: LocationCoordinate(latitude: swLat, longitude: swLng),
152+
);
153+
154+
return box.contains(this);
155+
}
156+
157+
return false;
158+
}
159+
}

packages/stream_core/lib/src/query/filter_operator.dart renamed to packages/stream_core/lib/src/query/filter/filter_operator.dart

File renamed without changes.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import 'package:json_annotation/json_annotation.dart';
2+
3+
import 'location_coordinate.dart';
4+
5+
part 'bounding_box.g.dart';
6+
7+
/// A rectangular geographic region for area-based location filtering.
8+
///
9+
/// Defined by northeast and southwest corners. Used for rectangular
10+
/// area queries such as map viewports or city boundaries.
11+
///
12+
/// ```dart
13+
/// final bbox = BoundingBox(
14+
/// northEast: LocationCoordinate(latitude: 37.8324, longitude: -122.3482),
15+
/// southWest: LocationCoordinate(latitude: 37.7079, longitude: -122.5161),
16+
/// );
17+
///
18+
/// final isInside = bbox.contains(point);
19+
/// ```
20+
@JsonSerializable(createFactory: false)
21+
class BoundingBox {
22+
const BoundingBox({
23+
required this.northEast,
24+
required this.southWest,
25+
});
26+
27+
/// The northeast corner of this bounding box.
28+
@JsonKey(includeToJson: false)
29+
final LocationCoordinate northEast;
30+
31+
/// The southwest corner of this bounding box.
32+
@JsonKey(includeToJson: false)
33+
final LocationCoordinate southWest;
34+
35+
/// The latitude of the northeast corner.
36+
@JsonKey(name: 'ne_lat')
37+
double get neLat => northEast.latitude;
38+
39+
/// The longitude of the northeast corner.
40+
@JsonKey(name: 'ne_lng')
41+
double get neLng => northEast.longitude;
42+
43+
/// The latitude of the southwest corner.
44+
@JsonKey(name: 'sw_lat')
45+
double get swLat => southWest.latitude;
46+
47+
/// The longitude of the southwest corner.
48+
@JsonKey(name: 'sw_lng')
49+
double get swLng => southWest.longitude;
50+
51+
/// Whether [point] is within this bounding box.
52+
bool contains(LocationCoordinate point) {
53+
var withinLatitude = point.latitude <= northEast.latitude;
54+
withinLatitude &= point.latitude >= southWest.latitude;
55+
56+
var withinLongitude = point.longitude <= northEast.longitude;
57+
withinLongitude &= point.longitude >= southWest.longitude;
58+
59+
return withinLatitude && withinLongitude;
60+
}
61+
62+
/// Converts this bounding box to JSON.
63+
Map<String, dynamic> toJson() => _$BoundingBoxToJson(this);
64+
}

packages/stream_core/lib/src/query/filter/location/bounding_box.g.dart

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import 'package:json_annotation/json_annotation.dart';
2+
3+
import 'distance.dart';
4+
import 'location_coordinate.dart';
5+
6+
part 'circular_region.g.dart';
7+
8+
/// A circular geographic region for proximity-based location filtering.
9+
///
10+
/// Defined by a center point and radius. Used for geofencing and
11+
/// "near" location queries.
12+
///
13+
/// ```dart
14+
/// final region = CircularRegion(
15+
/// radius: 5.kilometers,
16+
/// center: LocationCoordinate(latitude: 37.7749, longitude: -122.4194),
17+
/// );
18+
///
19+
/// final isInside = region.contains(point);
20+
/// ```
21+
@JsonSerializable(createFactory: false)
22+
class CircularRegion {
23+
const CircularRegion({
24+
required this.radius,
25+
required this.center,
26+
});
27+
28+
/// The radius of this circular region.
29+
@JsonKey(includeToJson: false)
30+
final Distance radius;
31+
32+
/// The center coordinate of this circular region.
33+
@JsonKey(includeToJson: false)
34+
final LocationCoordinate center;
35+
36+
/// The latitude of the center point.
37+
@JsonKey(name: 'lat')
38+
double get lat => center.latitude;
39+
40+
/// The longitude of the center point.
41+
@JsonKey(name: 'lng')
42+
double get lng => center.longitude;
43+
44+
/// The radius in kilometers.
45+
@JsonKey(name: 'distance')
46+
double get distance => radius.inKilometers;
47+
48+
/// Whether [point] is within this circular region.
49+
bool contains(LocationCoordinate point) {
50+
final distance = center.distanceTo(point);
51+
return distance <= radius;
52+
}
53+
54+
/// Converts this circular region to JSON.
55+
Map<String, dynamic> toJson() => _$CircularRegionToJson(this);
56+
}

packages/stream_core/lib/src/query/filter/location/circular_region.g.dart

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/// A distance value with convenient unit conversions.
2+
///
3+
/// ```dart
4+
/// final distance1 = 1000.meters; // 1000 meters
5+
/// final distance2 = 5.kilometers; // 5000 meters
6+
/// print(distance1.inKilometers); // 1.0
7+
/// ```
8+
extension type const Distance._(double meters) implements double {
9+
/// Creates a [Distance] from [meters].
10+
const Distance.fromMeters(double meters) : this._(meters);
11+
12+
/// The distance in kilometers.
13+
double get inKilometers => meters / 1000;
14+
}
15+
16+
/// Extension methods on [num] for creating [Distance] values.
17+
extension DistanceExtension on num {
18+
/// This value as a [Distance] in meters.
19+
Distance get meters => Distance.fromMeters(toDouble());
20+
21+
/// This value as a [Distance] in kilometers.
22+
Distance get kilometers => Distance.fromMeters(toDouble() * 1000);
23+
}

0 commit comments

Comments
 (0)