diff --git a/android/src/toga_android/hardware/location.py b/android/src/toga_android/hardware/location.py index 5d6bd81257..3b3a64ea5b 100644 --- a/android/src/toga_android/hardware/location.py +++ b/android/src/toga_android/hardware/location.py @@ -14,8 +14,6 @@ def toga_location(location): """Convert an Android location into a Toga LatLng and altitude.""" - latlng = LatLng(location.getLatitude(), location.getLongitude()) - # MSL altitude was added in API 34. We can't test this at runtime if Build.VERSION.SDK_INT >= 34 and location.hasMslAltitude(): # pragma: no cover altitude = location.getMslAltitudeMeters() @@ -24,6 +22,20 @@ def toga_location(location): else: altitude = None + latlng = LatLng( + location.getLatitude(), + location.getLongitude(), + altitude=altitude, + horizontal_accuracy=( + location.getAccuracy() if location.hasAccuracy() else None + ), + vertical_accuracy=( + location.getVerticalAccuracyMeters() + if location.hasVerticalAccuracy() + else None + ), + ) + return { "location": latlng, "altitude": altitude, diff --git a/changes/3112.feature.md b/changes/3112.feature.md new file mode 100644 index 0000000000..f6b735c038 --- /dev/null +++ b/changes/3112.feature.md @@ -0,0 +1 @@ +Add altitude, horizontal accuracy and vertical accuracy to LatLng. diff --git a/cocoa/src/toga_cocoa/hardware/location.py b/cocoa/src/toga_cocoa/hardware/location.py index 74bf8bf51e..37251dd818 100644 --- a/cocoa/src/toga_cocoa/hardware/location.py +++ b/cocoa/src/toga_cocoa/hardware/location.py @@ -11,17 +11,20 @@ def toga_location(location): """Convert a Cocoa location into a Toga LatLng and altitude.""" + # A non-positive vertical accuracy indicates altitude is invalid. + altitude_valid = location.verticalAccuracy > 0.0 + altitude = location.altitude if altitude_valid else None + latlng = LatLng( location.coordinate.latitude, location.coordinate.longitude, + altitude=altitude, + horizontal_accuracy=( + location.horizontalAccuracy if location.horizontalAccuracy > 0.0 else None + ), + vertical_accuracy=location.verticalAccuracy if altitude_valid else None, ) - # A vertical accuracy that non-positive indicates altitude is invalid. - if location.verticalAccuracy > 0.0: - altitude = location.altitude - else: - altitude = None - return { "location": latlng, "altitude": altitude, diff --git a/core/src/toga/types.py b/core/src/toga/types.py index 2063184891..177db1a4e2 100644 --- a/core/src/toga/types.py +++ b/core/src/toga/types.py @@ -23,14 +23,47 @@ """ -class LatLng(NamedTuple): - """A geographic coordinate.""" +class LatLng(tuple): + """A geographic coordinate, with optional altitude and accuracy attributes. - lat: float - """Latitude""" + A LatLng compares equal to a 2-tuple (lat, lng). The optional + altitude, horizontal_accuracy, and vertical_accuracy attributes + are not considered for equality or hashing. + """ - lng: float - """Longitude""" + altitude: float | None = None + """Altitude in meters, or None if not available.""" + + horizontal_accuracy: float | None = None + """Horizontal accuracy in meters, or None if not available.""" + + vertical_accuracy: float | None = None + """Vertical accuracy in meters, or None if not available.""" + + def __new__( + cls, + lat: float, + lng: float, + *, + altitude: float | None = None, + horizontal_accuracy: float | None = None, + vertical_accuracy: float | None = None, + ) -> LatLng: + self = super().__new__(cls, (lat, lng)) + self.altitude = altitude + self.horizontal_accuracy = horizontal_accuracy + self.vertical_accuracy = vertical_accuracy + return self + + @property + def lat(self) -> float: + """Latitude""" + return self[0] + + @property + def lng(self) -> float: + """Longitude""" + return self[1] def __str__(self) -> str: return f"({self.lat:6f}, {self.lng:6f})" diff --git a/core/tests/test_types.py b/core/tests/test_types.py index 704d2a3957..c7d285cf12 100644 --- a/core/tests/test_types.py +++ b/core/tests/test_types.py @@ -1,4 +1,53 @@ -from toga.types import Position, Size +import pytest + +from toga.types import LatLng, Position, Size + + +def test_latlng_required_properties(): + """A LatLng class has latitude and longitude values.""" + LAT = -31.95 + LNG = 115.86 + ll = LatLng(LAT, LNG) + assert ll.lat == pytest.approx(LAT) + assert ll.lng == pytest.approx(LNG) + assert str(ll) == f"({LAT:6f}, {LNG:6f})" + + assert ll == LatLng(LAT, LNG) + assert ll != LatLng(LAT, LNG + 1) + + assert ll == (LAT, LNG) # Tuple equivalence for backwards-compatibility + assert ll != (LAT + 1, LNG) + assert hash(ll) == hash((LAT, LNG)) + + # Optional values were not filled in + assert ll.altitude is None + assert ll.horizontal_accuracy is None + assert ll.vertical_accuracy is None + + +def test_latlng_optional_properties(): + """A LatLng class has optional values for altitude and accuracies.""" + LAT = -31.95 + LNG = 115.86 + ALT = 20.1 + HORIZONTAL_ACC = 10.2 + VERTICAL_ACC = 11.3 + bare_ll = LatLng(LAT, LNG) + ll = LatLng( + LAT, + LNG, + altitude=ALT, + horizontal_accuracy=HORIZONTAL_ACC, + vertical_accuracy=VERTICAL_ACC, + ) + assert ll.altitude == pytest.approx(ALT) + assert ll.horizontal_accuracy == pytest.approx(HORIZONTAL_ACC) + assert ll.vertical_accuracy == pytest.approx(VERTICAL_ACC) + + assert bare_ll == ll # optional values are not used for comparison + assert bare_ll.altitude != ll.altitude + assert bare_ll.horizontal_accuracy != ll.horizontal_accuracy + assert bare_ll.vertical_accuracy != ll.vertical_accuracy def test_position_properties(): diff --git a/gtk/src/toga_gtk/hardware/location.py b/gtk/src/toga_gtk/hardware/location.py index 3b36bbb507..0818d82997 100644 --- a/gtk/src/toga_gtk/hardware/location.py +++ b/gtk/src/toga_gtk/hardware/location.py @@ -34,8 +34,15 @@ def toga_location(location): """Convert a ``Geoclue.Location`` into ``OnLocationChangeHandler`` kwargs.""" - latlng = LatLng(location.props.latitude, location.props.longitude) - altitude = location.get_property("altitude") + altitude = location.props.altitude + latlng = LatLng( + location.props.latitude, + location.props.longitude, + altitude=altitude, + horizontal_accuracy=( + location.props.accuracy if location.props.accuracy > 0.0 else None + ), + ) return { "location": latlng, diff --git a/iOS/src/toga_iOS/hardware/location.py b/iOS/src/toga_iOS/hardware/location.py index bdf94500de..ef6d26e5e1 100644 --- a/iOS/src/toga_iOS/hardware/location.py +++ b/iOS/src/toga_iOS/hardware/location.py @@ -14,17 +14,20 @@ def toga_location(location): """Convert a Cocoa location into a Toga LatLng and altitude.""" + # A non-positive vertical accuracy indicates altitude is invalid. + altitude_valid = location.verticalAccuracy > 0.0 + altitude = location.altitude if altitude_valid else None + latlng = LatLng( location.coordinate.latitude, location.coordinate.longitude, + altitude=altitude, + horizontal_accuracy=( + location.horizontalAccuracy if location.horizontalAccuracy > 0.0 else None + ), + vertical_accuracy=location.verticalAccuracy if altitude_valid else None, ) - # A vertical accuracy that non-positive indicates altitude is invalid. - if location.verticalAccuracy > 0.0: - altitude = location.altitude - else: - altitude = None - return { "location": latlng, "altitude": altitude,