-
Notifications
You must be signed in to change notification settings - Fork 0
feature: Add Geolocation API support to uni-dom #407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| # Geolocation API Support for uni-dom | ||
|
|
||
| ## Overview | ||
|
|
||
| Add a `Geolocation` object that provides reactive geolocation tracking using the browser's Geolocation API. | ||
|
|
||
| ## Goals | ||
|
|
||
| 1. Provide reactive position tracking with `Rx[GeoPosition]` | ||
| 2. Support both one-shot and continuous position watching | ||
| 3. Handle errors gracefully (permission denied, unavailable, timeout) | ||
| 4. Support configuration options (high accuracy, timeout, max age) | ||
|
|
||
| ## API Design | ||
|
|
||
| ### Core Types | ||
|
|
||
| ```scala | ||
| // Position data from the Geolocation API | ||
| case class GeoPosition( | ||
| latitude: Double, | ||
| longitude: Double, | ||
| altitude: Option[Double], | ||
| accuracy: Double, | ||
| altitudeAccuracy: Option[Double], | ||
| heading: Option[Double], | ||
| speed: Option[Double], | ||
| timestamp: Long | ||
| ) | ||
|
|
||
| // Geolocation errors | ||
| enum GeoError: | ||
| case PermissionDenied | ||
| case PositionUnavailable | ||
| case Timeout | ||
| case NotSupported | ||
|
|
||
| // Options for position requests | ||
| case class GeoOptions( | ||
| enableHighAccuracy: Boolean = false, | ||
| timeout: Long = Long.MaxValue, | ||
| maximumAge: Long = 0 | ||
| ) | ||
| ``` | ||
|
|
||
| ### Geolocation Object | ||
|
|
||
| ```scala | ||
| object Geolocation: | ||
| // Check if geolocation is supported | ||
| def isSupported: Boolean | ||
|
|
||
| // One-shot position request | ||
| def getCurrentPosition( | ||
| onSuccess: GeoPosition => Unit, | ||
| onError: GeoError => Unit = _ => (), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| options: GeoOptions = GeoOptions() | ||
| ): Unit | ||
|
|
||
| // Continuous position watching - returns a watcher that can be cancelled | ||
| def watch(options: GeoOptions = GeoOptions()): PositionWatcher | ||
|
|
||
| class PositionWatcher extends Cancelable: | ||
| def position: Rx[Option[GeoPosition]] // None until first position | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| def error: Rx[Option[GeoError]] // None if no error | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| def lastPosition: Option[GeoPosition] | ||
| def lastError: Option[GeoError] | ||
| def cancel: Unit | ||
| ``` | ||
|
|
||
| ### Usage Examples | ||
|
|
||
| ```scala | ||
| import wvlet.uni.dom.all.* | ||
|
|
||
| // Check if supported | ||
| if Geolocation.isSupported then | ||
| // One-shot position | ||
| Geolocation.getCurrentPosition( | ||
| onSuccess = pos => println(s"Location: ${pos.latitude}, ${pos.longitude}"), | ||
| onError = err => println(s"Error: ${err}") | ||
| ) | ||
|
|
||
| // Continuous watching with reactive updates | ||
| val watcher = Geolocation.watch(GeoOptions(enableHighAccuracy = true)) | ||
|
|
||
| div( | ||
| watcher.position.map { | ||
| case Some(pos) => span(s"Lat: ${pos.latitude}, Lon: ${pos.longitude}") | ||
| case None => span("Waiting for location...") | ||
| }, | ||
| watcher.error.map { | ||
| case Some(GeoError.PermissionDenied) => span(cls := "error", "Location access denied") | ||
| case Some(err) => span(cls := "error", s"Error: ${err}") | ||
| case None => DomNode.empty | ||
| } | ||
| ) | ||
|
|
||
| // Don't forget to cancel when done | ||
| watcher.cancel | ||
| ``` | ||
|
|
||
| ## Implementation Notes | ||
|
|
||
| - Use `dom.window.navigator.geolocation` from scala-js-dom | ||
| - Convert PositionError codes to GeoError enum | ||
| - Handle null/undefined values from optional coordinates | ||
| - PositionWatcher uses RxVar internally for reactive updates | ||
|
|
||
| ## Test Plan | ||
|
|
||
| - Test GeoPosition case class | ||
| - Test GeoError enum values | ||
| - Test GeoOptions defaults | ||
| - Test Geolocation.isSupported returns Boolean | ||
| - Test PositionWatcher returns proper Rx types | ||
| - Test watch() returns PositionWatcher | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| /* | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
| package wvlet.uni.dom | ||
|
|
||
| import wvlet.uni.test.UniTest | ||
| import wvlet.uni.dom.all.* | ||
| import wvlet.uni.rx.Rx | ||
|
|
||
| class GeolocationTest extends UniTest: | ||
|
|
||
| test("GeoPosition case class holds position data"): | ||
| val pos = GeoPosition( | ||
| latitude = 37.7749, | ||
| longitude = -122.4194, | ||
| altitude = Some(10.0), | ||
| accuracy = 50.0, | ||
| altitudeAccuracy = Some(5.0), | ||
| heading = Some(90.0), | ||
| speed = Some(1.5), | ||
| timestamp = 1234567890L | ||
| ) | ||
| pos.latitude shouldBe 37.7749 | ||
| pos.longitude shouldBe -122.4194 | ||
| pos.altitude shouldBe Some(10.0) | ||
| pos.accuracy shouldBe 50.0 | ||
| pos.altitudeAccuracy shouldBe Some(5.0) | ||
| pos.heading shouldBe Some(90.0) | ||
| pos.speed shouldBe Some(1.5) | ||
| pos.timestamp shouldBe 1234567890L | ||
|
|
||
| test("GeoPosition with optional fields as None"): | ||
| val pos = GeoPosition( | ||
| latitude = 40.7128, | ||
| longitude = -74.0060, | ||
| altitude = None, | ||
| accuracy = 100.0, | ||
| altitudeAccuracy = None, | ||
| heading = None, | ||
| speed = None, | ||
| timestamp = 9876543210L | ||
| ) | ||
| pos.altitude shouldBe None | ||
| pos.altitudeAccuracy shouldBe None | ||
| pos.heading shouldBe None | ||
| pos.speed shouldBe None | ||
|
|
||
| test("GeoError enum has all error types"): | ||
| GeoError.PermissionDenied shouldMatch { case GeoError.PermissionDenied => | ||
| } | ||
| GeoError.PositionUnavailable shouldMatch { case GeoError.PositionUnavailable => | ||
| } | ||
| GeoError.Timeout shouldMatch { case GeoError.Timeout => | ||
| } | ||
| GeoError.NotSupported shouldMatch { case GeoError.NotSupported => | ||
| } | ||
|
|
||
| test("GeoError values are distinct"): | ||
| val errors = Seq( | ||
| GeoError.PermissionDenied, | ||
| GeoError.PositionUnavailable, | ||
| GeoError.Timeout, | ||
| GeoError.NotSupported | ||
| ) | ||
| errors.distinct.size shouldBe 4 | ||
|
|
||
| test("GeoOptions has sensible defaults"): | ||
| val opts = GeoOptions() | ||
| opts.enableHighAccuracy shouldBe false | ||
| opts.timeout shouldBe Long.MaxValue | ||
| opts.maximumAge shouldBe 0L | ||
|
|
||
| test("GeoOptions can be customized"): | ||
| val opts = GeoOptions(enableHighAccuracy = true, timeout = 10000L, maximumAge = 60000L) | ||
| opts.enableHighAccuracy shouldBe true | ||
| opts.timeout shouldBe 10000L | ||
| opts.maximumAge shouldBe 60000L | ||
|
|
||
| test("Geolocation.isSupported returns Boolean"): | ||
| val supported = Geolocation.isSupported | ||
| supported shouldMatch { case _: Boolean => | ||
| } | ||
|
|
||
| test("Geolocation.watch returns PositionWatcher"): | ||
| val watcher = Geolocation.watch() | ||
| watcher shouldMatch { case _: Geolocation.PositionWatcher => | ||
| } | ||
| watcher.cancel | ||
|
|
||
| test("Geolocation.watch with options returns PositionWatcher"): | ||
| val watcher = Geolocation.watch(GeoOptions(enableHighAccuracy = true)) | ||
| watcher shouldMatch { case _: Geolocation.PositionWatcher => | ||
| } | ||
| watcher.cancel | ||
|
|
||
| test("PositionWatcher.position returns Rx[Option[GeoPosition]]"): | ||
| val watcher = Geolocation.watch() | ||
| val pos = watcher.position | ||
| pos shouldMatch { case _: Rx[?] => | ||
| } | ||
| watcher.cancel | ||
|
|
||
| test("PositionWatcher.error returns Rx[Option[GeoError]]"): | ||
| val watcher = Geolocation.watch() | ||
| val err = watcher.error | ||
| err shouldMatch { case _: Rx[?] => | ||
| } | ||
| watcher.cancel | ||
|
|
||
| test("PositionWatcher.lastPosition returns Option[GeoPosition]"): | ||
| val watcher = Geolocation.watch() | ||
| val pos = watcher.lastPosition | ||
| pos shouldMatch { case _: Option[?] => | ||
| } | ||
| watcher.cancel | ||
|
|
||
| test("PositionWatcher.lastError returns Option[GeoError]"): | ||
| val watcher = Geolocation.watch() | ||
| val err = watcher.lastError | ||
| err shouldMatch { case _: Option[?] => | ||
| } | ||
| watcher.cancel | ||
|
|
||
| test("PositionWatcher can be cancelled"): | ||
| val watcher = Geolocation.watch() | ||
| watcher.cancel | ||
| // Should not throw | ||
|
|
||
| test("PositionWatcher can be cancelled multiple times"): | ||
| val watcher = Geolocation.watch() | ||
| watcher.cancel | ||
| watcher.cancel | ||
| // Should not throw | ||
|
|
||
| test("Geolocation.getCurrentPosition accepts callbacks"): | ||
| var called = false | ||
| Geolocation.getCurrentPosition(onSuccess = _ => called = true, onError = _ => called = true) | ||
| // In jsdom environment without geolocation, this should call onError | ||
|
Comment on lines
+145
to
+148
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test currently only verifies that |
||
| // We just verify the call doesn't throw | ||
|
|
||
| test("Geolocation.getCurrentPosition with options"): | ||
| Geolocation.getCurrentPosition( | ||
| onSuccess = _ => (), | ||
| onError = _ => (), | ||
| options = GeoOptions(enableHighAccuracy = true, timeout = 5000L) | ||
| ) | ||
| // Should not throw | ||
|
|
||
| test("PositionWatcher position reactive stream emits values"): | ||
| val watcher = Geolocation.watch() | ||
| var emittedValue = false | ||
| val cancel = watcher | ||
| .position | ||
| .run { _ => | ||
| emittedValue = true | ||
| } | ||
| // Initial value should be emitted | ||
| emittedValue shouldBe true | ||
| cancel.cancel | ||
| watcher.cancel | ||
|
Comment on lines
+159
to
+170
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test only verifies that an initial value is emitted by the |
||
|
|
||
| test("PositionWatcher error reactive stream emits values"): | ||
| val watcher = Geolocation.watch() | ||
| var emittedValue = false | ||
| val cancel = watcher | ||
| .error | ||
| .run { _ => | ||
| emittedValue = true | ||
| } | ||
| // Initial value should be emitted | ||
| emittedValue shouldBe true | ||
| cancel.cancel | ||
| watcher.cancel | ||
|
Comment on lines
+173
to
+183
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to the |
||
|
|
||
| end GeolocationTest | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default value
Long.MaxValuefortimeoutinGeoOptionsis used to signify 'no timeout' by the implementation. While functional, this might be less intuitive for users compared to usingOption[Long]or aligning with the browser API's convention (e.g.,0formaximumAgeor omitting the property fortimeoutto use the browser's default ofInfinity). Consider clarifying this in the documentation or adjusting the type toOption[Long]for better Scala idiomatic representation of an optional timeout.