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
117 changes: 117 additions & 0 deletions plans/2026-02-05-geolocation.md
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The default value Long.MaxValue for timeout in GeoOptions is used to signify 'no timeout' by the implementation. While functional, this might be less intuitive for users compared to using Option[Long] or aligning with the browser API's convention (e.g., 0 for maximumAge or omitting the property for timeout to use the browser's default of Infinity). Consider clarifying this in the documentation or adjusting the type to Option[Long] for better Scala idiomatic representation of an optional timeout.

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 = _ => (),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Providing a default no-op function (_ => ()) for onError can lead to silent failures if the caller doesn't explicitly provide an error handler. While convenient for simple cases, it might mask important issues. For robust applications, it's often better to encourage explicit error handling.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment // None until first position is helpful. To be even more precise for reactive stream users, it could explicitly state that the Rx stream will emit None as its initial value before any actual position data is acquired.

def error: Rx[Option[GeoError]] // None if no error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the position stream, clarifying that the error stream will emit None as its initial value when there is no error would enhance clarity for reactive stream consumers.

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
185 changes: 185 additions & 0 deletions uni-dom-test/src/test/scala/wvlet/uni/dom/GeolocationTest.scala
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test currently only verifies that getCurrentPosition doesn't throw an exception. In a jsdom environment without geolocation support, it should specifically call the onError callback. A more robust test would mock the geolocation object to explicitly trigger success or error scenarios and assert that the correct callback (onSuccess or onError) is invoked.

// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test only verifies that an initial value is emitted by the position reactive stream. It does not cover the continuous watching aspect, meaning it doesn't assert that subsequent position updates are emitted when the underlying geolocation changes. Consider adding a mock for geolocation.watchPosition that can simulate multiple position updates to fully test the reactive stream's behavior.


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the position stream test, this test only confirms the initial emission of the error stream. It does not verify that actual GeoError instances are emitted when an error occurs during continuous watching. A comprehensive test would mock geolocation.watchPosition to trigger error callbacks and assert the emission of specific GeoError types.


end GeolocationTest
Loading
Loading