diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 1caf04d..b588e9f 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -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 diff --git a/packages/stream_core/lib/src/query.dart b/packages/stream_core/lib/src/query.dart index 6a11dbc..1204455 100644 --- a/packages/stream_core/lib/src/query.dart +++ b/packages/stream_core/lib/src/query.dart @@ -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'; diff --git a/packages/stream_core/lib/src/query/filter.dart b/packages/stream_core/lib/src/query/filter/filter.dart similarity index 96% rename from packages/stream_core/lib/src/query/filter.dart rename to packages/stream_core/lib/src/query/filter/filter.dart index 16ba854..af7d10d 100644 --- a/packages/stream_core/lib/src/query/filter.dart +++ b/packages/stream_core/lib/src/query/filter/filter.dart @@ -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. /// @@ -217,7 +220,13 @@ sealed class ComparisonOperator extends Filter { @override Map toJson() { return { - field.remote: {operator: value}, + field.remote: { + operator: switch (value) { + final BoundingBox bbox => bbox.toJson(), + final CircularRegion region => region.toJson(), + _ => value, + }, + }, }; } } @@ -243,6 +252,15 @@ final class EqualOperator extends ComparisonOperator { // 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); } diff --git a/packages/stream_core/lib/src/query/filter_operation_utils.dart b/packages/stream_core/lib/src/query/filter/filter_operation_utils.dart similarity index 61% rename from packages/stream_core/lib/src/query/filter_operation_utils.dart rename to packages/stream_core/lib/src/query/filter/filter_operation_utils.dart index 278b09b..646a536 100644 --- a/packages/stream_core/lib/src/query/filter_operation_utils.dart +++ b/packages/stream_core/lib/src/query/filter/filter_operation_utils.dart @@ -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). @@ -86,3 +91,69 @@ extension JSONContainmentExtension on Map { }); } } + +/// 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; + } +} diff --git a/packages/stream_core/lib/src/query/filter_operator.dart b/packages/stream_core/lib/src/query/filter/filter_operator.dart similarity index 100% rename from packages/stream_core/lib/src/query/filter_operator.dart rename to packages/stream_core/lib/src/query/filter/filter_operator.dart diff --git a/packages/stream_core/lib/src/query/filter/location/bounding_box.dart b/packages/stream_core/lib/src/query/filter/location/bounding_box.dart new file mode 100644 index 0000000..da269d8 --- /dev/null +++ b/packages/stream_core/lib/src/query/filter/location/bounding_box.dart @@ -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 toJson() => _$BoundingBoxToJson(this); +} diff --git a/packages/stream_core/lib/src/query/filter/location/bounding_box.g.dart b/packages/stream_core/lib/src/query/filter/location/bounding_box.g.dart new file mode 100644 index 0000000..90bb530 --- /dev/null +++ b/packages/stream_core/lib/src/query/filter/location/bounding_box.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bounding_box.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Map _$BoundingBoxToJson(BoundingBox instance) => + { + 'ne_lat': instance.neLat, + 'ne_lng': instance.neLng, + 'sw_lat': instance.swLat, + 'sw_lng': instance.swLng, + }; diff --git a/packages/stream_core/lib/src/query/filter/location/circular_region.dart b/packages/stream_core/lib/src/query/filter/location/circular_region.dart new file mode 100644 index 0000000..071d622 --- /dev/null +++ b/packages/stream_core/lib/src/query/filter/location/circular_region.dart @@ -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 toJson() => _$CircularRegionToJson(this); +} diff --git a/packages/stream_core/lib/src/query/filter/location/circular_region.g.dart b/packages/stream_core/lib/src/query/filter/location/circular_region.g.dart new file mode 100644 index 0000000..66a08e0 --- /dev/null +++ b/packages/stream_core/lib/src/query/filter/location/circular_region.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'circular_region.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Map _$CircularRegionToJson(CircularRegion instance) => + { + 'lat': instance.lat, + 'lng': instance.lng, + 'distance': instance.distance, + }; diff --git a/packages/stream_core/lib/src/query/filter/location/distance.dart b/packages/stream_core/lib/src/query/filter/location/distance.dart new file mode 100644 index 0000000..64aa8e2 --- /dev/null +++ b/packages/stream_core/lib/src/query/filter/location/distance.dart @@ -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); +} diff --git a/packages/stream_core/lib/src/query/filter/location/location_coordinate.dart b/packages/stream_core/lib/src/query/filter/location/location_coordinate.dart new file mode 100644 index 0000000..2164904 --- /dev/null +++ b/packages/stream_core/lib/src/query/filter/location/location_coordinate.dart @@ -0,0 +1,100 @@ +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import 'distance.dart'; + +/// A geographic coordinate with latitude and longitude. +/// +/// Uses the WGS84 coordinate system (same as GPS). +/// +/// ```dart +/// const sanFrancisco = LocationCoordinate( +/// latitude: 37.7749, +/// longitude: -122.4194, +/// ); +/// +/// final distance = sanFrancisco.distanceTo(newYork); +/// print('Distance: ${distance.inKilometers} km'); +/// ``` +@immutable +class LocationCoordinate { + const LocationCoordinate({ + required this.latitude, + required this.longitude, + }); + + /// The latitude in decimal degrees. + /// + /// Valid range is -90° (South Pole) to +90° (North Pole). + final double latitude; + + /// The longitude in decimal degrees. + /// + /// Valid range is -180° (West) to +180° (East). + final double longitude; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! LocationCoordinate) return false; + + const epsilon = 1e-7; // ~1cm precision + final absLatDiff = (latitude - other.latitude).abs(); + final absLngDiff = (longitude - other.longitude).abs(); + + return absLatDiff < epsilon && absLngDiff < epsilon; + } + + @override + int get hashCode => Object.hash(latitude, longitude); + + /// The distance to [other]. + /// + /// Uses the Haversine formula, which assumes a spherical Earth + /// with typical error < 0.5%. + /// + /// ```dart + /// const london = LocationCoordinate(latitude: 51.5074, longitude: -0.1278); + /// const paris = LocationCoordinate(latitude: 48.8566, longitude: 2.3522); + /// + /// final distance = london.distanceTo(paris); + /// print('Distance: ${distance.inKilometers} km'); // ~343.56 km + /// ``` + Distance distanceTo(LocationCoordinate other) { + // If the coordinates are equal, the distance is zero. + if (this == other) return 0.meters; + + const earthRadius = 6378137.0; + + // Calculate differences in coordinates + final dLat = _degToRad(other.latitude - latitude); + final dLon = _degToRad(other.longitude - longitude); + + // Haversine formula components + final sinDLat = math.sin(dLat / 2); + final sinDLon = math.sin(dLon / 2); + + // Calculate latitude component: sin²(Δlat/2) + final latComponent = _square(sinDLat); + + // Calculate longitude component: sin²(Δlon/2) * cos(lat1) * cos(lat2) + final lonComponent = _square(sinDLon) * + math.cos(_degToRad(latitude)) * + math.cos(_degToRad(other.latitude)); + + // Combine components + final a = latComponent + lonComponent; + + // Calculate angular distance + final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + + return Distance.fromMeters(earthRadius * c); + } +} + +@pragma('vm:prefer-inline') +double _degToRad(double degree) => degree * math.pi / 180; + +@pragma('vm:prefer-inline') +double _square(double value) => value * value; diff --git a/packages/stream_core/test/query/filter_test.dart b/packages/stream_core/test/query/filter_test.dart index 1046adf..86c3f3e 100644 --- a/packages/stream_core/test/query/filter_test.dart +++ b/packages/stream_core/test/query/filter_test.dart @@ -15,6 +15,7 @@ class TestModel { this.metadata, this.tags, this.projects, + this.location, }); final String? id; @@ -25,6 +26,7 @@ class TestModel { final Map? metadata; final List? tags; final List>? projects; + final LocationCoordinate? location; } // Test implementation of FilterField for testing purposes @@ -39,6 +41,11 @@ class TestFilterField extends FilterField { static final metadata = TestFilterField('metadata', (it) => it.metadata); static final tags = TestFilterField('tags', (it) => it.tags); static final projects = TestFilterField('projects', (it) => it.projects); + static final near = TestFilterField('near', (it) => it.location); + static final withinBounds = TestFilterField( + 'within_bounds', + (it) => it.location, + ); } void main() { @@ -1934,5 +1941,290 @@ void main() { ); }); }); + + group('Location Filtering', () { + test('should match location within CircularRegion', () { + const center = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + const nearbyLocation = LocationCoordinate( + latitude: 37.8149, + longitude: -122.4594, + ); + + const farLocation = LocationCoordinate( + latitude: 40.7128, + longitude: -74.0060, + ); + + final region = CircularRegion( + radius: 10.kilometers, + center: center, + ); + + final filter = Filter.equal(TestFilterField.near, region); + + final modelNearby = TestModel(location: nearbyLocation); + final modelFar = TestModel(location: farLocation); + + expect(filter.matches(modelNearby), isTrue); + expect(filter.matches(modelFar), isFalse); + }); + + test('should match location within BoundingBox', () { + const bbox = BoundingBox( + northEast: LocationCoordinate( + latitude: 37.8324, + longitude: -122.3482, + ), + southWest: LocationCoordinate( + latitude: 37.7079, + longitude: -122.5161, + ), + ); + + const insideLocation = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + const outsideLocation = LocationCoordinate( + latitude: 37.8044, + longitude: -122.2712, + ); + + final filter = Filter.equal(TestFilterField.withinBounds, bbox); + + final modelInside = TestModel(location: insideLocation); + final modelOutside = TestModel(location: outsideLocation); + + expect(filter.matches(modelInside), isTrue); + expect(filter.matches(modelOutside), isFalse); + }); + + test('should support Map-based circular region format', () { + const location = LocationCoordinate( + latitude: 37.7849, + longitude: -122.4294, + ); + + final regionMap = { + 'lat': 37.7749, + 'lng': -122.4194, + 'distance': 10.0, + }; + + final filter = Filter.equal(TestFilterField.near, regionMap); + final model = TestModel(location: location); + + expect(filter.matches(model), isTrue); + }); + + test('should support Map-based bounding box format', () { + const insideLocation = LocationCoordinate( + latitude: 41.89, + longitude: 12.49, + ); + + const outsideLocation = LocationCoordinate( + latitude: 41.95, + longitude: 12.49, + ); + + final bboxMap = { + 'ne_lat': 41.91, + 'ne_lng': 12.51, + 'sw_lat': 41.87, + 'sw_lng': 12.47, + }; + + final filter = Filter.equal(TestFilterField.withinBounds, bboxMap); + + final modelInside = TestModel(location: insideLocation); + final modelOutside = TestModel(location: outsideLocation); + + expect(filter.matches(modelInside), isTrue); + expect(filter.matches(modelOutside), isFalse); + }); + + test('should serialize CircularRegion filter to JSON', () { + final region = CircularRegion( + radius: 5.kilometers, + center: const LocationCoordinate(latitude: 41.89, longitude: 12.49), + ); + + final filter = Filter.equal(TestFilterField.near, region); + + expect(filter.toJson(), { + 'near': { + r'$eq': { + 'lat': 41.89, + 'lng': 12.49, + 'distance': 5.0, + }, + }, + }); + }); + + test('should serialize BoundingBox filter to JSON', () { + const bbox = BoundingBox( + northEast: LocationCoordinate(latitude: 41.91, longitude: 12.51), + southWest: LocationCoordinate(latitude: 41.87, longitude: 12.47), + ); + + final filter = Filter.equal(TestFilterField.withinBounds, bbox); + + expect(filter.toJson(), { + 'within_bounds': { + r'$eq': { + 'ne_lat': 41.91, + 'ne_lng': 12.51, + 'sw_lat': 41.87, + 'sw_lng': 12.47, + }, + }, + }); + }); + + test('should work with logical operators', () { + const location = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + final region = CircularRegion( + radius: 10.kilometers, + center: location, + ); + + final filter = Filter.and([ + Filter.equal(TestFilterField.name, 'Office'), + Filter.equal(TestFilterField.near, region), + ]); + + final model = TestModel(name: 'Office', location: location); + + expect(filter.matches(model), isTrue); + }); + + test('should not match when location field is null', () { + final region = CircularRegion( + radius: 10.kilometers, + center: const LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ), + ); + + const bbox = BoundingBox( + northEast: LocationCoordinate( + latitude: 37.8324, + longitude: -122.3482, + ), + southWest: LocationCoordinate( + latitude: 37.7079, + longitude: -122.5161, + ), + ); + + final filterNear = Filter.equal(TestFilterField.near, region); + final filterBounds = Filter.equal(TestFilterField.withinBounds, bbox); + + final modelWithNull = TestModel(location: null); + + expect(filterNear.matches(modelWithNull), isFalse); + expect(filterBounds.matches(modelWithNull), isFalse); + }); + + test('should not match with invalid Map format for CircularRegion', () { + const location = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + final model = TestModel(location: location); + + // Missing 'distance' field + final invalidMap1 = {'lat': 37.7749, 'lng': -122.4194}; + final filter1 = Filter.equal(TestFilterField.near, invalidMap1); + + expect(filter1.matches(model), isFalse); + + // Missing 'lng' field + final invalidMap2 = {'lat': 37.7749, 'distance': 10.0}; + final filter2 = Filter.equal(TestFilterField.near, invalidMap2); + + expect(filter2.matches(model), isFalse); + }); + + test('should not match with invalid Map format for BoundingBox', () { + const location = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + final model = TestModel(location: location); + + // Missing 'sw_lat' field + final invalidMap1 = { + 'ne_lat': 37.8324, + 'ne_lng': -122.3482, + 'sw_lng': -122.5161, + }; + + final filter1 = Filter.equal(TestFilterField.withinBounds, invalidMap1); + expect(filter1.matches(model), isFalse); + + // Missing 'ne_lng' field + final invalidMap2 = { + 'ne_lat': 37.8324, + 'sw_lat': 37.7079, + 'sw_lng': -122.5161, + }; + + final filter2 = Filter.equal(TestFilterField.withinBounds, invalidMap2); + expect(filter2.matches(model), isFalse); + }); + + test('should match location at exact radius boundary', () { + const center = LocationCoordinate(latitude: 0, longitude: 0); + const radiusMeters = 1000.0; + final region = CircularRegion( + radius: radiusMeters.meters, + center: center, + ); + + // Point approximately 1km away at equator + const pointAtBoundary = LocationCoordinate( + latitude: 0, + longitude: 0.00898, // ~1km at equator + ); + + final filter = Filter.equal(TestFilterField.near, region); + final model = TestModel(location: pointAtBoundary); + + expect(filter.matches(model), isTrue); + }); + + test('should match location at exact bounding box boundary', () { + const bbox = BoundingBox( + northEast: LocationCoordinate(latitude: 38, longitude: -122), + southWest: LocationCoordinate(latitude: 37, longitude: -123), + ); + + const neCorner = LocationCoordinate(latitude: 38, longitude: -122); + const swCorner = LocationCoordinate(latitude: 37, longitude: -123); + + final filter = Filter.equal(TestFilterField.withinBounds, bbox); + + final modelNE = TestModel(location: neCorner); + final modelSW = TestModel(location: swCorner); + + expect(filter.matches(modelNE), isTrue); + expect(filter.matches(modelSW), isTrue); + }); + }); }); } diff --git a/packages/stream_core/test/query/location_test.dart b/packages/stream_core/test/query/location_test.dart new file mode 100644 index 0000000..cd0c14a --- /dev/null +++ b/packages/stream_core/test/query/location_test.dart @@ -0,0 +1,159 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('LocationCoordinate', () { + test('should calculate distance accurately', () { + const sanFrancisco = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + const newYork = LocationCoordinate( + latitude: 40.7128, + longitude: -74.0060, + ); + + final distance = sanFrancisco.distanceTo(newYork); + + // Allow 1% error margin for Haversine approximation + const expectedDistance = 4130000; // ~4130 km + expect(distance, closeTo(expectedDistance, expectedDistance * 0.01)); + }); + + test('should return zero distance for identical coordinates', () { + const location = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + final distance = location.distanceTo(location); + expect(distance, equals(0.meters)); + }); + + test('should support equality comparison', () { + const location1 = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + const location2 = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + const location3 = LocationCoordinate( + latitude: 40.7128, + longitude: -74.0060, + ); + + expect(location1, equals(location2)); + expect(location1, isNot(equals(location3))); + expect(location1.hashCode, equals(location2.hashCode)); + }); + + test('should consider near-equal coordinates as equal', () { + const location1 = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + // Coordinates differ by less than epsilon (~1cm) + const location2 = LocationCoordinate( + latitude: 37.77490000001, + longitude: -122.41940000001, + ); + + // Coordinates differ by more than epsilon + const location3 = LocationCoordinate( + latitude: 37.7749001, + longitude: -122.4194001, + ); + + expect(location1, equals(location2)); + expect(location1, isNot(equals(location3))); + }); + }); + + group('Distance', () { + test('should convert between meters and kilometers', () { + final distance = 5.kilometers; + expect(distance, equals(5000.0)); + expect(distance.inKilometers, equals(5.0)); + }); + }); + + group('CircularRegion', () { + test('should contain point within radius', () { + const center = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + final region = CircularRegion( + radius: 10.kilometers, + center: center, + ); + + const nearbyPoint = LocationCoordinate( + latitude: 37.8149, + longitude: -122.4594, + ); + expect(region.contains(nearbyPoint), isTrue); + + const farPoint = LocationCoordinate( + latitude: 40.7128, + longitude: -74.0060, + ); + expect(region.contains(farPoint), isFalse); + }); + + test('should serialize to JSON correctly', () { + const center = LocationCoordinate( + latitude: 37.7749, + longitude: -122.4194, + ); + + final region = CircularRegion(radius: 5.kilometers, center: center); + + expect(region.toJson(), { + 'lat': 37.7749, + 'lng': -122.4194, + 'distance': 5.0, + }); + }); + }); + + group('BoundingBox', () { + test('should contain point within bounds', () { + const bbox = BoundingBox( + northEast: LocationCoordinate(latitude: 38, longitude: -122), + southWest: LocationCoordinate(latitude: 37, longitude: -123), + ); + + const insidePoint = LocationCoordinate( + latitude: 37.5, + longitude: -122.5, + ); + expect(bbox.contains(insidePoint), isTrue); + + const outsidePoint = LocationCoordinate( + latitude: 38.1, + longitude: -122.5, + ); + expect(bbox.contains(outsidePoint), isFalse); + }); + + test('should serialize to JSON correctly', () { + const bbox = BoundingBox( + northEast: LocationCoordinate(latitude: 37.8324, longitude: -122.3482), + southWest: LocationCoordinate(latitude: 37.7079, longitude: -122.5161), + ); + + expect(bbox.toJson(), { + 'ne_lat': 37.8324, + 'ne_lng': -122.3482, + 'sw_lat': 37.7079, + 'sw_lng': -122.5161, + }); + }); + }); +}