From b55bc5f4a5445d98b98c56a97362c09787c886bd Mon Sep 17 00:00:00 2001 From: conan Date: Mon, 27 Jul 2026 12:54:58 -0400 Subject: [PATCH 1/3] fix: example tools qa --- .../authenticator/AuthenticatorCodeScreen.kt | 16 ++-- .../AuthenticatorConfirmRemoveScreen.kt | 93 +++++++++++++++++++ .../weather/WeatherHomeScreen.kt | 18 +++- .../thelightphone/weather/WeatherModels.kt | 18 +++- .../thelightphone/weather/WeatherViewModel.kt | 69 ++++++-------- .../sdk/shared/LightServiceMethod.kt | 3 +- .../thelightphone/sdk/ui/LightScrollView.kt | 15 ++- 7 files changed, 176 insertions(+), 56 deletions(-) create mode 100644 examples/authenticator/src/main/kotlin/com/thelightphone/authenticator/AuthenticatorConfirmRemoveScreen.kt diff --git a/examples/authenticator/src/main/kotlin/com/thelightphone/authenticator/AuthenticatorCodeScreen.kt b/examples/authenticator/src/main/kotlin/com/thelightphone/authenticator/AuthenticatorCodeScreen.kt index 4788b61a..c5334649 100644 --- a/examples/authenticator/src/main/kotlin/com/thelightphone/authenticator/AuthenticatorCodeScreen.kt +++ b/examples/authenticator/src/main/kotlin/com/thelightphone/authenticator/AuthenticatorCodeScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.thelightphone.sdk.SealedLightActivity @@ -28,7 +27,6 @@ import com.thelightphone.sdk.ui.LightTopBar import com.thelightphone.sdk.ui.LightTopBarCenter import com.thelightphone.sdk.ui.gridUnitsAsDp import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -42,7 +40,6 @@ class AuthenticatorCodeScreen( @Composable override fun Content() { val themeColors by LightThemeController.colors.collectAsState() - val scope = rememberCoroutineScope() var account by remember { mutableStateOf(null) } var secret by remember { mutableStateOf(null) } @@ -108,11 +105,16 @@ class AuthenticatorCodeScreen( LightBarButton.Text( text = "REMOVE", onClick = { - scope.launch { - withContext(Dispatchers.IO) { - repository.deleteAccount(loadedAccount.id) + navigateTo(screenFactory = { + AuthenticatorConfirmRemoveScreen( + it, + loadedAccount, + repository, + ) + }) { removed -> + if (removed) { + goBack() } - goBack() } }, ), diff --git a/examples/authenticator/src/main/kotlin/com/thelightphone/authenticator/AuthenticatorConfirmRemoveScreen.kt b/examples/authenticator/src/main/kotlin/com/thelightphone/authenticator/AuthenticatorConfirmRemoveScreen.kt new file mode 100644 index 00000000..c53ab952 --- /dev/null +++ b/examples/authenticator/src/main/kotlin/com/thelightphone/authenticator/AuthenticatorConfirmRemoveScreen.kt @@ -0,0 +1,93 @@ +package com.thelightphone.authenticator + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import com.thelightphone.sdk.SealedLightActivity +import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.ui.LightBarButton +import com.thelightphone.sdk.ui.LightBottomBar +import com.thelightphone.sdk.ui.LightIcons +import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextVariant +import com.thelightphone.sdk.ui.LightTheme +import com.thelightphone.sdk.ui.LightThemeController +import com.thelightphone.sdk.ui.LightThemeTokens +import com.thelightphone.sdk.ui.LightTopBar +import com.thelightphone.sdk.ui.LightTopBarCenter +import com.thelightphone.sdk.ui.gridUnitsAsDp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class AuthenticatorConfirmRemoveScreen( + sealedActivity: SealedLightActivity, + private val account: StoredAccount, + private val repository: TotpAccountRepository, +) : SimpleLightScreen(sealedActivity) { + + @Composable + override fun Content() { + val themeColors by LightThemeController.colors.collectAsState() + val scope = rememberCoroutineScope() + val title = account.issuer.takeIf { it.isNotBlank() } ?: account.displayName + + LightTheme(colors = themeColors) { + Column( + modifier = Modifier + .fillMaxSize() + .background(LightThemeTokens.colors.background), + ) { + LightTopBar( + leftButton = LightBarButton.LightIcon( + icon = LightIcons.BACK, + onClick = { goBack(false) }, + ), + center = LightTopBarCenter.Text(title), + modifier = Modifier.padding(bottom = 1f.gridUnitsAsDp()), + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 1f.gridUnitsAsDp()), + contentAlignment = Alignment.Center, + ) { + LightText( + text = "Are you sure you'd like to remove this account?", + variant = LightTextVariant.Copy, + align = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + + LightBottomBar( + items = listOf( + LightBarButton.Text( + text = "CONFIRM", + onClick = { + scope.launch { + withContext(Dispatchers.IO) { + repository.deleteAccount(account.id) + } + goBack(true) + } + }, + ), + ), + ) + } + } + } +} diff --git a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherHomeScreen.kt b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherHomeScreen.kt index 465c1d60..c0365277 100644 --- a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherHomeScreen.kt +++ b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherHomeScreen.kt @@ -111,11 +111,19 @@ class WeatherHomeScreen(sealedActivity: SealedLightActivity) : center = LightTopBarCenter.Text("Weather"), modifier = Modifier.padding(bottom = 1f.gridUnitsAsDp()), ) - LightText( - text = "Loading…", - variant = LightTextVariant.Copy, - modifier = Modifier.padding(horizontal = 1f.gridUnitsAsDp()), - ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + LightText( + text = mode.message, + variant = LightTextVariant.Copy, + align = TextAlign.Center, + modifier = Modifier.padding(horizontal = 1f.gridUnitsAsDp()), + ) + } } } diff --git a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherModels.kt b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherModels.kt index c62db974..cb06bd68 100644 --- a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherModels.kt +++ b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherModels.kt @@ -1,5 +1,7 @@ package com.thelightphone.weather +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit import kotlinx.serialization.Serializable @Serializable @@ -66,10 +68,22 @@ data class StoredForecast( } } - fun hoursForToday(): List = - hourly.filter { it.time.substringBefore('T') == today.date } + fun hoursForToday(now: LocalDateTime = LocalDateTime.now()): List { + val todayDate = today.date + val fromHour = now.truncatedTo(ChronoUnit.HOURS) + return hourly.filter { hour -> + if (hour.time.substringBefore('T') != todayDate) return@filter false + val hourTime = parseHourlyLocalDateTime(hour.time) ?: return@filter true + !hourTime.isBefore(fromHour) + } + } } +private fun parseHourlyLocalDateTime(isoDateTime: String): LocalDateTime? = + runCatching { + LocalDateTime.parse(isoDateTime.take(16)) + }.getOrNull() + private fun WeeklyDay.toDayForecast(): DayForecast = DayForecast( date = date, tempMaxC = tempMaxC, diff --git a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherViewModel.kt b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherViewModel.kt index e60b661f..c4616328 100644 --- a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherViewModel.kt +++ b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherViewModel.kt @@ -1,5 +1,6 @@ package com.thelightphone.weather +import android.os.SystemClock import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit @@ -8,6 +9,7 @@ import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SimpleLightScreen import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,7 +26,7 @@ sealed class WeatherScreenMode { val query: String, val results: List, ) : WeatherScreenMode() - data object Loading : WeatherScreenMode() + data class Loading(val message: String) : WeatherScreenMode() data class Settings(val locationName: String) : WeatherScreenMode() data object Attribution : WeatherScreenMode() data class Weekly( @@ -46,7 +48,7 @@ sealed class WeatherScreenMode { } data class WeatherUiState( - val mode: WeatherScreenMode = WeatherScreenMode.Loading, + val mode: WeatherScreenMode = WeatherScreenMode.Loading(FETCHING_WEATHER_MESSAGE), val canCancelLocationInput: Boolean = false, val locationInputSession: Int = 0, val temperatureUnit: TemperatureUnit = TemperatureUnit.Celsius, @@ -61,6 +63,11 @@ private enum class LocationInputSource { private const val NETWORK_ERROR_MESSAGE = "The Weather tool requires a network connection. Please insert a data sim or connect to wi-fi to view the latest conditions." +private const val MIN_LOADING_DISPLAY_MS = 1_000L + +internal const val LOADING_MESSAGE = "Loading…" +internal const val FETCHING_WEATHER_MESSAGE = "fetching weather data..." + class WeatherViewModel( private val dataStore: DataStore, ) : LightViewModel() { @@ -182,7 +189,6 @@ class WeatherViewModel( val locationName = prefs[WeatherPreferences.LOCATION_NAME] val lat = prefs[WeatherPreferences.LATITUDE]?.toDoubleOrNull() val lon = prefs[WeatherPreferences.LONGITUDE]?.toDoubleOrNull() - val forecastJson = prefs[WeatherPreferences.FORECAST_JSON] updateState { it.copy(temperatureUnit = unit) } @@ -191,32 +197,9 @@ class WeatherViewModel( return } - val cachedForecast = forecastJson?.let { - runCatching { json.decodeFromString(it) }.getOrNull() - } savedLocationQuery = query cachedLocationName = locationName - if (cachedForecast != null && cachedForecast.weekly.isNotEmpty()) { - setState( - WeatherUiState( - mode = WeatherScreenMode.Weather( - locationName = locationName, - forecast = cachedForecast, - ), - canCancelLocationInput = true, - temperatureUnit = unit, - ), - ) - } else { - updateState { - it.copy(mode = WeatherScreenMode.Loading, temperatureUnit = unit) - } - } - - val needsRefresh = cachedForecast == null || - cachedForecast.weekly.isEmpty() || - cachedForecast.hourly.isEmpty() - refreshForecast(query, locationName, lat, lon, showLoadingScreen = needsRefresh) + refreshForecast(query, locationName, lat, lon) } private suspend fun refreshForecastOnScreenShow() { @@ -230,7 +213,6 @@ class WeatherViewModel( locationName = locationName, latitude = lat, longitude = lon, - showLoadingScreen = false, ) } @@ -243,14 +225,9 @@ class WeatherViewModel( viewModelScope.launch(Dispatchers.IO + apiExceptionHandler) { runCatching { - updateState { - it.copy( - mode = WeatherScreenMode.Loading, - errorModal = null, - ) - } - + val loadingStartedAt = beginLoading(LOADING_MESSAGE) val searchResult = api.searchLocations(query) + awaitMinimumLoading(loadingStartedAt) searchResult.fold( onSuccess = { results -> updateState { @@ -283,7 +260,6 @@ class WeatherViewModel( locationName = result.displayName(), latitude = result.latitude, longitude = result.longitude, - showLoadingScreen = true, ) }.onFailure { showApiFailure() @@ -300,13 +276,11 @@ class WeatherViewModel( locationName: String, latitude: Double, longitude: Double, - showLoadingScreen: Boolean, ) { - if (showLoadingScreen) { - updateState { it.copy(mode = WeatherScreenMode.Loading) } - } + val loadingStartedAt = beginLoading(FETCHING_WEATHER_MESSAGE) val forecastResult = api.fetchForecast(latitude, longitude) + awaitMinimumLoading(loadingStartedAt) forecastResult.fold( onSuccess = { forecast -> try { @@ -339,6 +313,21 @@ class WeatherViewModel( ) } + private suspend fun beginLoading(message: String): Long { + updateState { + it.copy( + mode = WeatherScreenMode.Loading(message), + errorModal = null, + ) + } + return SystemClock.elapsedRealtime() + } + + private suspend fun awaitMinimumLoading(startedAtMs: Long) { + val remaining = MIN_LOADING_DISPLAY_MS - (SystemClock.elapsedRealtime() - startedAtMs) + if (remaining > 0) delay(remaining) + } + fun showPreviousDay() { _uiState.update { state -> val mode = state.mode as? WeatherScreenMode.Weather ?: return@update state diff --git a/sdk/shared/src/main/kotlin/com/thelightphone/sdk/shared/LightServiceMethod.kt b/sdk/shared/src/main/kotlin/com/thelightphone/sdk/shared/LightServiceMethod.kt index 9e6cb154..fff15ef7 100644 --- a/sdk/shared/src/main/kotlin/com/thelightphone/sdk/shared/LightServiceMethod.kt +++ b/sdk/shared/src/main/kotlin/com/thelightphone/sdk/shared/LightServiceMethod.kt @@ -70,7 +70,8 @@ sealed interface LightServiceMethod { val emojisAsString: String?, val displayVoice: Boolean, val enableKeyAnimation: Boolean, - val swipeEnabled: Boolean? + // optional for older sdk servers that omit this field + val swipeEnabled: Boolean? = null, ) } diff --git a/sdk/ui/src/main/kotlin/com/thelightphone/sdk/ui/LightScrollView.kt b/sdk/ui/src/main/kotlin/com/thelightphone/sdk/ui/LightScrollView.kt index e5461e22..9e43f1b5 100644 --- a/sdk/ui/src/main/kotlin/com/thelightphone/sdk/ui/LightScrollView.kt +++ b/sdk/ui/src/main/kotlin/com/thelightphone/sdk/ui/LightScrollView.kt @@ -25,11 +25,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput @@ -98,7 +102,16 @@ fun LightScrollView( ) { val scope = rememberCoroutineScope() val scrollOffsetPx by remember { derivedStateOf { scrollState.value.toFloat() } } - val showScrollBar = scrollState.maxValue > 0 + val contentOverflows = scrollState.maxValue > 0 + var showScrollBar by remember { mutableStateOf(false) } + LaunchedEffect(contentOverflows) { + if (!contentOverflows) { + showScrollBar = false + } else { + withFrameMillis { } + showScrollBar = scrollState.maxValue > 0 + } + } val contentPaddingEnd = scrollBarGutterUnits(scrollBarPosition) Box(modifier = modifier) { From c4a502c0fdff85590ebbf3c437524c5ec2c2553f Mon Sep 17 00:00:00 2001 From: Guy Dupont Date: Tue, 28 Jul 2026 09:22:30 -0400 Subject: [PATCH 2/3] allow kotlinx datetime as dependency, move weather tool to use it --- examples/weather/build.gradle.kts | 1 + .../com/thelightphone/weather/WeatherApi.kt | 81 +++++++------ .../weather/WeatherFormatting.kt | 76 +++++------- .../weather/WeatherHomeScreen.kt | 3 +- .../thelightphone/weather/WeatherModels.kt | 40 +++--- .../thelightphone/weather/WeatherViewModel.kt | 29 ++--- .../weather/WeatherModelsTest.kt | 114 ++++++++++++++++++ gradle/libs.versions.toml | 2 + .../thelightphone/plugin/LightSdkPlugin.kt | 1 + 9 files changed, 222 insertions(+), 125 deletions(-) create mode 100644 examples/weather/src/test/kotlin/com/thelightphone/weather/WeatherModelsTest.kt diff --git a/examples/weather/build.gradle.kts b/examples/weather/build.gradle.kts index 44695041..a9efd58a 100644 --- a/examples/weather/build.gradle.kts +++ b/examples/weather/build.gradle.kts @@ -56,5 +56,6 @@ kotlin { dependencies { implementation(project(":sdk:client")) + implementation(libs.kotlinx.datetime) testImplementation(libs.kotlin.test) } diff --git a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherApi.kt b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherApi.kt index 58e31af8..cee129fb 100644 --- a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherApi.kt +++ b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherApi.kt @@ -8,6 +8,8 @@ import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -39,7 +41,7 @@ internal data class OpenMeteoForecastResponse( @Serializable internal data class OpenMeteoHourly( - val time: List = emptyList(), + val time: List = emptyList(), @SerialName("temperature_2m") val temperature2m: List = emptyList(), @SerialName("apparent_temperature") val apparentTemperature: List = emptyList(), val precipitation: List = emptyList(), @@ -55,7 +57,7 @@ internal data class OpenMeteoCurrent( @Serializable internal data class OpenMeteoDaily( - val time: List = emptyList(), + val time: List = emptyList(), @SerialName("temperature_2m_max") val temperature2mMax: List = emptyList(), @SerialName("temperature_2m_min") val temperature2mMin: List = emptyList(), @SerialName("apparent_temperature_max") val apparentTemperatureMax: List = emptyList(), @@ -66,8 +68,8 @@ internal data class OpenMeteoDaily( @SerialName("windspeed_10m_max") val windspeed10mMax: List = emptyList(), @SerialName("winddirection_10m_dominant") val winddirection10mDominant: List = emptyList(), @SerialName("uv_index_max") val uvIndexMax: List = emptyList(), - val sunrise: List = emptyList(), - val sunset: List = emptyList(), + val sunrise: List = emptyList(), + val sunset: List = emptyList(), ) internal class WeatherApi { @@ -114,42 +116,7 @@ internal class WeatherApi { } val forecastResponse: OpenMeteoForecastResponse = response.body() - val daily = forecastResponse.daily - ?: throw IllegalStateException("No forecast data available.") - - if (daily.time.size < 2) { - throw IllegalStateException("Forecast did not include today and tomorrow.") - } - - val current = forecastResponse.current?.let { - CurrentConditions( - tempC = it.temperature2m, - apparentTempC = it.apparentTemperature, - weatherCode = it.weatherCode, - ) - } - val today = daily.toDayForecast(index = 0) - val tomorrow = daily.toDayForecast(index = 1) - val dailyForecasts = daily.time.indices.map { index -> daily.toDayForecast(index) } - val weekly = daily.time.indices.map { index -> - WeeklyDay( - date = daily.time[index], - tempMaxC = daily.temperature2mMax[index], - tempMinC = daily.temperature2mMin[index], - precipitationMm = daily.precipitationSum[index], - precipitationProbabilityMax = daily.precipitationProbabilityMax.getOrNull(index), - weatherCode = daily.weathercode[index], - ) - } - val hourly = forecastResponse.hourly?.toHourlyForecasts().orEmpty() - StoredForecast( - today = today, - tomorrow = tomorrow, - weekly = weekly, - hourly = hourly, - current = current, - daily = dailyForecasts, - ) + forecastResponse.toStoredForecast() } fun close() { @@ -159,6 +126,40 @@ internal class WeatherApi { internal class LocationNotFoundException : Exception("Location not found.") +internal fun OpenMeteoForecastResponse.toStoredForecast(): StoredForecast { + val daily = daily ?: throw IllegalStateException("No forecast data available.") + if (daily.time.size < 2) { + throw IllegalStateException("Forecast did not include today and tomorrow.") + } + + val currentConditions = current?.let { + CurrentConditions( + tempC = it.temperature2m, + apparentTempC = it.apparentTemperature, + weatherCode = it.weatherCode, + ) + } + return StoredForecast( + today = daily.toDayForecast(index = 0), + tomorrow = daily.toDayForecast(index = 1), + weekly = daily.toWeeklyDays(), + hourly = hourly?.toHourlyForecasts().orEmpty(), + current = currentConditions, + daily = daily.time.indices.map { index -> daily.toDayForecast(index) }, + ) +} + +private fun OpenMeteoDaily.toWeeklyDays(): List = time.indices.map { index -> + WeeklyDay( + date = time[index], + tempMaxC = temperature2mMax[index], + tempMinC = temperature2mMin[index], + precipitationMm = precipitationSum[index], + precipitationProbabilityMax = precipitationProbabilityMax.getOrNull(index), + weatherCode = weathercode[index], + ) +} + private fun OpenMeteoDaily.toDayForecast(index: Int): DayForecast { return DayForecast( date = time[index], diff --git a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherFormatting.kt b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherFormatting.kt index d1730693..dd9510b8 100644 --- a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherFormatting.kt +++ b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherFormatting.kt @@ -1,11 +1,9 @@ package com.thelightphone.weather -import java.time.DayOfWeek -import java.time.LocalDate -import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.format.TextStyle -import java.util.Locale +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.Month import kotlin.math.roundToInt enum class TemperatureUnit { @@ -83,12 +81,10 @@ fun formatWindSpeed(kmh: Double, compass: String, unit: TemperatureUnit): String TemperatureUnit.Celsius -> "${kmh.roundToInt()} km/h $compass" } -fun formatTimeAmPm(iso: String): String = runCatching { - val timePart = iso.substringAfter('T') - val localTime = LocalTime.parse(timePart.take(5)) - localTime.format(DateTimeFormatter.ofPattern("h:mm a", Locale.US)) -}.getOrElse { - iso.substringAfter('T', iso).take(5) +fun formatTimeAmPm(dateTime: LocalDateTime?): String { + dateTime ?: return "--:--" + val (hour, period) = dateTime.to12Hour() + return "$hour:${dateTime.minute.toString().padStart(2, '0')} $period" } fun formatUvIndex(value: Double): String = value.round1() @@ -110,13 +106,9 @@ fun formatWeeklyPrecipitationDetail(day: WeeklyDay, unit: TemperatureUnit): Stri return if (probability != null) "$amount ($probability%)" else amount } -fun formatHourLabel(isoDateTime: String): String { - return try { - val time = LocalTime.parse(isoDateTime.substringAfter('T').take(5)) - time.format(DateTimeFormatter.ofPattern("ha", Locale.US)).uppercase(Locale.US) - } catch (_: Exception) { - isoDateTime.substringAfter('T').take(5) - } +fun formatHourLabel(dateTime: LocalDateTime): String { + val (hour, period) = dateTime.to12Hour() + return "$hour$period" } fun formatHourlyTempLine(hour: HourlyForecast, unit: TemperatureUnit): String { @@ -131,34 +123,30 @@ fun formatHourlyRainLine(hour: HourlyForecast, unit: TemperatureUnit): String { return if (probability != null) "Rain: $rain ($probability%)" else "Rain: $rain" } -fun formatDailyTitle(isoDate: String): String { - return try { - val date = LocalDate.parse(isoDate) - val weekday = when (date.dayOfWeek) { - DayOfWeek.MONDAY -> "Mon" - DayOfWeek.TUESDAY -> "Tues" - DayOfWeek.WEDNESDAY -> "Weds" - DayOfWeek.THURSDAY -> "Thurs" - DayOfWeek.FRIDAY -> "Fri" - DayOfWeek.SATURDAY -> "Sat" - DayOfWeek.SUNDAY -> "Sun" - } - val month = date.month.getDisplayName(TextStyle.FULL, Locale.US) - "$weekday $month ${date.dayOfMonth}" - } catch (_: Exception) { - isoDate +fun formatDailyTitle(date: LocalDate): String { + val weekday = when (date.dayOfWeek) { + DayOfWeek.MONDAY -> "Mon" + DayOfWeek.TUESDAY -> "Tues" + DayOfWeek.WEDNESDAY -> "Weds" + DayOfWeek.THURSDAY -> "Thurs" + DayOfWeek.FRIDAY -> "Fri" + DayOfWeek.SATURDAY -> "Sat" + DayOfWeek.SUNDAY -> "Sun" } + return "$weekday ${date.month.displayName()} ${date.dayOfMonth}" } -fun formatWeeklyDayLabel(isoDate: String): String { - return try { - val date = LocalDate.parse(isoDate) - val dayOfWeek = date.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault()) - val month = date.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) - "$dayOfWeek $month ${date.dayOfMonth}" - } catch (_: Exception) { - isoDate - } +fun formatWeeklyDayLabel(date: LocalDate): String { + val dayOfWeek = date.dayOfWeek.name.lowercase().replaceFirstChar { it.uppercase() } + return "$dayOfWeek ${date.month.displayName()} ${date.dayOfMonth}" +} + +private fun Month.displayName(): String = name.lowercase().replaceFirstChar { it.uppercase() } + +private fun LocalDateTime.to12Hour(): Pair { + val period = if (hour < 12) "AM" else "PM" + val twelveHour = hour % 12 + return (if (twelveHour == 0) 12 else twelveHour) to period } private fun Double.round1(): String { diff --git a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherHomeScreen.kt b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherHomeScreen.kt index c0365277..1455639e 100644 --- a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherHomeScreen.kt +++ b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherHomeScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle +import kotlinx.datetime.LocalDate import com.thelightphone.weather.R import com.thelightphone.sdk.ui.designVerticalPxToSp import com.thelightphone.sdk.InitialScreen @@ -491,7 +492,7 @@ private fun WeatherBoldLine(text: String) { @Composable private fun HourlyForecastContent( - date: String, + date: LocalDate, hours: List, temperatureUnit: TemperatureUnit, onClose: () -> Unit, diff --git a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherModels.kt b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherModels.kt index cb06bd68..fc2a4ef8 100644 --- a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherModels.kt +++ b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherModels.kt @@ -1,8 +1,12 @@ package com.thelightphone.weather -import java.time.LocalDateTime -import java.time.temporal.ChronoUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.Serializable +import kotlin.time.Clock @Serializable data class CurrentConditions( @@ -13,7 +17,7 @@ data class CurrentConditions( @Serializable data class DayForecast( - val date: String, + val date: LocalDate, val tempMaxC: Double, val tempMinC: Double, val apparentTempMaxC: Double, @@ -24,13 +28,13 @@ data class DayForecast( val windSpeedMaxKmh: Double, val windDirectionDominant: Int, val uvIndexMax: Double, - val sunrise: String, - val sunset: String, + val sunrise: LocalDateTime?, + val sunset: LocalDateTime?, ) @Serializable data class HourlyForecast( - val time: String, + val time: LocalDateTime, val tempC: Double, val apparentTempC: Double, val precipitationMm: Double, @@ -39,7 +43,7 @@ data class HourlyForecast( @Serializable data class WeeklyDay( - val date: String, + val date: LocalDate, val tempMaxC: Double, val tempMinC: Double, val precipitationMm: Double, @@ -68,22 +72,14 @@ data class StoredForecast( } } - fun hoursForToday(now: LocalDateTime = LocalDateTime.now()): List { - val todayDate = today.date - val fromHour = now.truncatedTo(ChronoUnit.HOURS) - return hourly.filter { hour -> - if (hour.time.substringBefore('T') != todayDate) return@filter false - val hourTime = parseHourlyLocalDateTime(hour.time) ?: return@filter true - !hourTime.isBefore(fromHour) - } + fun hoursForToday( + now: LocalDateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + ): List { + val fromHour = now.date.atTime(now.hour, 0) // replaces truncatedTo(HOURS) + return hourly.filter { hour -> hour.time.date == today.date && hour.time >= fromHour } } } -private fun parseHourlyLocalDateTime(isoDateTime: String): LocalDateTime? = - runCatching { - LocalDateTime.parse(isoDateTime.take(16)) - }.getOrNull() - private fun WeeklyDay.toDayForecast(): DayForecast = DayForecast( date = date, tempMaxC = tempMaxC, @@ -96,8 +92,8 @@ private fun WeeklyDay.toDayForecast(): DayForecast = DayForecast( windSpeedMaxKmh = 0.0, windDirectionDominant = 0, uvIndexMax = 0.0, - sunrise = "", - sunset = "", + sunrise = null, + sunset = null, ) internal fun wmoWeatherDescription(code: Int): String = when (code) { diff --git a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherViewModel.kt b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherViewModel.kt index c4616328..09c6cd37 100644 --- a/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherViewModel.kt +++ b/examples/weather/src/main/kotlin/com/thelightphone/weather/WeatherViewModel.kt @@ -1,24 +1,17 @@ package com.thelightphone.weather -import android.os.SystemClock import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.lifecycle.viewModelScope import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SimpleLightScreen -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import kotlinx.serialization.json.Json +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant sealed class WeatherScreenMode { data object LocationInput : WeatherScreenMode() @@ -63,7 +56,7 @@ private enum class LocationInputSource { private const val NETWORK_ERROR_MESSAGE = "The Weather tool requires a network connection. Please insert a data sim or connect to wi-fi to view the latest conditions." -private const val MIN_LOADING_DISPLAY_MS = 1_000L +private val MIN_LOADING_DISPLAY = 1.seconds internal const val LOADING_MESSAGE = "Loading…" internal const val FETCHING_WEATHER_MESSAGE = "fetching weather data..." @@ -313,19 +306,19 @@ class WeatherViewModel( ) } - private suspend fun beginLoading(message: String): Long { + private suspend fun beginLoading(message: String): Instant { updateState { it.copy( mode = WeatherScreenMode.Loading(message), errorModal = null, ) } - return SystemClock.elapsedRealtime() + return Clock.System.now() } - private suspend fun awaitMinimumLoading(startedAtMs: Long) { - val remaining = MIN_LOADING_DISPLAY_MS - (SystemClock.elapsedRealtime() - startedAtMs) - if (remaining > 0) delay(remaining) + private suspend fun awaitMinimumLoading(startedAt: Instant) { + val remaining = MIN_LOADING_DISPLAY - (Clock.System.now() - startedAt) + if (remaining.isPositive()) delay(remaining) } fun showPreviousDay() { diff --git a/examples/weather/src/test/kotlin/com/thelightphone/weather/WeatherModelsTest.kt b/examples/weather/src/test/kotlin/com/thelightphone/weather/WeatherModelsTest.kt new file mode 100644 index 00000000..a7fe38c3 --- /dev/null +++ b/examples/weather/src/test/kotlin/com/thelightphone/weather/WeatherModelsTest.kt @@ -0,0 +1,114 @@ +package com.thelightphone.weather + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + + +//curl "https://production.lightphonecloud.com/tools/weather/forecast?latitude=37.7749&longitude=-122.4194¤t=temperature_2m,apparent_temperature,weather_code&hourly=temperature_2m,apparent_temperature,precipitation,precipitation_probability&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,precipitation_sum,precipitation_probability_max,weathercode,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,sunrise,sunset&timezone=auto&forecast_days=7" +private val SAMPLE_OPEN_METEO_RESPONSE = """ +{"daily":{"apparent_temperature_max":[24.5,25.1,21.3,24.2,23.7,22.0,26.1],"apparent_temperature_min":[11.9,11.6,13.3,13.4,14.2,13.6,13.1],"precipitation_probability_max":[0,1,0,0,0,0,0],"precipitation_sum":[0.0,0.0,0.0,0.0,0.0,0.0,0.0],"sunrise":["2026-07-28T06:10","2026-07-29T06:11","2026-07-30T06:11","2026-07-31T06:12","2026-08-01T06:13","2026-08-02T06:14","2026-08-03T06:15"],"sunset":["2026-07-28T20:22","2026-07-29T20:21","2026-07-30T20:20","2026-07-31T20:19","2026-08-01T20:18","2026-08-02T20:17","2026-08-03T20:16"],"temperature_2m_max":[23.4,23.9,20.9,23.5,22.8,21.3,25.2],"temperature_2m_min":[12.7,12.5,14.1,14.1,14.4,14.4,13.9],"time":["2026-07-28","2026-07-29","2026-07-30","2026-07-31","2026-08-01","2026-08-02","2026-08-03"],"uv_index_max":[8.15,8.15,8.2,8.15,8.15,7.95,8.0],"weathercode":[1,1,2,0,0,3,0],"winddirection_10m_dominant":[283,275,252,251,246,239,220],"windspeed_10m_max":[29.5,29.7,20.8,19.4,19.9,18.6,18.4]},"hourly":{"time":["2026-07-28T00:00","2026-07-28T01:00","2026-07-28T02:00","2026-07-28T03:00","2026-07-28T04:00","2026-07-28T05:00","2026-07-28T06:00","2026-07-28T07:00","2026-07-28T08:00","2026-07-28T09:00","2026-07-28T10:00","2026-07-28T11:00","2026-07-28T12:00","2026-07-28T13:00","2026-07-28T14:00","2026-07-28T15:00","2026-07-28T16:00","2026-07-28T17:00","2026-07-28T18:00","2026-07-28T19:00","2026-07-28T20:00","2026-07-28T21:00","2026-07-28T22:00","2026-07-28T23:00","2026-07-29T00:00","2026-07-29T01:00","2026-07-29T02:00","2026-07-29T03:00","2026-07-29T04:00","2026-07-29T05:00"],"temperature_2m":[14.0,13.6,13.9,13.3,13.1,13.3,12.7,13.2,15.6,17.4,19.1,21.4,22.8,23.2,23.4,23.1,22.6,21.4,20.1,18.7,17.2,16.2,15.4,14.3,13.8,13.5,13.1,12.9,12.8,12.6],"apparent_temperature":[12.8,12.5,12.9,12.1,12.0,13.1,11.9,13.0,16.3,18.5,20.7,23.4,24.5,24.3,23.9,22.8,21.5,19.6,17.1,15.7,14.3,14.8,15.9,14.7,14.1,13.6,12.9,12.3,12.1,11.8],"precipitation":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],"precipitation_probability":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0]},"current":{"apparent_temperature":11.9,"interval":900,"temperature_2m":12.7,"time":"2026-07-28T06:00","weather_code":0}} +""".trimIndent() + +private val json = Json { ignoreUnknownKeys = true } + +private fun sampleForecast(): StoredForecast = + json.decodeFromString(SAMPLE_OPEN_METEO_RESPONSE).toStoredForecast() + +class WeatherModelsTest { + + @Test + fun `decodes open-meteo shaped dates and seconds-less datetimes`() { + val forecast = sampleForecast() + assertEquals(LocalDate.parse("2026-07-28"), forecast.today.date) + assertEquals(LocalDateTime.parse("2026-07-28T06:10"), forecast.today.sunrise) + assertEquals(LocalDateTime.parse("2026-07-28T00:00"), forecast.hourly.first().time) + assertEquals(30, forecast.hourly.size) + } + + @Test + fun `dayCount prefers weekly size, then daily size, then falls back to two`() { + val forecast = sampleForecast() // weekly and daily both have 7 real days + + val weeklyLonger = forecast.copy(weekly = forecast.weekly + forecast.weekly[0]) + assertEquals(8, weeklyLonger.dayCount()) + + assertEquals(7, forecast.copy(weekly = emptyList()).dayCount()) + assertEquals(2, forecast.copy(weekly = emptyList(), daily = emptyList()).dayCount()) + } + + @Test + fun `dayAt reads straight from the daily list when present`() { + val forecast = sampleForecast() + assertEquals(forecast.daily[6], forecast.dayAt(6)) + assertNull(forecast.dayAt(7)) + } + + @Test + fun `dayAt falls back to today, tomorrow, and converted weekly days`() { + val forecast = sampleForecast().copy(daily = emptyList()) + + assertEquals(forecast.today, forecast.dayAt(0)) + assertEquals(forecast.tomorrow, forecast.dayAt(1)) + + val thirdDay = forecast.dayAt(2) + assertNotNull(thirdDay) + assertEquals(LocalDate.parse("2026-07-30"), thirdDay.date) + assertEquals(2, thirdDay.weatherCode) + // WeeklyDay carries no wind/UV/sunrise data; the fallback conversion + // has to make those up rather than lying with a fake value. + assertNull(thirdDay.sunrise) + assertNull(thirdDay.sunset) + assertEquals(0, thirdDay.windDirectionDominant) + } + + @Test + fun `dayAt returns null out of range`() { + val forecast = sampleForecast().copy(daily = emptyList(), weekly = emptyList()) + assertNull(forecast.dayAt(2)) + assertNull(forecast.dayAt(-1)) + } + + @Test + fun `hoursForToday keeps only todays hours at or after the current hour`() { + val forecast = sampleForecast() + val hours = forecast.hoursForToday(now = LocalDateTime.parse("2026-07-28T10:30")) + + assertEquals(14, hours.size) // 10:00 through 23:00 + assertEquals(LocalDateTime.parse("2026-07-28T10:00"), hours.first().time) + assertEquals(LocalDateTime.parse("2026-07-28T23:00"), hours.last().time) + assertTrue(hours.all { it.time.date == LocalDate.parse("2026-07-28") }) + } + + @Test + fun `hoursForToday truncates now to the top of the hour`() { + val forecast = sampleForecast() + val hours = forecast.hoursForToday(now = LocalDateTime.parse("2026-07-28T12:45")) + + assertEquals(12, hours.size) // 12:00 through 23:00 + assertEquals(LocalDateTime.parse("2026-07-28T12:00"), hours.first().time) + } + + @Test + fun `weather descriptions come from the wmo code table`() { + val forecast = sampleForecast() + assertEquals("Clear sky", forecast.current?.weatherDescription) + assertEquals("Mainly clear, partly cloudy, or overcast", forecast.today.weatherDescription) + assertEquals("Thunderstorm with hail", wmoWeatherDescription(96)) + assertEquals("Weather code 42", wmoWeatherDescription(42)) + } + + @Test + fun `wind compass reads direction off the day forecast`() { + val forecast = sampleForecast() + assertEquals("W", forecast.today.windCompass) + assertEquals("N", degreesToCompass(0)) + assertEquals("N", degreesToCompass(359)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5178687d..7cb1c00d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ mlkitBarcode = "17.3.0" room = "2.7.0" work = "2.10.0" media3 = "1.10.1" +kotlinxDatetime = "0.8.0" [libraries] unifiedpush-connector = { module = "org.unifiedpush.android:connector", version.ref = "connector" } @@ -30,6 +31,7 @@ androidx-splashscreen = { module = "androidx.core:core-splashscreen", version = kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version = "1.10.2" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.10.2" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.8.1" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version = "2.10.0" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version = "2.10.0" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version = "1.2.1" } diff --git a/plugin/src/main/kotlin/com/thelightphone/plugin/LightSdkPlugin.kt b/plugin/src/main/kotlin/com/thelightphone/plugin/LightSdkPlugin.kt index eecc52b8..2da84e2d 100644 --- a/plugin/src/main/kotlin/com/thelightphone/plugin/LightSdkPlugin.kt +++ b/plugin/src/main/kotlin/com/thelightphone/plugin/LightSdkPlugin.kt @@ -27,6 +27,7 @@ class LightSdkPlugin : Plugin { "io.ktor", "org.jetbrains.kotlinx:kotlinx-serialization", "org.jetbrains.kotlinx:kotlinx-io", + "org.jetbrains.kotlinx:kotlinx-datetime", "org.unifiedpush.android:connector", "androidx.core:core-splashscreen", "com.thelightphone.lp3keyboard", From 61880474034a13eb60117bc76d6070a53f6186eb Mon Sep 17 00:00:00 2001 From: conan Date: Wed, 29 Jul 2026 12:04:15 -0400 Subject: [PATCH 3/3] fix: explicitNulls --- .../com/thelightphone/sdk/shared/LightServiceMethod.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/shared/src/main/kotlin/com/thelightphone/sdk/shared/LightServiceMethod.kt b/sdk/shared/src/main/kotlin/com/thelightphone/sdk/shared/LightServiceMethod.kt index fff15ef7..93b2db2e 100644 --- a/sdk/shared/src/main/kotlin/com/thelightphone/sdk/shared/LightServiceMethod.kt +++ b/sdk/shared/src/main/kotlin/com/thelightphone/sdk/shared/LightServiceMethod.kt @@ -6,7 +6,10 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.serializer -val lightJson = Json { ignoreUnknownKeys = true } +val lightJson = Json { + ignoreUnknownKeys = true + explicitNulls = false +} /** * Defines a typed method that a client can call on the server's bound service.