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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand All @@ -42,7 +40,6 @@ class AuthenticatorCodeScreen(
@Composable
override fun Content() {
val themeColors by LightThemeController.colors.collectAsState()
val scope = rememberCoroutineScope()
var account by remember { mutableStateOf<StoredAccount?>(null) }
var secret by remember { mutableStateOf<String?>(null) }

Expand Down Expand Up @@ -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()
}
},
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Boolean>(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)
}
},
),
),
)
}
}
}
}
1 change: 1 addition & 0 deletions examples/weather/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@ kotlin {

dependencies {
implementation(project(":sdk:client"))
implementation(libs.kotlinx.datetime)
testImplementation(libs.kotlin.test)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,7 +41,7 @@ internal data class OpenMeteoForecastResponse(

@Serializable
internal data class OpenMeteoHourly(
val time: List<String> = emptyList(),
val time: List<LocalDateTime> = emptyList(),
@SerialName("temperature_2m") val temperature2m: List<Double> = emptyList(),
@SerialName("apparent_temperature") val apparentTemperature: List<Double> = emptyList(),
val precipitation: List<Double> = emptyList(),
Expand All @@ -55,7 +57,7 @@ internal data class OpenMeteoCurrent(

@Serializable
internal data class OpenMeteoDaily(
val time: List<String> = emptyList(),
val time: List<LocalDate> = emptyList(),
@SerialName("temperature_2m_max") val temperature2mMax: List<Double> = emptyList(),
@SerialName("temperature_2m_min") val temperature2mMin: List<Double> = emptyList(),
@SerialName("apparent_temperature_max") val apparentTemperatureMax: List<Double> = emptyList(),
Expand All @@ -66,8 +68,8 @@ internal data class OpenMeteoDaily(
@SerialName("windspeed_10m_max") val windspeed10mMax: List<Double> = emptyList(),
@SerialName("winddirection_10m_dominant") val winddirection10mDominant: List<Int> = emptyList(),
@SerialName("uv_index_max") val uvIndexMax: List<Double> = emptyList(),
val sunrise: List<String> = emptyList(),
val sunset: List<String> = emptyList(),
val sunrise: List<LocalDateTime> = emptyList(),
val sunset: List<LocalDateTime> = emptyList(),
)

internal class WeatherApi {
Expand Down Expand Up @@ -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() {
Expand All @@ -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<WeeklyDay> = 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],
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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<Int, String> {
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 {
Expand Down
Loading
Loading