build: shell, data layer, theme, animated scene, components

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 13:52:42 +02:00
co-authored by Claude Opus 5.5
parent e02f5aa18d
commit 912d6f2ac1
36 changed files with 3103 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".WetterApp"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver android:name=".widget.NowWidgetReceiver" android:exported="true" android:label="@string/widget_now_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_now" />
</receiver>
<receiver android:name=".widget.ForecastWidgetReceiver" android:exported="true" android:label="@string/widget_forecast_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_forecast" />
</receiver>
</application>
</manifest>
@@ -0,0 +1,83 @@
package app.wetter
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.PowerManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import app.wetter.data.Graph
import app.wetter.data.Place
import app.wetter.data.Settings
import app.wetter.ui.screens.DayScreen
import app.wetter.ui.screens.HomeScreen
import app.wetter.ui.screens.LocationsScreen
import app.wetter.ui.screens.PreviewScreen
import app.wetter.ui.screens.SettingsScreen
import app.wetter.ui.theme.WeatherTheme
import java.time.LocalDate
sealed interface Route {
data class Home(val locationId: Long? = null) : Route
data object Locations : Route
data class Preview(val place: Place) : Route
data class Day(val locationId: Long, val date: LocalDate) : Route
data object Settings : Route
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Graph.init(this)
val startLocation = intent.getLongExtra(EXTRA_LOCATION, -1L).takeIf { it >= 0 }
setContent {
val settings by Graph.settings.settings.collectAsState(initial = Settings())
val stack = remember { mutableStateListOf<Route>(Route.Home(startLocation)) }
val push = { r: Route -> stack.add(r); Unit }
val pop = { if (stack.size > 1) stack.removeAt(stack.lastIndex); Unit }
val power = getSystemService(PowerManager::class.java)
val animate = !power.isPowerSaveMode
WeatherTheme(settings.theme) {
BackHandler(enabled = stack.size > 1, onBack = pop)
AnimatedContent(stack.last(), transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "nav") { route ->
when (route) {
is Route.Home -> HomeScreen(
settings, route.locationId, animate,
onOpenLocations = { push(Route.Locations) },
onOpenSettings = { push(Route.Settings) },
onOpenDay = { id, d -> push(Route.Day(id, d)) },
)
Route.Locations -> LocationsScreen(
settings, onBack = pop,
onOpen = { id -> stack.clear(); stack.add(Route.Home(id)) },
onPreview = { push(Route.Preview(it)) },
)
is Route.Preview -> PreviewScreen(route.place, settings, onBack = pop, onSaved = { id -> stack.clear(); stack.add(Route.Home(id)) })
is Route.Day -> DayScreen(route.locationId, route.date, settings, onBack = pop)
Route.Settings -> SettingsScreen(settings, onBack = pop)
}
}
}
}
}
companion object {
const val EXTRA_LOCATION = "location_id"
fun intent(context: Context, locationId: Long?) = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (locationId != null) putExtra(EXTRA_LOCATION, locationId)
}
}
}
@@ -0,0 +1,13 @@
package app.wetter
import android.app.Application
import app.wetter.data.Graph
import app.wetter.work.RefreshWorker
class WetterApp : Application() {
override fun onCreate() {
super.onCreate()
Graph.init(this)
RefreshWorker.schedule(this)
}
}
@@ -0,0 +1,120 @@
package app.wetter.data
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "locations", indices = [Index("sort_order")])
data class LocationEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val admin: String?,
@androidx.room.ColumnInfo(name = "country_code") val countryCode: String,
val lat: Double,
val lon: Double,
val timezone: String,
@androidx.room.ColumnInfo(name = "is_current") val isCurrent: Boolean = false,
@androidx.room.ColumnInfo(name = "sort_order") val sortOrder: Int = 0,
@androidx.room.ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@androidx.room.ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis(),
)
@Entity(
tableName = "forecast_cache",
foreignKeys = [ForeignKey(LocationEntity::class, ["id"], ["location_id"], onDelete = ForeignKey.CASCADE)],
)
data class ForecastCacheEntity(
@PrimaryKey @androidx.room.ColumnInfo(name = "location_id") val locationId: Long,
@androidx.room.ColumnInfo(name = "fetched_at") val fetchedAt: Long,
@androidx.room.ColumnInfo(name = "forecast_json") val forecastJson: String,
@androidx.room.ColumnInfo(name = "air_json") val airJson: String?,
)
@Entity(
tableName = "widget_config",
foreignKeys = [ForeignKey(LocationEntity::class, ["id"], ["location_id"], onDelete = ForeignKey.SET_NULL)],
indices = [Index("location_id")],
)
data class WidgetConfigEntity(
@PrimaryKey @androidx.room.ColumnInfo(name = "app_widget_id") val appWidgetId: Int,
/** null = follow the current location */
@androidx.room.ColumnInfo(name = "location_id") val locationId: Long?,
val style: String = "auto",
)
@Dao
interface WeatherDao {
@Query("SELECT * FROM locations ORDER BY is_current DESC, sort_order, id")
fun locations(): Flow<List<LocationEntity>>
@Query("SELECT * FROM locations ORDER BY is_current DESC, sort_order, id")
suspend fun locationsOnce(): List<LocationEntity>
@Query("SELECT * FROM locations WHERE id = :id")
suspend fun location(id: Long): LocationEntity?
@Query("SELECT * FROM locations WHERE is_current = 1 LIMIT 1")
suspend fun currentLocation(): LocationEntity?
@Insert
suspend fun insert(location: LocationEntity): Long
@androidx.room.Update
suspend fun update(location: LocationEntity)
@Query("DELETE FROM locations WHERE id = :id AND is_current = 0")
suspend fun delete(id: Long)
@Query("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM locations")
suspend fun nextSortOrder(): Int
@Query("UPDATE locations SET sort_order = :order WHERE id = :id")
suspend fun setSortOrder(id: Long, order: Int)
@Transaction
suspend fun reorder(ids: List<Long>) = ids.forEachIndexed { i, id -> setSortOrder(id, i) }
@Query("SELECT * FROM forecast_cache WHERE location_id = :id")
fun forecast(id: Long): Flow<ForecastCacheEntity?>
@Query("SELECT * FROM forecast_cache WHERE location_id = :id")
suspend fun forecastOnce(id: Long): ForecastCacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun putForecast(entity: ForecastCacheEntity)
@Upsert
suspend fun putWidget(config: WidgetConfigEntity)
@Query("SELECT * FROM widget_config WHERE app_widget_id = :id")
suspend fun widget(id: Int): WidgetConfigEntity?
@Query("DELETE FROM widget_config WHERE app_widget_id = :id")
suspend fun deleteWidget(id: Int)
}
@Database(
entities = [LocationEntity::class, ForecastCacheEntity::class, WidgetConfigEntity::class],
version = 1,
exportSchema = true,
)
abstract class WeatherDb : RoomDatabase() {
abstract fun dao(): WeatherDao
companion object {
fun create(context: Context) =
Room.databaseBuilder(context, WeatherDb::class.java, "weather.db").build()
}
}
@@ -0,0 +1,50 @@
package app.wetter.data
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.CancellationSignal
import androidx.core.content.ContextCompat
import androidx.core.location.LocationManagerCompat
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
/** Platform location without Google Play Services. Coarse is enough for weather. */
class DeviceLocation(private val context: Context) {
private val manager = context.getSystemService(LocationManager::class.java)
fun hasPermission() = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
fun isEnabled() = LocationManagerCompat.isLocationEnabled(manager)
@SuppressLint("MissingPermission")
suspend fun get(): Location? {
if (!hasPermission()) return null
val providers = listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.PASSIVE_PROVIDER)
.filter { runCatching { manager.isProviderEnabled(it) }.getOrDefault(false) }
val lastKnown = providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
if (lastKnown != null && System.currentTimeMillis() - lastKnown.time < FRESH_MS) return lastKnown
val provider = providers.firstOrNull { it != LocationManager.PASSIVE_PROVIDER } ?: return lastKnown
val fresh = withTimeoutOrNull(TIMEOUT_MS) {
suspendCancellableCoroutine { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
LocationManagerCompat.getCurrentLocation(manager, provider, signal, ContextCompat.getMainExecutor(context)) {
cont.resume(it)
}
}
}
return fresh ?: lastKnown
}
private companion object {
const val FRESH_MS = 15 * 60 * 1000L
const val TIMEOUT_MS = 12_000L
}
}
@@ -0,0 +1,46 @@
package app.wetter.data
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
import kotlin.math.roundToInt
/** All values come metric from the API and are converted here, never in the UI. */
class Units(val s: Settings) {
fun tempValue(c: Double): Int = (if (s.temp == TempUnit.F) c * 9 / 5 + 32 else c).roundToInt()
fun temp(c: Double): String = "${tempValue(c)}°"
fun windValue(kmh: Double): Int = when (s.wind) {
WindUnit.KMH -> kmh
WindUnit.MS -> kmh / 3.6
WindUnit.MPH -> kmh / 1.609
WindUnit.KN -> kmh / 1.852
}.roundToInt()
val windUnit get() = when (s.wind) { WindUnit.KMH -> "km/h"; WindUnit.MS -> "m/s"; WindUnit.MPH -> "mph"; WindUnit.KN -> "kn" }
fun precip(mm: Double): String = if (s.precip == PrecipUnit.IN) String.format(Locale.getDefault(), "%.2f in", mm / 25.4)
else String.format(Locale.getDefault(), "%.1f mm", mm)
fun pressureValue(hpa: Double): String = if (s.pressure == PressureUnit.INHG)
String.format(Locale.getDefault(), "%.2f", hpa * 0.02953) else String.format(Locale.getDefault(), "%,.0f", hpa)
val pressureUnit get() = if (s.pressure == PressureUnit.INHG) "inHg" else "hPa"
fun distanceValue(m: Double): String {
val v = if (s.distance == DistanceUnit.MI) m / 1609.0 else m / 1000.0
return if (v >= 10) v.roundToInt().toString() else String.format(Locale.getDefault(), "%.1f", v)
}
val distanceUnit get() = if (s.distance == DistanceUnit.MI) "mi" else "km"
}
object Fmt {
private val hour = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
fun time(t: LocalDateTime): String = t.format(hour)
fun hourLabel(t: LocalDateTime): String = t.format(hour)
fun weekdayShort(t: java.time.LocalDate): String = t.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()).trimEnd('.')
fun dayMonth(t: java.time.LocalDate): String = DateTimeFormatter.ofPattern("d.M.", Locale.getDefault()).format(t)
fun longDate(t: java.time.LocalDate): String = t.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG))
fun compass(deg: Int): Int = ((deg % 360 + 360) % 360 + 22) / 45 % 8
}
@@ -0,0 +1,18 @@
package app.wetter.data
import android.content.Context
/** Manual dependency container; one instance per process. */
object Graph {
lateinit var repo: WeatherRepository private set
lateinit var settings: SettingsStore private set
lateinit var device: DeviceLocation private set
fun init(context: Context) {
if (::repo.isInitialized) return
val app = context.applicationContext
device = DeviceLocation(app)
settings = SettingsStore(app)
repo = WeatherRepository(WeatherDb.create(app).dao(), OpenMeteo(), device)
}
}
@@ -0,0 +1,108 @@
package app.wetter.data
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
enum class Condition { CLEAR, PARTLY_CLOUDY, CLOUDY, FOG, DRIZZLE, RAIN, SNOW, SLEET, STORM }
/** WMO weather interpretation codes, as used by Open-Meteo. */
fun conditionOf(code: Int): Condition = when (code) {
0, 1 -> Condition.CLEAR
2 -> Condition.PARTLY_CLOUDY
3 -> Condition.CLOUDY
45, 48 -> Condition.FOG
51, 53, 55 -> Condition.DRIZZLE
56, 57, 66, 67 -> Condition.SLEET
61, 63, 65, 80, 81, 82 -> Condition.RAIN
71, 73, 75, 77, 85, 86 -> Condition.SNOW
95, 96, 99 -> Condition.STORM
else -> Condition.CLOUDY
}
/** 0..1, how heavy the precipitation or storm is, for the scene. */
fun intensityOf(code: Int): Float = when (code) {
51, 61, 71, 80, 85, 56, 66 -> 0.35f
53, 63, 73, 81, 95 -> 0.65f
55, 65, 75, 82, 86, 57, 67, 96, 99 -> 1f
else -> 0.5f
}
data class Current(
val temp: Double,
val feelsLike: Double,
val code: Int,
val isDay: Boolean,
val humidity: Int,
val dewPoint: Double,
val windSpeed: Double,
val windDir: Int,
val gusts: Double,
val pressure: Double,
val visibility: Double,
val uv: Double,
)
data class Hour(
val time: LocalDateTime,
val temp: Double,
val code: Int,
val isDay: Boolean,
val precipProb: Int,
val precip: Double,
val windSpeed: Double,
val humidity: Int,
)
data class Day(
val date: LocalDate,
val code: Int,
val tMin: Double,
val tMax: Double,
val precipProb: Int,
val precipSum: Double,
val sunrise: LocalDateTime,
val sunset: LocalDateTime,
val uvMax: Double,
)
data class Quarter(val time: LocalDateTime, val precip: Double)
data class Air(
val aqi: Int?,
val pm25: Double?,
val pm10: Double?,
val pollen: Map<String, Double>,
)
data class Forecast(
val zone: ZoneId,
val fetchedAt: Long,
val current: Current,
val hourly: List<Hour>,
val daily: List<Day>,
val minutely: List<Quarter>,
val air: Air?,
/** Fixed "now" for previews and screenshot tests. */
val clock: LocalDateTime? = null,
) {
val now: LocalDateTime get() = clock ?: LocalDateTime.now(zone)
val today: Day? get() = daily.firstOrNull()
/** Hours from the current one on. */
fun upcomingHours(count: Int = 24): List<Hour> {
val start = now.withMinute(0).withSecond(0).withNano(0)
return hourly.filter { !it.time.isBefore(start) }.take(count)
}
fun hoursOf(date: LocalDate): List<Hour> = hourly.filter { it.time.toLocalDate() == date }
}
data class Place(
val name: String,
val admin: String?,
val countryCode: String,
val lat: Double,
val lon: Double,
val timezone: String,
)
@@ -0,0 +1,191 @@
package app.wetter.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Locale
import java.util.concurrent.TimeUnit
/** Public, keyless APIs: Open-Meteo (forecast, air quality, geocoding) and Nominatim (reverse geocoding). */
class OpenMeteo(private val http: OkHttpClient = defaultClient()) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun forecastJson(lat: Double, lon: Double): String = get(
"https://api.open-meteo.com/v1/forecast".toHttpUrl().newBuilder()
.addQueryParameter("latitude", coord(lat))
.addQueryParameter("longitude", coord(lon))
.addQueryParameter("current", CURRENT)
.addQueryParameter("hourly", HOURLY)
.addQueryParameter("daily", DAILY)
.addQueryParameter("minutely_15", "precipitation")
.addQueryParameter("forecast_minutely_15", "16")
.addQueryParameter("timezone", "auto")
.addQueryParameter("forecast_days", "10")
.build().toString()
)
suspend fun airJson(lat: Double, lon: Double): String = get(
"https://air-quality-api.open-meteo.com/v1/air-quality".toHttpUrl().newBuilder()
.addQueryParameter("latitude", coord(lat))
.addQueryParameter("longitude", coord(lon))
.addQueryParameter("current", AIR)
.addQueryParameter("timezone", "auto")
.build().toString()
)
suspend fun search(query: String, language: String = Locale.getDefault().language): List<Place> {
val body = get(
"https://geocoding-api.open-meteo.com/v1/search".toHttpUrl().newBuilder()
.addQueryParameter("name", query)
.addQueryParameter("count", "10")
.addQueryParameter("language", language)
.addQueryParameter("format", "json")
.build().toString()
)
val results = json.parseToJsonElement(body).jsonObject["results"] as? JsonArray ?: return emptyList()
return results.map { it.jsonObject }.map {
Place(
name = it.str("name") ?: "",
admin = it.str("admin1"),
countryCode = it.str("country_code") ?: "",
lat = it.num("latitude") ?: 0.0,
lon = it.num("longitude") ?: 0.0,
timezone = it.str("timezone") ?: "UTC",
)
}
}
/** Name for a coordinate. Nominatim allows 1 request/s; we only call it when the position changes. */
suspend fun reverse(lat: Double, lon: Double, language: String = Locale.getDefault().language): String? {
val body = get(
"https://nominatim.openstreetmap.org/reverse".toHttpUrl().newBuilder()
.addQueryParameter("lat", coord(lat))
.addQueryParameter("lon", coord(lon))
.addQueryParameter("format", "jsonv2")
.addQueryParameter("zoom", "10")
.addQueryParameter("accept-language", language)
.build().toString()
)
val address = json.parseToJsonElement(body).jsonObject["address"]?.jsonObject ?: return null
return listOf("city", "town", "village", "municipality", "county")
.firstNotNullOfOrNull { address.str(it) }
}
fun parse(forecastJson: String, airJson: String?, fetchedAt: Long): Forecast {
val root = json.parseToJsonElement(forecastJson).jsonObject
val zone = runCatching { ZoneId.of(root.str("timezone")) }.getOrDefault(ZoneId.systemDefault())
val c = root.getValue("current").jsonObject
val current = Current(
temp = c.num("temperature_2m") ?: 0.0,
feelsLike = c.num("apparent_temperature") ?: 0.0,
code = c.int("weather_code") ?: 3,
isDay = (c.int("is_day") ?: 1) == 1,
humidity = c.int("relative_humidity_2m") ?: 0,
dewPoint = c.num("dew_point_2m") ?: 0.0,
windSpeed = c.num("wind_speed_10m") ?: 0.0,
windDir = c.int("wind_direction_10m") ?: 0,
gusts = c.num("wind_gusts_10m") ?: 0.0,
pressure = c.num("pressure_msl") ?: 0.0,
visibility = c.num("visibility") ?: 0.0,
uv = c.num("uv_index") ?: 0.0,
)
val h = root.getValue("hourly").jsonObject
val hTime = h.arr("time")
val hourly = hTime.indices.map { i ->
Hour(
time = LocalDateTime.parse(hTime[i].jsonPrimitive.content),
temp = h.numAt("temperature_2m", i) ?: 0.0,
code = h.intAt("weather_code", i) ?: 3,
isDay = (h.intAt("is_day", i) ?: 1) == 1,
precipProb = h.intAt("precipitation_probability", i) ?: 0,
precip = h.numAt("precipitation", i) ?: 0.0,
windSpeed = h.numAt("wind_speed_10m", i) ?: 0.0,
humidity = h.intAt("relative_humidity_2m", i) ?: 0,
)
}
val d = root.getValue("daily").jsonObject
val dTime = d.arr("time")
val daily = dTime.indices.map { i ->
Day(
date = LocalDate.parse(dTime[i].jsonPrimitive.content),
code = d.intAt("weather_code", i) ?: 3,
tMin = d.numAt("temperature_2m_min", i) ?: 0.0,
tMax = d.numAt("temperature_2m_max", i) ?: 0.0,
precipProb = d.intAt("precipitation_probability_max", i) ?: 0,
precipSum = d.numAt("precipitation_sum", i) ?: 0.0,
sunrise = LocalDateTime.parse(d.arr("sunrise")[i].jsonPrimitive.content),
sunset = LocalDateTime.parse(d.arr("sunset")[i].jsonPrimitive.content),
uvMax = d.numAt("uv_index_max", i) ?: 0.0,
)
}
val minutely = (root["minutely_15"] as? JsonObject)?.let { m ->
val t = m.arr("time")
t.indices.map { i -> Quarter(LocalDateTime.parse(t[i].jsonPrimitive.content), m.numAt("precipitation", i) ?: 0.0) }
} ?: emptyList()
val air = airJson?.let { runCatching { parseAir(it) }.getOrNull() }
return Forecast(zone, fetchedAt, current, hourly, daily, minutely, air)
}
private fun parseAir(body: String): Air {
val c = json.parseToJsonElement(body).jsonObject.getValue("current").jsonObject
val pollen = POLLEN.mapNotNull { key -> c.num("${key}_pollen")?.let { key to it } }.toMap()
return Air(c.int("european_aqi"), c.num("pm2_5"), c.num("pm10"), pollen)
}
private suspend fun get(url: String): String = withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).header("User-Agent", USER_AGENT).build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
response.body?.string() ?: throw IOException("empty body")
}
}
companion object {
const val USER_AGENT = "Wetter/0.1 (private, non-commercial)"
private const val CURRENT = "temperature_2m,apparent_temperature,weather_code,is_day,relative_humidity_2m," +
"dew_point_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,pressure_msl,visibility,uv_index"
private const val HOURLY = "temperature_2m,weather_code,is_day,precipitation_probability,precipitation," +
"wind_speed_10m,relative_humidity_2m"
private const val DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max," +
"precipitation_sum,sunrise,sunset,uv_index_max"
val POLLEN = listOf("alder", "birch", "grass", "mugwort", "olive", "ragweed")
private val AIR = "european_aqi,pm2_5,pm10," + POLLEN.joinToString(",") { "${it}_pollen" }
/** Two decimals (~1 km): enough for weather, and less precise than the real position. */
private fun coord(v: Double) = String.format(Locale.ROOT, "%.2f", v)
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
}
}
private fun JsonObject.str(key: String) = (this[key] as? JsonElement)?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
private fun JsonObject.num(key: String) = (this[key] as? JsonElement)?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull
private fun JsonObject.int(key: String) = num(key)?.toInt()
private fun JsonObject.arr(key: String): JsonArray = this[key]?.jsonArray ?: JsonArray(emptyList())
private fun JsonObject.numAt(key: String, i: Int) = arr(key).getOrNull(i)?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull
private fun JsonObject.intAt(key: String, i: Int) = numAt(key, i)?.toInt() ?: arr(key).getOrNull(i)?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull
@@ -0,0 +1,83 @@
package app.wetter.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Locale
enum class TempUnit { C, F }
enum class WindUnit { KMH, MS, MPH, KN }
enum class PrecipUnit { MM, IN }
enum class PressureUnit { HPA, INHG }
enum class DistanceUnit { KM, MI }
enum class ThemeMode { SYSTEM, LIGHT, DARK }
data class Settings(
val temp: TempUnit = defaultTemp(),
val wind: WindUnit = WindUnit.KMH,
val precip: PrecipUnit = PrecipUnit.MM,
val pressure: PressureUnit = PressureUnit.HPA,
val distance: DistanceUnit = DistanceUnit.KM,
val theme: ThemeMode = ThemeMode.SYSTEM,
val animations: Boolean = true,
val brief: Boolean = true,
)
private fun defaultTemp() =
if (Locale.getDefault().country in setOf("US", "LR", "MM")) TempUnit.F else TempUnit.C
private val Context.store by preferencesDataStore("settings")
class SettingsStore(private val context: Context) {
private object K {
val temp = stringPreferencesKey("temp")
val wind = stringPreferencesKey("wind")
val precip = stringPreferencesKey("precip")
val pressure = stringPreferencesKey("pressure")
val distance = stringPreferencesKey("distance")
val theme = stringPreferencesKey("theme")
val animations = booleanPreferencesKey("animations")
val brief = booleanPreferencesKey("brief")
}
val settings: Flow<Settings> = context.store.data.map { p ->
val d = Settings()
Settings(
temp = p[K.temp].toEnum(d.temp),
wind = p[K.wind].toEnum(d.wind),
precip = p[K.precip].toEnum(d.precip),
pressure = p[K.pressure].toEnum(d.pressure),
distance = p[K.distance].toEnum(d.distance),
theme = p[K.theme].toEnum(d.theme),
animations = p[K.animations] ?: d.animations,
brief = p[K.brief] ?: d.brief,
)
}
suspend fun update(transform: (Settings) -> Settings) {
context.store.edit { p ->
val d = Settings()
val current = Settings(
p[K.temp].toEnum(d.temp), p[K.wind].toEnum(d.wind), p[K.precip].toEnum(d.precip),
p[K.pressure].toEnum(d.pressure), p[K.distance].toEnum(d.distance), p[K.theme].toEnum(d.theme),
p[K.animations] ?: d.animations, p[K.brief] ?: d.brief,
)
val s = transform(current)
p[K.temp] = s.temp.name
p[K.wind] = s.wind.name
p[K.precip] = s.precip.name
p[K.pressure] = s.pressure.name
p[K.distance] = s.distance.name
p[K.theme] = s.theme.name
p[K.animations] = s.animations
p[K.brief] = s.brief
}
}
}
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { runCatching { enumValueOf<E>(it) }.getOrNull() } ?: default
@@ -0,0 +1,119 @@
package app.wetter.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.math.abs
sealed interface RefreshResult {
data object Ok : RefreshResult
data object Skipped : RefreshResult
data class Failed(val error: Throwable) : RefreshResult
}
class WeatherRepository(
private val dao: WeatherDao,
private val api: OpenMeteo,
private val device: DeviceLocation,
) {
private val currentLock = Mutex()
val locations: Flow<List<LocationEntity>> = dao.locations()
fun forecast(locationId: Long): Flow<Forecast?> = dao.forecast(locationId).map { cache ->
cache?.let { runCatching { api.parse(it.forecastJson, it.airJson, it.fetchedAt) }.getOrNull() }
}
suspend fun forecastOnce(locationId: Long): Forecast? = dao.forecastOnce(locationId)?.let {
runCatching { api.parse(it.forecastJson, it.airJson, it.fetchedAt) }.getOrNull()
}
suspend fun location(id: Long) = dao.location(id)
suspend fun currentLocation() = dao.currentLocation()
suspend fun locationsOnce() = dao.locationsOnce()
suspend fun refresh(location: LocationEntity, force: Boolean = false): RefreshResult {
val cached = dao.forecastOnce(location.id)
if (!force && cached != null && System.currentTimeMillis() - cached.fetchedAt < STALE_MS) return RefreshResult.Skipped
return runCatching {
val forecast = api.forecastJson(location.lat, location.lon)
val air = runCatching { api.airJson(location.lat, location.lon) }.getOrNull()
dao.putForecast(ForecastCacheEntity(location.id, System.currentTimeMillis(), forecast, air))
RefreshResult.Ok
}.getOrElse { RefreshResult.Failed(it) }
}
suspend fun refreshAll(force: Boolean = false) {
updateCurrentLocation()
dao.locationsOnce().forEach { refresh(it, force) }
}
/** Device position -> the single is_current row. Returns null without permission or fix. */
suspend fun updateCurrentLocation(): LocationEntity? = currentLock.withLock {
val fix = device.get() ?: return dao.currentLocation()
val existing = dao.currentLocation()
if (existing != null && abs(existing.lat - fix.latitude) < MOVE_DEG && abs(existing.lon - fix.longitude) < MOVE_DEG) {
return existing
}
val name = runCatching { api.reverse(fix.latitude, fix.longitude) }.getOrNull()
val timezone = java.util.TimeZone.getDefault().id
val row = LocationEntity(
id = existing?.id ?: 0,
name = name ?: existing?.name ?: "",
admin = null,
countryCode = "",
lat = fix.latitude,
lon = fix.longitude,
timezone = timezone,
isCurrent = true,
sortOrder = -1,
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
)
if (existing == null) row.copy(id = dao.insert(row)) else row.also { dao.update(it) }
}
suspend fun search(query: String): List<Place> = api.search(query.trim())
/** Full forecast for a place that is not saved yet (S15). */
suspend fun preview(place: Place): Forecast {
val forecast = api.forecastJson(place.lat, place.lon)
val air = runCatching { api.airJson(place.lat, place.lon) }.getOrNull()
return api.parse(forecast, air, System.currentTimeMillis())
}
suspend fun save(place: Place): Long {
val existing = dao.locationsOnce().firstOrNull {
!it.isCurrent && abs(it.lat - place.lat) < DUPE_DEG && abs(it.lon - place.lon) < DUPE_DEG
}
if (existing != null) return existing.id
val id = dao.insert(
LocationEntity(
name = place.name, admin = place.admin, countryCode = place.countryCode,
lat = place.lat, lon = place.lon, timezone = place.timezone, sortOrder = dao.nextSortOrder(),
)
)
dao.location(id)?.let { refresh(it, force = true) }
return id
}
suspend fun delete(id: Long) = dao.delete(id)
suspend fun reorder(ids: List<Long>) = dao.reorder(ids)
suspend fun widgetLocation(appWidgetId: Int): LocationEntity? {
val config = dao.widget(appWidgetId)
return config?.locationId?.let { dao.location(it) } ?: dao.currentLocation() ?: dao.locationsOnce().firstOrNull()
}
suspend fun setWidgetLocation(appWidgetId: Int, locationId: Long?) =
dao.putWidget(WidgetConfigEntity(appWidgetId, locationId))
suspend fun deleteWidget(appWidgetId: Int) = dao.deleteWidget(appWidgetId)
companion object {
const val STALE_MS = 30 * 60 * 1000L
const val OLD_MS = 3 * 60 * 60 * 1000L
private const val MOVE_DEG = 0.02
private const val DUPE_DEG = 0.01
}
}
@@ -0,0 +1,347 @@
package app.wetter.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Air
import androidx.compose.material.icons.outlined.Compress
import androidx.compose.material.icons.outlined.FilterVintage
import androidx.compose.material.icons.outlined.LightMode
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.WaterDrop
import androidx.compose.material.icons.outlined.WbTwilight
import androidx.compose.material.icons.outlined.Masks
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import app.wetter.R
import app.wetter.data.Air
import app.wetter.data.Current
import app.wetter.data.Day
import app.wetter.data.Fmt
import app.wetter.data.OpenMeteo
import app.wetter.data.Units
import app.wetter.ui.theme.LocalWeatherColors
import app.wetter.ui.theme.WColor
import app.wetter.ui.theme.WRadius
import app.wetter.ui.theme.WSpace
import app.wetter.ui.theme.WType
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/** Two-column grid of shaped tiles: circles on the left, rounded squares on the right. */
@Composable
fun DetailGrid(current: Current, day: Day?, air: Air?, units: Units, now: LocalDateTime, modifier: Modifier = Modifier) {
val tiles = buildList<@Composable (Modifier) -> Unit> {
add { WindTile(current, units, it) }
if (day != null) add { SunTile(day, now, it) }
add { UvTile(current.uv, it) }
if (air?.aqi != null) add { AqiTile(air.aqi, it) }
add { VisibilityTile(current.visibility, units, it) }
add { HumidityTile(current.humidity, current.dewPoint, units, it) }
add { PressureTile(current.pressure, units, it) }
if (air != null && air.pollen.isNotEmpty()) add { PollenTile(air.pollen, it) }
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(WSpace.s12)) {
tiles.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(WSpace.s12)) {
row.forEach { tile -> tile(Modifier.weight(1f).aspectRatio(1f)) }
if (row.size == 1) Spacer(Modifier.weight(1f))
}
}
}
}
@Composable
private fun Tile(
title: String,
icon: ImageVector,
shape: Shape,
description: String,
modifier: Modifier,
centered: Boolean,
background: (androidx.compose.ui.graphics.drawscope.DrawScope.() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit,
) {
val c = LocalWeatherColors.current
Box(modifier.clip(shape).background(c.card).clearAndSetSemantics { contentDescription = description }) {
if (background != null) Canvas(Modifier.fillMaxSize(), onDraw = background)
content()
Row(
Modifier.align(if (centered) Alignment.TopCenter else Alignment.TopStart)
.padding(top = if (centered) 28.dp else WSpace.s16, start = if (centered) 0.dp else WSpace.s16),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, tint = c.onCard, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(WSpace.s4))
Text(title, style = WType.label, color = c.onCard)
}
}
}
@Composable
private fun ValueText(value: String, unit: String?, color: Color, modifier: Modifier = Modifier) {
Text(
buildAnnotatedString {
append(value)
if (unit != null) withStyle(SpanStyle(fontSize = WType.title.fontSize)) { append(" $unit") }
},
style = WType.metric.copy(fontSize = WType.metric.fontSize * 1.25f), color = color, modifier = modifier,
)
}
@Composable
fun WindTile(cur: Current, units: Units, modifier: Modifier) {
val c = LocalWeatherColors.current
val res = LocalContext.current.resources
val from = compassLabel(res, cur.windDir)
val value = units.windValue(cur.windSpeed).toString()
val title = stringResource(R.string.card_wind)
Tile(title, Icons.Outlined.Air, CircleShape, "$title $value ${units.windUnit}, ${stringResource(R.string.wind_from, from)}", modifier, centered = true) {
// the blob points where the wind blows to
Box(Modifier.fillMaxSize(.78f).align(Alignment.Center).clip(WShapes.triangle(cur.windDir + 180f + 90f)).background(c.fillSecondary))
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(WSpace.s12))
ValueText(value, units.windUnit, c.onFillSecondary)
Text(stringResource(R.string.wind_from, from), style = WType.label, color = c.onFillSecondary)
}
}
}
@Composable
fun SunTile(day: Day, now: LocalDateTime, modifier: Modifier) {
val c = LocalWeatherColors.current
val title = stringResource(R.string.card_sun)
val dayLen = ChronoUnit.MINUTES.between(day.sunrise, day.sunset).toFloat()
val progress = (ChronoUnit.MINUTES.between(day.sunrise, now) / dayLen).coerceIn(-0.25f, 1.25f)
val hill = c.fillPrimary
val night = c.onCard.copy(alpha = .08f)
Tile(
title, Icons.Outlined.WbTwilight, RoundedCornerShape(WRadius.card),
"$title, ↑ ${Fmt.time(day.sunrise)}, ↓ ${Fmt.time(day.sunset)}", modifier, centered = false,
background = {
val horizon = size.height * .62f
val amp = size.height * .2f
fun y(x: Float) = horizon + amp * cos((x / size.width) * 2 * PI.toFloat())
val curve = Path().apply {
moveTo(0f, y(0f))
var x = 0f
while (x <= size.width) { lineTo(x, y(x)); x += 4f }
}
val fill = Path().apply { addPath(curve); lineTo(size.width, size.height); lineTo(0f, size.height); close() }
clipRect(bottom = horizon) { drawPath(fill, hill) }
drawRect(night, Offset(0f, horizon), Size(size.width, size.height - horizon))
drawLine(c.onCardMuted.copy(alpha = .5f), Offset(0f, horizon), Offset(size.width, horizon), 2f)
// map day progress 0..1 onto the hump between x=.25w and .75w
val sx = size.width * (.25f + progress * .5f)
drawCircle(WColor.iconSun, size.minDimension * .045f, Offset(sx, y(sx)))
},
) {
Column(Modifier.align(Alignment.BottomCenter).padding(bottom = WSpace.s12), horizontalAlignment = Alignment.CenterHorizontally) {
Text("↑ ${Fmt.time(day.sunrise)}", style = WType.label, color = c.onCard)
Text("↓ ${Fmt.time(day.sunset)}", style = WType.label, color = c.onCard)
}
}
}
@Composable
fun UvTile(uv: Double, modifier: Modifier) {
val c = LocalWeatherColors.current
val level = uvLevel(uv)
val levelText = stringArrayResource(R.array.uv_level)[level]
val title = stringResource(R.string.card_uv)
val value = uv.toInt().toString()
Tile(
title, Icons.Outlined.LightMode, WShapes.cookie12, "$title $value, $levelText", modifier, centered = true,
background = {
// five dots on a lower arc, the active band lit
val r = size.minDimension * .34f
val cx = size.width / 2
val cy = size.height / 2
for (i in 0..4) {
val ang = (PI - (PI * .2) - i * (PI * .6 / 4)).toFloat() // 144°..36° below centre
val p = Offset(cx + r * cos(ang), cy + r * sin(ang))
drawCircle(if (i == level) (if (level >= 2) WColor.uvHigh else WColor.uvDotLow) else WColor.uvDotOff.copy(alpha = .45f), size.minDimension * .04f, p)
}
},
) {
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
ValueText(value, null, c.onCard)
Text(levelText, style = WType.label, color = c.onCard)
}
}
}
@Composable
fun AqiTile(aqi: Int?, modifier: Modifier) {
val c = LocalWeatherColors.current
val title = stringResource(R.string.card_aqi)
val levelText = aqi?.let { stringArrayResource(R.array.aqi_level)[aqiLevel(it)] } ?: stringResource(R.string.no_data)
val bar = listOf(WColor.aqiBar1, WColor.aqiBar2, WColor.aqiBar3, WColor.aqiBar4, WColor.aqiBar5, WColor.aqiBar6)
val knob = c.onCard
Tile(
title, Icons.Outlined.Masks, RoundedCornerShape(WRadius.card), "$title ${aqi ?: ""}, $levelText", modifier, centered = false,
) {
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(WSpace.s16), horizontalAlignment = Alignment.End) {
ValueText(aqi?.toString() ?: stringResource(R.string.no_data), null, c.onCard)
Spacer(Modifier.height(WSpace.s12))
Canvas(Modifier.fillMaxWidth().height(14.dp)) {
val y = size.height / 2
drawLine(Brush.horizontalGradient(bar), Offset(0f, y), Offset(size.width, y), size.height * .5f, StrokeCap.Round)
if (aqi != null) {
val x = (aqi / 120f).coerceIn(0f, 1f) * size.width
drawCircle(knob, size.height * .55f, Offset(x, y))
drawCircle(bar[aqiLevel(aqi)], size.height * .38f, Offset(x, y))
}
}
Spacer(Modifier.height(WSpace.s8))
Text(levelText, style = WType.label, color = c.onCard)
}
}
}
@Composable
fun VisibilityTile(meters: Double, units: Units, modifier: Modifier) {
val c = LocalWeatherColors.current
val title = stringResource(R.string.card_visibility)
val value = units.distanceValue(meters)
val fill = c.fillTertiary
Tile(
title, Icons.Outlined.Visibility, CircleShape, "$title $value ${units.distanceUnit}", modifier, centered = true,
background = {
drawPath(wavyCircle(center, size.minDimension * .40f, 14, size.minDimension * .018f), fill)
},
) {
Box(Modifier.align(Alignment.Center).padding(top = WSpace.s16)) {
ValueText(value, units.distanceUnit, c.onFillTertiary)
}
}
}
@Composable
fun HumidityTile(humidity: Int, dewPoint: Double, units: Units, modifier: Modifier) {
val c = LocalWeatherColors.current
val title = stringResource(R.string.card_humidity)
val dew = stringResource(R.string.dew_point)
val fill = c.fillTertiary
Tile(
title, Icons.Outlined.WaterDrop, RoundedCornerShape(WRadius.card),
"$title $humidity %, $dew ${units.temp(dewPoint)}", modifier, centered = false,
background = {
val level = size.height * (1f - humidity / 100f * .8f)
val amp = size.height * .025f
val path = Path().apply {
moveTo(0f, level)
var x = 0f
while (x <= size.width) {
lineTo(x, level + amp * sin(x / size.width * 4 * PI.toFloat()))
x += 4f
}
lineTo(size.width, size.height); lineTo(0f, size.height); close()
}
drawPath(path, fill)
},
) {
Column(Modifier.align(Alignment.BottomStart).padding(WSpace.s16)) {
ValueText("$humidity", "%", c.onFillTertiary)
Spacer(Modifier.height(WSpace.s8))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(30.dp).clip(CircleShape).background(c.onFillTertiary.copy(alpha = .15f)), contentAlignment = Alignment.Center) {
Text(units.temp(dewPoint), style = WType.labelSm, color = c.onFillTertiary)
}
Spacer(Modifier.width(WSpace.s8))
Text(dew, style = WType.label, color = c.onFillTertiary)
}
}
}
}
@Composable
fun PressureTile(hpa: Double, units: Units, modifier: Modifier) {
val c = LocalWeatherColors.current
val title = stringResource(R.string.card_pressure)
val value = units.pressureValue(hpa)
val track = c.onCard.copy(alpha = .15f)
val active = c.fillPrimary
Tile(
title, Icons.Outlined.Compress, CircleShape, "$title $value ${units.pressureUnit}", modifier, centered = true,
background = {
val stroke = size.minDimension * .045f
val inset = stroke * 1.4f
val arcSize = Size(size.width - inset * 2, size.height - inset * 2)
val frac = ((hpa - 960) / 90).toFloat().coerceIn(0f, 1f)
drawArc(track, 135f, 270f, false, Offset(inset, inset), arcSize, style = Stroke(stroke, cap = StrokeCap.Round))
drawArc(active, 135f, 270f * frac, false, Offset(inset, inset), arcSize, style = Stroke(stroke, cap = StrokeCap.Round))
},
) {
Column(Modifier.align(Alignment.Center).padding(top = WSpace.s16), horizontalAlignment = Alignment.CenterHorizontally) {
ValueText(value, null, c.onCard)
Text(units.pressureUnit, style = WType.label, color = c.onCard)
}
}
}
@Composable
fun PollenTile(pollen: Map<String, Double>, modifier: Modifier) {
val c = LocalWeatherColors.current
val title = stringResource(R.string.card_pollen)
val names = stringArrayResource(R.array.pollen_names)
val top = pollen.entries.filter { it.value >= 1 }.sortedByDescending { it.value }.take(3)
val label = { key: String -> names.getOrElse(OpenMeteo.POLLEN.indexOf(key)) { key } }
val desc = if (top.isEmpty()) stringResource(R.string.pollen_none) else top.joinToString { label(it.key) }
Tile(title, Icons.Outlined.FilterVintage, RoundedCornerShape(WRadius.card), "$title: $desc", modifier, centered = false) {
Column(Modifier.align(Alignment.BottomStart).padding(WSpace.s16), verticalArrangement = Arrangement.spacedBy(WSpace.s8)) {
if (top.isEmpty()) Text(stringResource(R.string.pollen_none), style = WType.bodySm, color = c.onCard)
top.forEach { (key, v) ->
// grains/m³: <10 low, <50 medium, else high
val level = when { v < 10 -> 1; v < 50 -> 2; else -> 3 }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label(key), style = WType.bodySm, color = c.onCard, modifier = Modifier.width(72.dp))
repeat(3) { i ->
Box(Modifier.padding(end = 4.dp).size(10.dp).clip(CircleShape).background(if (i < level) c.fillPrimary else c.onCard.copy(alpha = .15f)))
}
}
}
}
}
}
@@ -0,0 +1,80 @@
package app.wetter.ui.components
import android.content.res.Resources
import androidx.annotation.StringRes
import app.wetter.R
import app.wetter.data.Condition
import app.wetter.data.Forecast
import app.wetter.data.Fmt
import app.wetter.data.Units
import kotlin.math.roundToInt
@StringRes
fun conditionLabel(condition: Condition, isDay: Boolean): Int = when (condition) {
Condition.CLEAR -> if (isDay) R.string.cond_clear_day else R.string.cond_clear_night
Condition.PARTLY_CLOUDY -> R.string.cond_partly
Condition.CLOUDY -> R.string.cond_cloudy
Condition.FOG -> R.string.cond_fog
Condition.DRIZZLE -> R.string.cond_drizzle
Condition.RAIN -> R.string.cond_rain
Condition.SNOW -> R.string.cond_snow
Condition.SLEET -> R.string.cond_sleet
Condition.STORM -> R.string.cond_storm
}
/** Rule-based summary in place of an AI report: what happens next, and how tomorrow compares. */
fun buildBrief(res: Resources, f: Forecast, units: Units): String {
val parts = mutableListOf<String>()
val now = f.now
val soon = f.minutely.filter { !it.time.isBefore(now.minusMinutes(15)) }.take(12)
if (soon.firstOrNull()?.let { it.precip >= 0.1 } == true || isWet(app.wetter.data.conditionOf(f.current.code))) {
val dryAt = soon.firstOrNull { it.precip < 0.1 }?.time
parts += if (dryAt != null) res.getString(R.string.brief_wet_now_until, Fmt.time(dryAt)) else res.getString(R.string.brief_wet_now)
}
val hours = f.upcomingHours(18)
val wet = if (parts.isNotEmpty()) null else hours.drop(1).firstOrNull { it.precipProb >= 50 && it.precip >= 0.2 }
if (parts.isNotEmpty()) {
// already said
} else if (wet != null) {
val c = app.wetter.data.conditionOf(wet.code)
val at = wet.time.hour.toString()
parts += when (c) {
Condition.SNOW, Condition.SLEET -> res.getString(R.string.brief_snow_from, at)
Condition.STORM -> res.getString(R.string.brief_storm_from, at)
else -> res.getString(R.string.brief_rain_from, at)
}
} else {
parts += res.getString(if (f.now.hour < 15) R.string.brief_dry else R.string.brief_dry_rest)
}
val today = f.daily.getOrNull(0)
val tomorrow = f.daily.getOrNull(1)
if (today != null && tomorrow != null) {
val diff = units.tempValue(tomorrow.tMax) - units.tempValue(today.tMax)
parts += when {
diff >= 3 -> res.getString(R.string.brief_warmer, diff)
diff <= -3 -> res.getString(R.string.brief_colder, -diff)
else -> res.getString(R.string.brief_same)
}
}
if (f.current.gusts >= 50) parts += res.getString(R.string.brief_wind, "${units.windValue(f.current.gusts)} ${units.windUnit}")
val uvHours = f.hoursOf(f.now.toLocalDate())
if ((today?.uvMax ?: 0.0) >= 6 && f.current.isDay && uvHours.isNotEmpty()) {
parts += res.getString(R.string.brief_uv, "11", "16")
}
return parts.joinToString(" ")
}
fun uvLevel(uv: Double): Int = when {
uv < 3 -> 0
uv < 6 -> 1
uv < 8 -> 2
uv < 11 -> 3
else -> 4
}
/** European AQI bands: 0-20, 20-40, 40-60, 60-80, 80-100, >100. */
fun aqiLevel(aqi: Int): Int = (aqi / 20).coerceIn(0, 5)
fun minutesAgo(fetchedAt: Long): Int = ((System.currentTimeMillis() - fetchedAt) / 60_000.0).roundToInt()
fun compassLabel(res: Resources, deg: Int): String = res.getStringArray(R.array.compass)[Fmt.compass(deg)]
@@ -0,0 +1,312 @@
package app.wetter.ui.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.Notes
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Umbrella
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.wetter.R
import app.wetter.data.Condition
import app.wetter.data.Day
import app.wetter.data.Fmt
import app.wetter.data.Forecast
import app.wetter.data.Hour
import app.wetter.data.Quarter
import app.wetter.data.Units
import app.wetter.data.conditionOf
import app.wetter.ui.scene.WeatherIcon
import app.wetter.ui.theme.LocalWeatherColors
import app.wetter.ui.theme.WRadius
import app.wetter.ui.theme.WSpace
import app.wetter.ui.theme.WType
import java.time.LocalDate
@Composable
fun SectionCard(
title: String,
icon: ImageVector,
modifier: Modifier = Modifier,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val c = LocalWeatherColors.current
Column(
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(WRadius.card))
.background(c.card)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = WSpace.s20)
.animateContentSize()
) {
Row(Modifier.padding(horizontal = WSpace.s20), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = c.onCard, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(WSpace.s12))
Text(title, style = WType.cardTitle, color = c.onCard, modifier = Modifier.weight(1f).semantics { heading() })
trailing?.invoke()
}
Spacer(Modifier.height(WSpace.s16))
content()
}
}
@Composable
fun HeroBlock(
place: String,
forecast: Forecast,
units: Units,
textColor: Color,
modifier: Modifier = Modifier,
dayOverride: Day? = null,
) {
val cur = forecast.current
val condition = conditionOf(cur.code)
val today = forecast.today
val conditionText = stringResource(conditionLabel(condition, cur.isDay))
val feels = stringResource(R.string.feels_like, units.temp(cur.feelsLike))
val hl = today?.let { stringResource(R.string.high_low, units.temp(it.tMax), units.temp(it.tMin)) } ?: ""
val summary = "$place, ${units.temp(cur.temp)}, $conditionText, $feels, $hl"
Column(
modifier.fillMaxWidth().padding(vertical = WSpace.s24).clearAndSetSemantics { contentDescription = summary },
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(conditionText, style = WType.heroCondition, color = textColor)
Spacer(Modifier.height(WSpace.s8))
Row(verticalAlignment = Alignment.Top) {
Text(
"${units.tempValue(cur.temp)}",
style = WType.display.copy(letterSpacing = (-4).sp()),
color = textColor,
)
WeatherIcon(condition, cur.isDay, 64.dp, Modifier.padding(top = WSpace.s12))
}
Spacer(Modifier.height(WSpace.s8))
Text(feels, style = WType.title, color = textColor)
Spacer(Modifier.height(WSpace.s4))
Text(hl, style = WType.bodySm.copy(fontWeight = FontWeight.Medium), color = textColor)
}
}
private fun Int.sp() = androidx.compose.ui.unit.TextUnit(this.toFloat(), androidx.compose.ui.unit.TextUnitType.Sp)
@Composable
fun HourlyStrip(hours: List<Hour>, units: Units, modifier: Modifier = Modifier, markNow: Boolean = true) {
val c = LocalWeatherColors.current
SectionCard(stringResource(R.string.hourly), Icons.Outlined.Schedule, modifier) {
LazyRow(
contentPadding = PaddingValues(horizontal = WSpace.s16),
horizontalArrangement = Arrangement.spacedBy(WSpace.s4),
) {
itemsIndexed(hours) { i, h ->
val isNow = markNow && i == 0
val cond = conditionOf(h.code)
val label = if (isNow) stringResource(R.string.now) else Fmt.hourLabel(h.time)
val condText = stringResource(conditionLabel(cond, h.isDay))
Column(
Modifier.widthIn(min = 52.dp).semantics(mergeDescendants = true) {
contentDescription = "$label, ${units.temp(h.temp)}, $condText, ${h.precipProb} %"
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
if (isNow) Box(Modifier.size(38.dp).clip(WShapes.cookie8).background(c.nowChip))
Text(units.temp(h.temp), style = WType.bodySm.copy(fontWeight = FontWeight.Medium), color = if (isNow) c.onNowChip else c.onCard)
}
Spacer(Modifier.height(WSpace.s8))
WeatherIcon(cond, h.isDay, 28.dp)
Spacer(Modifier.height(WSpace.s4))
Text(
if (h.precipProb >= 10) "${h.precipProb} %" else " ",
style = WType.labelSm, color = c.precipText,
)
Spacer(Modifier.height(WSpace.s4))
Text(label, style = WType.label, color = c.onCardMuted)
}
}
}
}
}
@Composable
fun DailyCapsules(
days: List<Day>,
units: Units,
modifier: Modifier = Modifier,
selected: LocalDate? = null,
asSection: Boolean = true,
onDay: (Day) -> Unit,
) {
val c = LocalWeatherColors.current
val today = days.firstOrNull()?.date
val row = @Composable {
LazyRow(
contentPadding = PaddingValues(horizontal = WSpace.s16),
horizontalArrangement = Arrangement.spacedBy(WSpace.s8),
) {
itemsIndexed(days) { _, d ->
val isSel = d.date == selected
val cond = conditionOf(d.code)
val name = if (d.date == today) stringResource(R.string.today) else Fmt.weekdayShort(d.date)
val condText = stringResource(conditionLabel(cond, true))
val fg = if (isSel) c.onPillSelected else c.onCard
Column(
Modifier
.width(60.dp)
.clip(RoundedCornerShape(WRadius.dayPill * 2))
.background(if (isSel) c.pillSelected else c.pill)
.clickable { onDay(d) }
.padding(vertical = WSpace.s16)
.semantics(mergeDescendants = true) {
contentDescription = "$name, $condText, ${units.temp(d.tMin)} bis ${units.temp(d.tMax)}, ${d.precipProb} %"
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(units.temp(d.tMax), style = WType.bodySm.copy(fontWeight = FontWeight.Medium), color = fg)
Text(units.temp(d.tMin), style = WType.bodySm, color = if (isSel) fg else c.onCardMuted)
Spacer(Modifier.height(WSpace.s16))
WeatherIcon(cond, true, 34.dp)
Spacer(Modifier.height(WSpace.s8))
Text(if (d.precipProb >= 10) "${d.precipProb} %" else " ", style = WType.labelSm, color = if (isSel) fg else c.precipText)
Spacer(Modifier.height(WSpace.s12))
Text(name, style = WType.label, color = fg, textAlign = TextAlign.Center)
Text(Fmt.dayMonth(d.date), style = WType.labelSm, color = if (isSel) fg else c.onCardMuted)
}
}
}
}
if (asSection) SectionCard(stringResource(R.string.daily), Icons.Outlined.CalendarMonth, modifier) { row() }
else Box(modifier) { row() }
}
@Composable
fun BriefCard(text: String, modifier: Modifier = Modifier) {
val c = LocalWeatherColors.current
var open by rememberSaveable { mutableStateOf(true) }
SectionCard(
stringResource(R.string.brief_title), Icons.Outlined.Notes, modifier,
trailing = { Icon(if (open) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, null, tint = c.onCard) },
onClick = { open = !open },
) {
if (open) Text(text, style = WType.body, color = c.onCard, modifier = Modifier.padding(horizontal = WSpace.s20))
}
}
/** Shows only when precipitation falls within the next ~3 hours (15-minute steps). */
@Composable
fun NowcastCard(quarters: List<Quarter>, forecast: Forecast, modifier: Modifier = Modifier) {
val c = LocalWeatherColors.current
val now = forecast.now
val upcoming = quarters.filter { !it.time.isBefore(now.minusMinutes(15)) }.take(12)
val firstWet = upcoming.indexOfFirst { it.precip >= 0.1 }
if (firstWet < 0) return
val lastWet = upcoming.indexOfLast { it.precip >= 0.1 }
val peak = upcoming.maxOf { it.precip }
val levelText = stringResource(
when {
peak >= 2.5 -> R.string.precip_heavy
peak >= 0.6 -> R.string.precip_moderate
else -> R.string.precip_light
}
)
val start = Fmt.time(upcoming[firstWet].time)
val end = Fmt.time(upcoming[lastWet].time.plusMinutes(15))
val title = if (firstWet == 0) stringResource(R.string.nowcast_title_ongoing, end) else stringResource(R.string.nowcast_title_rain, start)
val primary = MaterialTheme.colorScheme.primary
SectionCard(title, Icons.Outlined.Umbrella, modifier) {
Column(Modifier.padding(horizontal = WSpace.s20)) {
Text(stringResource(R.string.nowcast_sub, levelText, start, end), style = WType.bodySm, color = c.onCardMuted)
Spacer(Modifier.height(WSpace.s16))
Row {
Column(Modifier.height(96.dp), verticalArrangement = Arrangement.SpaceBetween) {
listOf(R.string.nowcast_heavy, R.string.nowcast_moderate, R.string.nowcast_light).forEach {
Text(stringResource(it), style = WType.labelSm, color = c.onCardMuted)
}
}
Spacer(Modifier.width(WSpace.s12))
Canvas(Modifier.weight(1f).height(96.dp)) {
val n = upcoming.size
val step = size.width / n
val dot = (step * .62f).coerceAtMost(size.height / 6)
upcoming.forEachIndexed { i, q ->
val level = when {
q.precip >= 2.5 -> 3
q.precip >= 0.6 -> 2
q.precip >= 0.1 -> 1
else -> 0
}
val x = step * i + step / 2
if (level == 0) {
drawCircle(c.onCardMuted.copy(alpha = .25f), dot * .18f, Offset(x, size.height - dot / 2))
} else {
val top = size.height - size.height * level / 3f + dot / 2
drawLine(primary, Offset(x, size.height - dot / 2), Offset(x, top), dot, androidx.compose.ui.graphics.StrokeCap.Round)
}
}
}
}
Spacer(Modifier.height(WSpace.s8))
Row(Modifier.fillMaxWidth().padding(start = 56.dp), horizontalArrangement = Arrangement.SpaceBetween) {
listOf(0, upcoming.size / 2, upcoming.size - 1).forEach {
Text(Fmt.time(upcoming[it].time), style = WType.labelSm, color = c.onCardMuted)
}
}
}
}
}
@Composable
fun StatusChip(text: String, color: Color, modifier: Modifier = Modifier) {
Text(
text, style = WType.label, color = color,
modifier = modifier
.clip(RoundedCornerShape(WRadius.pill))
.background(color.copy(alpha = .14f))
.padding(horizontal = WSpace.s12, vertical = WSpace.s4),
)
}
internal fun isWet(c: Condition) = c in setOf(Condition.RAIN, Condition.DRIZZLE, Condition.SNOW, Condition.SLEET, Condition.STORM)
@@ -0,0 +1,55 @@
package app.wetter.ui.components
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.graphics.shapes.CornerRounding
import androidx.graphics.shapes.RoundedPolygon
import androidx.graphics.shapes.star
import androidx.graphics.shapes.toPath
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/** Any RoundedPolygon as a Compose Shape, scaled to the layout size. */
class PolygonShape(polygon: RoundedPolygon, private val rotation: Float = 0f) : Shape {
private val normalized = polygon.normalized()
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val path = normalized.toPath().asComposePath()
val m = Matrix()
if (rotation != 0f) {
m.translate(size.width / 2, size.height / 2)
m.rotateZ(rotation)
m.translate(-size.width / 2, -size.height / 2)
}
m.scale(size.width, size.height)
path.transform(m)
return Outline.Generic(path)
}
}
object WShapes {
val cookie12 = PolygonShape(RoundedPolygon.star(12, innerRadius = .86f, rounding = CornerRounding(.18f)))
val cookie8 = PolygonShape(RoundedPolygon.star(8, innerRadius = .80f, rounding = CornerRounding(.25f)))
fun triangle(rotation: Float) = PolygonShape(RoundedPolygon(3, rounding = CornerRounding(.32f)), rotation)
}
/** Circle whose radius is modulated by a sine: the "wavy" M3 Expressive edge. */
fun wavyCircle(center: Offset, radius: Float, waves: Int, amplitude: Float): Path = Path().apply {
val steps = 180
for (i in 0..steps) {
val a = 2 * PI * i / steps
val r = radius + amplitude * sin(waves * a).toFloat()
val x = center.x + r * cos(a).toFloat()
val y = center.y + r * sin(a).toFloat()
if (i == 0) moveTo(x, y) else lineTo(x, y)
}
close()
}
@@ -0,0 +1,148 @@
package app.wetter.ui.scene
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import app.wetter.data.Condition
import app.wetter.ui.theme.WColor
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/** Own weather glyphs, drawn from primitives so they scale and render in widgets too. */
@Composable
fun WeatherIcon(condition: Condition, isDay: Boolean, size: Dp, modifier: Modifier = Modifier, description: String? = null) {
Canvas(
modifier
.size(size)
.then(if (description != null) Modifier.semantics { contentDescription = description } else Modifier)
) { drawWeatherIcon(condition, isDay) }
}
fun DrawScope.drawWeatherIcon(condition: Condition, isDay: Boolean) {
val s = size.minDimension
when (condition) {
Condition.CLEAR -> if (isDay) sun(Offset(s * .5f, s * .5f), s * .30f) else moon(Offset(s * .5f, s * .5f), s * .32f)
Condition.PARTLY_CLOUDY -> {
if (isDay) sun(Offset(s * .62f, s * .36f), s * .22f) else moon(Offset(s * .62f, s * .34f), s * .22f)
cloud(Offset(s * .44f, s * .62f), s * .62f)
}
Condition.CLOUDY -> {
cloud(Offset(s * .60f, s * .42f), s * .46f, WColor.iconCloudShade)
cloud(Offset(s * .44f, s * .60f), s * .66f)
}
Condition.FOG -> {
cloud(Offset(s * .5f, s * .42f), s * .62f, WColor.iconCloudShade)
for (i in 0..2) {
val y = s * (.66f + i * .11f)
drawLine(WColor.iconFog, Offset(s * (.18f + i * .06f), y), Offset(s * (.82f - i * .04f), y), s * .06f, StrokeCap.Round)
}
}
Condition.DRIZZLE, Condition.RAIN -> {
cloud(Offset(s * .5f, s * .40f), s * .70f, if (condition == Condition.RAIN) WColor.iconCloudDark else WColor.iconCloudShade)
val n = if (condition == Condition.RAIN) 3 else 2
for (i in 0 until n) drop(Offset(s * (.32f + i * (.36f / (n - 1).coerceAtLeast(1))), s * (.74f + (i % 2) * .08f)), s * .11f)
}
Condition.SNOW -> {
cloud(Offset(s * .5f, s * .40f), s * .70f, WColor.iconCloudShade)
for (i in 0..2) flake(Offset(s * (.30f + i * .2f), s * (.76f + (i % 2) * .08f)), s * .08f)
}
Condition.SLEET -> {
cloud(Offset(s * .5f, s * .40f), s * .70f, WColor.iconCloudShade)
drop(Offset(s * .34f, s * .76f), s * .1f)
flake(Offset(s * .62f, s * .80f), s * .08f)
}
Condition.STORM -> {
cloud(Offset(s * .5f, s * .38f), s * .72f, WColor.iconCloudDark)
bolt(Offset(s * .52f, s * .58f), s * .34f)
}
}
}
private fun DrawScope.sun(c: Offset, r: Float) {
// a soft 12-bump "cookie" sun, filled, no rays
val path = Path()
val bumps = 12
val steps = 96
for (i in 0..steps) {
val a = 2 * PI * i / steps
val rr = r * (1f + 0.07f * cos(bumps * a).toFloat())
val p = Offset(c.x + rr * cos(a).toFloat(), c.y + rr * sin(a).toFloat())
if (i == 0) path.moveTo(p.x, p.y) else path.lineTo(p.x, p.y)
}
path.close()
drawPath(path, WColor.iconSun)
drawCircle(WColor.iconSunCore, r * .62f, c, alpha = .35f)
}
private fun DrawScope.moon(c: Offset, r: Float) {
val path = Path().apply {
addOval(androidx.compose.ui.geometry.Rect(c, r))
}
val cut = Path().apply {
addOval(androidx.compose.ui.geometry.Rect(Offset(c.x + r * .55f, c.y - r * .35f), r * .9f))
}
val crescent = Path().apply { op(path, cut, androidx.compose.ui.graphics.PathOperation.Difference) }
drawPath(crescent, WColor.iconMoon)
}
/** Cloud from three circles and a rounded base; width w centred on c. */
private fun DrawScope.cloud(c: Offset, w: Float, color: Color = WColor.iconCloud) {
val h = w * .5f
translate(c.x - w / 2, c.y - h / 2) {
// soft underside so white clouds still read on light cards
val shade = WColor.iconCloudShade
drawCircle(shade, h * .42f, Offset(w * .28f, h * .70f))
drawCircle(shade, h * .38f, Offset(w * .78f, h * .74f))
drawRoundRect(shade, Offset(w * .1f, h * .66f), Size(w * .8f, h * .42f), androidx.compose.ui.geometry.CornerRadius(h * .21f))
drawCircle(color, h * .42f, Offset(w * .28f, h * .62f))
drawCircle(color, h * .56f, Offset(w * .54f, h * .44f))
drawCircle(color, h * .38f, Offset(w * .78f, h * .66f))
drawRoundRect(color, Offset(w * .1f, h * .58f), Size(w * .8f, h * .42f), androidx.compose.ui.geometry.CornerRadius(h * .21f))
}
}
private fun DrawScope.drop(c: Offset, r: Float) {
val path = Path().apply {
moveTo(c.x, c.y - r * 1.6f)
cubicTo(c.x + r * .2f, c.y - r * .8f, c.x + r, c.y - r * .2f, c.x + r, c.y + r * .2f)
cubicTo(c.x + r, c.y + r * .8f, c.x + r * .5f, c.y + r * 1.1f, c.x, c.y + r * 1.1f)
cubicTo(c.x - r * .5f, c.y + r * 1.1f, c.x - r, c.y + r * .8f, c.x - r, c.y + r * .2f)
cubicTo(c.x - r, c.y - r * .2f, c.x - r * .2f, c.y - r * .8f, c.x, c.y - r * 1.6f)
close()
}
drawPath(path, WColor.iconDrop)
}
private fun DrawScope.flake(c: Offset, r: Float) {
drawCircle(WColor.iconSnowOutline, r * 1.15f, c)
drawCircle(WColor.iconSnow, r, c)
}
private fun DrawScope.bolt(c: Offset, h: Float) {
val w = h * .55f
val path = Path().apply {
moveTo(c.x + w * .15f, c.y)
lineTo(c.x - w * .45f, c.y + h * .55f)
lineTo(c.x - w * .02f, c.y + h * .55f)
lineTo(c.x - w * .2f, c.y + h)
lineTo(c.x + w * .45f, c.y + h * .38f)
lineTo(c.x + w * .02f, c.y + h * .38f)
lineTo(c.x + w * .25f, c.y)
close()
}
drawPath(path, WColor.iconBolt)
drawPath(path, WColor.iconSunCore, style = Stroke(width = h * .03f))
}
@@ -0,0 +1,175 @@
package app.wetter.ui.scene
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateFloat
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import app.wetter.data.Condition
import app.wetter.ui.theme.WColor
import kotlin.math.PI
import kotlin.math.sin
import kotlin.random.Random
@Immutable
data class SceneSpec(val condition: Condition, val isDay: Boolean, val intensity: Float = .5f) {
val top: Color get() = palette().first
val bottom: Color get() = palette().second
/** Light skies get dark text (on-scene-dark), dark skies white (on-scene). */
val isLight: Boolean get() = isDay && condition in setOf(Condition.CLEAR, Condition.PARTLY_CLOUDY, Condition.CLOUDY, Condition.FOG, Condition.SNOW)
val onScene: Color get() = if (isLight) WColor.onSceneDark else WColor.onScene
private fun palette(): Pair<Color, Color> = when {
condition == Condition.STORM -> WColor.sceneStormTop to WColor.sceneStormBottom
condition == Condition.RAIN || condition == Condition.DRIZZLE || condition == Condition.SLEET ->
if (isDay) WColor.sceneRainTop to WColor.sceneRainBottom else WColor.sceneClearNightTop to WColor.sceneRainTop
!isDay -> WColor.sceneClearNightTop to WColor.sceneClearNightBottom
condition == Condition.SNOW -> WColor.sceneSnowTop to WColor.sceneSnowBottom
condition == Condition.FOG -> WColor.sceneFogTop to WColor.sceneFogBottom
condition == Condition.CLOUDY -> WColor.sceneCloudyDayTop to WColor.sceneCloudyDayBottom
else -> WColor.sceneClearDayTop to WColor.sceneClearDayBottom
}
}
/**
* Animated sky. Soft gradient, slow drifting light shapes, and precipitation particles.
* [animate] false draws one still frame (battery saver, reduced motion, setting off).
*/
@Composable
fun WeatherScene(spec: SceneSpec, modifier: Modifier = Modifier, animate: Boolean = true, scroll: Float = 0f) {
val top by animateColorAsState(spec.top, tween(600), label = "top")
val bottom by animateColorAsState(spec.bottom, tween(600), label = "bottom")
val t = if (animate) {
val transition = rememberInfiniteTransition(label = "scene")
val v by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(60_000, easing = LinearEasing)), label = "t")
v
} else 0.2f
val particles = remember(spec.condition) { Particles.make(spec.condition) }
Canvas(modifier.fillMaxSize()) {
drawRect(Brush.verticalGradient(listOf(top, bottom)))
drawSceneLayers(spec, t, scroll, particles)
}
}
fun DrawScope.drawSceneLayers(spec: SceneSpec, t: Float, scroll: Float, particles: Particles) {
val w = size.width
val h = size.height
val parallax = -scroll * .3f
// Sun glow or stars
if (spec.isDay && spec.condition in setOf(Condition.CLEAR, Condition.PARTLY_CLOUDY)) {
val c = Offset(w * .82f, h * .08f + parallax)
drawCircle(Brush.radialGradient(listOf(WColor.sceneGlowSun.copy(alpha = .75f), Color.Transparent), c, w * .7f), w * .7f, c)
}
if (!spec.isDay && spec.condition in setOf(Condition.CLEAR, Condition.PARTLY_CLOUDY)) {
particles.stars.forEach { s ->
val twinkle = .5f + .5f * sin((t * 2 * PI * 20 + s.phase).toFloat())
drawCircle(Color.White.copy(alpha = .25f + .55f * twinkle), s.r, Offset(s.x * w, s.y * h * .55f + parallax))
}
}
// Big soft hills at the bottom (the calm, illustrative look)
val hill = if (spec.isLight) WColor.sceneHill else WColor.darkSceneHill
drawHill(hill.copy(alpha = .35f), h * .78f + parallax * .5f, h * .07f, w * .9f, t * 2 * PI.toFloat())
drawHill(hill.copy(alpha = .5f), h * .86f + parallax * .5f, h * .05f, w * 1.3f, t * 2 * PI.toFloat() + 1.7f)
// Drifting cloud shapes
val cloudAlpha = when (spec.condition) {
Condition.CLEAR -> .10f
Condition.PARTLY_CLOUDY -> .22f
Condition.CLOUDY, Condition.FOG, Condition.SNOW -> .32f
else -> .18f
}
particles.clouds.forEach { c ->
val x = ((c.x + t * c.speed) % 1.4f - .2f) * w
val y = c.y * h * .6f + parallax * .7f
val r = c.r * w
drawCircle(Brush.radialGradient(listOf(WColor.sceneCloud.copy(alpha = cloudAlpha), Color.Transparent), Offset(x, y), r), r, Offset(x, y))
}
// Precipitation
val count = (particles.drops.size * spec.intensity.coerceIn(.25f, 1f)).toInt()
when (spec.condition) {
Condition.RAIN, Condition.DRIZZLE, Condition.STORM, Condition.SLEET -> {
val len = if (spec.condition == Condition.DRIZZLE) h * .012f else h * .03f
particles.drops.take(count).forEach { d ->
val y = ((d.y + t * 90f * d.speed) % 1f) * h
val x = (d.x * w + y * .12f) % w
drawLine(Color.White.copy(alpha = .35f), Offset(x, y), Offset(x + len * .12f, y + len), 2f, StrokeCap.Round)
}
}
Condition.SNOW -> particles.drops.take(count).forEach { d ->
val y = ((d.y + t * 18f * d.speed) % 1f) * h
val x = d.x * w + sin((t * 2 * PI * 30 + d.phase).toFloat()) * 18f
drawCircle(Color.White.copy(alpha = .8f), 2.5f + d.speed * 2.5f, Offset(x, y))
}
Condition.FOG -> for (i in 0..3) {
val y = h * (.25f + i * .17f) + parallax * .4f
val dx = sin((t * 2 * PI + i).toFloat()) * w * .08f
drawRect(
Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = .22f), Color.Transparent), startX = dx, endX = w + dx),
topLeft = Offset(0f, y), size = androidx.compose.ui.geometry.Size(w, h * .07f),
)
}
else -> Unit
}
// Lightning: two short flashes per minute
if (spec.condition == Condition.STORM) {
val phase = (t * 2f) % 1f
if (phase in 0.40f..0.415f || phase in 0.43f..0.44f) drawRect(Color.White.copy(alpha = .35f))
}
}
private fun DrawScope.drawHill(color: Color, baseY: Float, amp: Float, wavelength: Float, phase: Float) {
val w = size.width
val path = Path().apply {
moveTo(0f, size.height)
var x = 0f
while (x <= w + 8f) {
lineTo(x, baseY - amp * sin(x / wavelength * 2 * PI.toFloat() + phase * .2f))
x += 8f
}
lineTo(w, size.height)
close()
}
drawPath(path, color)
}
class Particles(val stars: List<P>, val clouds: List<P>, val drops: List<P>) {
data class P(val x: Float, val y: Float, val r: Float, val speed: Float, val phase: Float)
companion object {
fun make(condition: Condition, seed: Int = 7): Particles {
val rnd = Random(seed)
fun p(r: ClosedFloatingPointRange<Float>) = P(rnd.nextFloat(), rnd.nextFloat(), r.start + rnd.nextFloat() * (r.endInclusive - r.start), .5f + rnd.nextFloat(), rnd.nextFloat() * 6.28f)
val dropCount = when (condition) {
Condition.RAIN, Condition.STORM -> 140
Condition.DRIZZLE, Condition.SLEET -> 80
Condition.SNOW -> 90
else -> 0
}
return Particles(
stars = List(60) { p(1f..2.6f) },
clouds = List(6) { p(0.35f..0.6f) },
drops = List(dropCount) { p(1f..1f) },
)
}
}
}
@@ -0,0 +1,102 @@
package app.wetter.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import app.wetter.data.ThemeMode
/** Roles the M3 scheme has no slot for: cards on the scene, tonal fills, day pills. */
@Immutable
data class WeatherColors(
val card: Color,
val onCard: Color,
val onCardMuted: Color,
val fillPrimary: Color,
val onFillPrimary: Color,
val fillSecondary: Color,
val onFillSecondary: Color,
val fillTertiary: Color,
val onFillTertiary: Color,
val pill: Color,
val pillSelected: Color,
val onPillSelected: Color,
val nowChip: Color,
val onNowChip: Color,
val precipText: Color,
val hill: Color,
val dark: Boolean,
)
private val LightWeather = WeatherColors(
card = WColor.card, onCard = WColor.onCard, onCardMuted = WColor.onCardMuted,
fillPrimary = WColor.fillPrimary, onFillPrimary = WColor.onFillPrimary,
fillSecondary = WColor.fillSecondary, onFillSecondary = WColor.onFillSecondary,
fillTertiary = WColor.fillTertiary, onFillTertiary = WColor.onFillTertiary,
pill = WColor.pill, pillSelected = WColor.pillSelected, onPillSelected = WColor.onPillSelected,
nowChip = WColor.nowChip, onNowChip = WColor.onNowChip, precipText = WColor.precipText,
hill = WColor.sceneHill, dark = false,
)
private val DarkWeather = WeatherColors(
card = WColor.darkCard, onCard = WColor.darkOnCard, onCardMuted = WColor.darkOnCardMuted,
fillPrimary = WColor.darkFillPrimary, onFillPrimary = WColor.darkOnFillPrimary,
fillSecondary = WColor.darkFillOlive, onFillSecondary = WColor.darkOnCard,
fillTertiary = WColor.darkFillTertiary, onFillTertiary = WColor.darkOnFillTertiary,
pill = WColor.darkPill, pillSelected = WColor.pillSelected, onPillSelected = WColor.onPillSelected,
nowChip = WColor.darkFillPrimary, onNowChip = WColor.darkOnFillPrimary, precipText = WColor.darkPrecipText,
hill = WColor.darkSceneHill, dark = true,
)
val LocalWeatherColors = staticCompositionLocalOf { LightWeather }
private val LightScheme = lightColorScheme(
primary = WColor.accent, onPrimary = WColor.onAccent,
primaryContainer = WColor.accentContainer, onPrimaryContainer = WColor.onAccentContainer,
secondaryContainer = WColor.accentContainer, onSecondaryContainer = WColor.onAccentContainer,
background = WColor.bg, onBackground = WColor.text,
surface = WColor.surface, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted,
surfaceContainer = WColor.surfaceContainer, surfaceContainerHigh = WColor.surfaceContainerHigh,
outline = WColor.borderInput, outlineVariant = WColor.border, error = WColor.danger,
)
private val DarkScheme = darkColorScheme(
primary = WColor.darkAccent, onPrimary = WColor.darkOnAccent,
secondaryContainer = WColor.darkFillPrimary, onSecondaryContainer = WColor.darkOnFillPrimary,
background = WColor.darkBg, onBackground = WColor.darkText,
surface = WColor.darkSurface, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted,
surfaceContainer = WColor.darkSurfaceContainer, surfaceContainerHigh = WColor.darkSurfaceContainer,
outline = WColor.darkBorderInput, outlineVariant = WColor.darkBorder, error = WColor.darkDanger,
)
private val AppTypography = Typography(
displayLarge = WType.display,
headlineLarge = WType.dayRange,
headlineMedium = WType.headline,
titleLarge = WType.heroCondition,
titleMedium = WType.cardTitle,
titleSmall = WType.title,
bodyLarge = WType.body,
bodyMedium = WType.bodySm,
labelLarge = WType.cardTitle,
labelMedium = WType.label,
labelSmall = WType.labelSm,
)
@Composable
fun WeatherTheme(mode: ThemeMode = ThemeMode.SYSTEM, content: @Composable () -> Unit) {
val dark = when (mode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
CompositionLocalProvider(LocalWeatherColors provides if (dark) DarkWeather else LightWeather) {
MaterialTheme(colorScheme = if (dark) DarkScheme else LightScheme, typography = AppTypography, content = content)
}
}
@@ -0,0 +1,157 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.wetter.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
object WColor {
val bg = Color(0xFFF8F9FF)
val surface = Color(0xFFFFFFFF)
val surfaceContainer = Color(0xFFECEEF4)
val surfaceContainerHigh = Color(0xFFE1E3EA)
val border = Color(0xFFC3C6CF)
val borderInput = Color(0xFF73777F)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF43474E)
val accent = Color(0xFF2A5EA8)
val onAccent = Color(0xFFFFFFFF)
val accentContainer = Color(0xFFD6E3FF)
val onAccentContainer = Color(0xFF001B3E)
val danger = Color(0xFFBA1A1A)
val warning = Color(0xFF8A5100)
val success = Color(0xFF2E6B30)
val darkBg = Color(0xFF111318)
val darkSurface = Color(0xFF1D2024)
val darkSurfaceContainer = Color(0xFF272A2F)
val darkBorder = Color(0xFF43474E)
val darkBorderInput = Color(0xFF8D9199)
val darkText = Color(0xFFE2E2E9)
val darkTextMuted = Color(0xFFC3C6CF)
val darkAccent = Color(0xFFA9C7FF)
val darkOnAccent = Color(0xFF003063)
val darkDanger = Color(0xFFFFB4AB)
val darkWarning = Color(0xFFFFB870)
val sceneClearDayTop = Color(0xFF7CC4EF)
val sceneClearDayBottom = Color(0xFFBFE3F7)
val sceneClearNightTop = Color(0xFF3D4F9A)
val sceneClearNightBottom = Color(0xFF5B4F8F)
val sceneCloudyDayTop = Color(0xFFC9CDD2)
val sceneCloudyDayBottom = Color(0xFFDFE2E5)
val sceneRainTop = Color(0xFF2E3D4F)
val sceneRainBottom = Color(0xFF5A6B7D)
val sceneSnowTop = Color(0xFF7C8EA3)
val sceneSnowBottom = Color(0xFFD4DDE6)
val sceneStormTop = Color(0xFF1F1A33)
val sceneStormBottom = Color(0xFF4A3F63)
val sceneFogTop = Color(0xFF8A929B)
val sceneFogBottom = Color(0xFFC4C9CE)
val onScene = Color(0xFFFFFFFF)
val onSceneMuted = Color(0xFFE8EDF3)
val onSceneDark = Color(0xFF14181D)
val sceneCard = Color(0xFF2A3644)
val sceneCardLight = Color(0xFFF2F5F8)
val alertMinor = Color(0xFFFFF2B8)
val onAlertMinor = Color(0xFF3D2F00)
val alertSevere = Color(0xFFFFDBC8)
val onAlertSevere = Color(0xFF4A1A00)
val alertExtreme = Color(0xFF93000A)
val onAlertExtreme = Color(0xFFFFFFFF)
val aqiGood = Color(0xFF2E7D32)
val aqiModerate = Color(0xFFA06A00)
val aqiBad = Color(0xFFC62828)
val uvHigh = Color(0xFFB34700)
val card = Color(0xFFFDF8F3)
val onCard = Color(0xFF1F1B17)
val onCardMuted = Color(0xFF5A524B)
val darkCard = Color(0xFF1F1714)
val darkOnCard = Color(0xFFEDE0DB)
val darkOnCardMuted = Color(0xFFC4B6B0)
val fillPrimary = Color(0xFFD3E3FD)
val onFillPrimary = Color(0xFF0B2A55)
val fillSecondary = Color(0xFFE9DDFF)
val onFillSecondary = Color(0xFF2A1A4D)
val fillTertiary = Color(0xFFC8E6C2)
val onFillTertiary = Color(0xFF0F3A12)
val darkFillPrimary = Color(0xFFA8C7FA)
val darkOnFillPrimary = Color(0xFF062E6F)
val darkFillTertiary = Color(0xFF2D5A27)
val darkOnFillTertiary = Color(0xFFB7F0AC)
val darkFillOlive = Color(0xFF3E4A1E)
val pill = Color(0xFFF1EBE5)
val darkPill = Color(0xFF2B221E)
val pillSelected = Color(0xFFA8C7FA)
val onPillSelected = Color(0xFF0B2A55)
val nowChip = Color(0xFF4E4038)
val onNowChip = Color(0xFFFFFFFF)
val precipText = Color(0xFF2A5EA8)
val darkPrecipText = Color(0xFFA8C7FA)
val nowcastDot = Color(0xFF2E7D32)
val iconSun = Color(0xFFFFB02E)
val iconSunCore = Color(0xFFFF9A1F)
val iconMoon = Color(0xFFA9C4FF)
val iconCloud = Color(0xFFF4F6F9)
val iconCloudShade = Color(0xFFCFD6DF)
val iconCloudDark = Color(0xFF9AA5B3)
val iconDrop = Color(0xFF3D7BF0)
val iconSnow = Color(0xFFFFFFFF)
val iconSnowOutline = Color(0xFF9FB3CC)
val iconBolt = Color(0xFFFFC93C)
val iconFog = Color(0xFFC3CAD3)
val sceneGlowSun = Color(0xFFFFF3C4)
val sceneCloud = Color(0xFFFFFFFF)
val sceneHill = Color(0xFF9FC5E6)
val darkSceneHill = Color(0xFF2F3F78)
val rangeCold = Color(0xFF6FA8FF)
val rangeWarm = Color(0xFFFFB02E)
val aqiBar1 = Color(0xFF4FD1C5)
val aqiBar2 = Color(0xFF9CCC65)
val aqiBar3 = Color(0xFFFFD54F)
val aqiBar4 = Color(0xFFFF8A65)
val aqiBar5 = Color(0xFFE53935)
val aqiBar6 = Color(0xFF8E24AA)
val uvDotLow = Color(0xFF5CCF8A)
val uvDotOff = Color(0xFF6B5A52)
}
object WType {
val labelSm = TextStyle(fontSize = 11.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
val label = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400)
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val title = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.W500)
val headline = TextStyle(fontSize = 24.sp, lineHeight = 32.sp, fontWeight = FontWeight.W500)
val metric = TextStyle(fontSize = 32.sp, lineHeight = 40.sp, fontWeight = FontWeight.W500)
val display = TextStyle(fontSize = 112.sp, lineHeight = 112.sp, fontWeight = FontWeight.W700)
val displayWidget = TextStyle(fontSize = 44.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
val dayRange = TextStyle(fontSize = 56.sp, lineHeight = 60.sp, fontWeight = FontWeight.W600)
val heroCondition = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
val cardTitle = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W500)
}
object WSpace {
val s0 = 0.dp
val s2 = 2.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s20 = 20.dp
val s24 = 24.dp
val s32 = 32.dp
val s48 = 48.dp
}
object WRadius {
val xs = 8.dp
val sm = 12.dp
val md = 16.dp
val lg = 24.dp
val xl = 28.dp
val widget = 24.dp
val pill = 999.dp
val card = 28.dp
val dayPill = 24.dp
}
@@ -0,0 +1,47 @@
package app.wetter.work
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import app.wetter.data.Graph
import app.wetter.widget.updateAllWidgets
import java.util.concurrent.TimeUnit
/** Refreshes every location, then redraws the widgets. */
class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
Graph.init(applicationContext)
val force = inputData.getBoolean(KEY_FORCE, false)
runCatching { Graph.repo.refreshAll(force) }
updateAllWidgets(applicationContext)
return Result.success()
}
companion object {
private const val PERIODIC = "refresh"
private const val NOW = "refresh-now"
private const val KEY_FORCE = "force"
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<RefreshWorker>(30, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).setRequiresBatteryNotLow(true).build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.KEEP, request)
}
fun runNow(context: Context, force: Boolean = true) {
val request = OneTimeWorkRequestBuilder<RefreshWorker>()
.setInputData(androidx.work.workDataOf(KEY_FORCE to force))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(context).enqueueUniqueWork(NOW, ExistingWorkPolicy.REPLACE, request)
}
}
}
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF7CC4EF" android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#FFFFB02E" android:pathData="M64,40m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"/>
<path android:fillColor="#FFF4F6F9" android:pathData="M30,74h44a12,12 0,0 0,0 -24a16,16 0,0 0,-30 -4a13,13 0,0 0,-14 28z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M17.65,6.35A7.96,7.96 0,0 0,12 4a8,8 0,1 0,7.73 10h-2.08A6,6 0,1 1,12 6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>
+121
View File
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Wetter</string>
<string name="cond_clear_day">Sonnig</string>
<string name="cond_clear_night">Klar</string>
<string name="cond_partly">Teils bewölkt</string>
<string name="cond_cloudy">Bedeckt</string>
<string name="cond_fog">Nebel</string>
<string name="cond_drizzle">Nieselregen</string>
<string name="cond_rain">Regen</string>
<string name="cond_snow">Schnee</string>
<string name="cond_sleet">Schneeregen</string>
<string name="cond_storm">Gewitter</string>
<string name="feels_like">Gefühlt %1$s</string>
<string name="high_low">Max. %1$s · Min. %2$s</string>
<string name="current_location">Mein Standort</string>
<string name="updated_ago">Stand: vor %1$d Min.</string>
<string name="updated_hours">Stand: vor %1$d Std.</string>
<string name="offline">Offline · zeige gespeicherte Daten</string>
<string name="hourly">Die nächsten Stunden</string>
<string name="daily">10 Tage</string>
<string name="now">Jetzt</string>
<string name="today">Heute</string>
<string name="brief_title">Kurz gesagt</string>
<string name="nowcast_title_rain">Niederschlag ab %1$s</string>
<string name="nowcast_title_ongoing">Niederschlag bis %1$s</string>
<string name="nowcast_sub">%1$s von %2$s bis %3$s</string>
<string name="nowcast_light">leicht</string>
<string name="nowcast_moderate">mäßig</string>
<string name="nowcast_heavy">stark</string>
<string name="precip_light">Leichter Niederschlag</string>
<string name="precip_moderate">Mäßiger Niederschlag</string>
<string name="precip_heavy">Starker Niederschlag</string>
<string name="card_wind">Wind</string>
<string name="card_uv">UV</string>
<string name="card_visibility">Sicht</string>
<string name="card_humidity">Feuchte</string>
<string name="card_pressure">Luftdruck</string>
<string name="card_sun">Sonne</string>
<string name="card_aqi">Luftqualität</string>
<string name="card_pollen">Pollen</string>
<string name="wind_from">Aus %1$s</string>
<string name="dew_point">Taupunkt</string>
<string name="no_data">– –</string>
<string-array name="compass">
<item>N</item><item>NO</item><item>O</item><item>SO</item><item>S</item><item>SW</item><item>W</item><item>NW</item>
</string-array>
<string-array name="uv_level">
<item>Niedrig</item><item>Mittel</item><item>Hoch</item><item>Sehr hoch</item><item>Extrem</item>
</string-array>
<string-array name="aqi_level">
<item>Sehr gute Luft</item><item>Gute Luft</item><item>Mittelmäßige Luft</item><item>Schlechte Luft</item><item>Sehr schlechte Luft</item><item>Extrem schlechte Luft</item>
</string-array>
<string-array name="pollen_names">
<item>Erle</item><item>Birke</item><item>Gräser</item><item>Beifuß</item><item>Olive</item><item>Ambrosia</item>
</string-array>
<string name="pollen_none">Kaum Pollen</string>
<string name="brief_rain_from">Ab %1$s Uhr Regen.</string>
<string name="brief_snow_from">Ab %1$s Uhr Schnee.</string>
<string name="brief_storm_from">Ab %1$s Uhr Gewitter möglich.</string>
<string name="brief_wet_now">Gerade nass draußen, Schirm mitnehmen.</string>
<string name="brief_wet_now_until">Gerade Niederschlag, ab etwa %1$s trocken.</string>
<string name="brief_dry">Bis heute Abend bleibt es trocken.</string>
<string name="brief_dry_rest">Den Rest des Tages bleibt es trocken.</string>
<string name="brief_warmer">Morgen %1$d° wärmer.</string>
<string name="brief_colder">Morgen %1$d° kühler.</string>
<string name="brief_same">Morgen ähnlich temperiert.</string>
<string name="brief_uv">UV hoch: Sonnenschutz zwischen %1$s und %2$s Uhr.</string>
<string name="brief_wind">Böen bis %1$s.</string>
<string name="locations">Orte</string>
<string name="search_hint">Stadt oder Postleitzahl</string>
<string name="search_empty">Kein Ort gefunden</string>
<string name="search_error">Suche fehlgeschlagen</string>
<string name="retry">Erneut versuchen</string>
<string name="save">Speichern</string>
<string name="saved">Gespeichert</string>
<string name="deleted">%1$s entfernt</string>
<string name="undo">Rückgängig</string>
<string name="delete">Entfernen</string>
<string name="back">Zurück</string>
<string name="settings">Einstellungen</string>
<string name="open_locations">Orte öffnen</string>
<string name="perm_title">Wetter für deinen Standort</string>
<string name="perm_body">Erlaube den ungefähren Standort, um das Wetter dort zu sehen. Er verlässt das Gerät nur auf 2 Nachkommastellen gerundet, für die Wetterabfrage.</string>
<string name="perm_allow">Standort erlauben</string>
<string name="perm_search">Lieber einen Ort suchen</string>
<string name="loading_error">Wetterdaten konnten nicht geladen werden</string>
<string name="set_units">Einheiten</string>
<string name="set_temp">Temperatur</string>
<string name="set_wind">Windgeschwindigkeit</string>
<string name="set_precip">Niederschlag</string>
<string name="set_pressure">Luftdruck</string>
<string name="set_distance">Sichtweite</string>
<string name="set_look">Darstellung</string>
<string name="set_theme">Design</string>
<string name="theme_system">System</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="set_animations">Animierter Himmel</string>
<string name="set_animations_sub">Wird im Energiesparmodus automatisch angehalten</string>
<string name="set_brief">Zusammenfassung anzeigen</string>
<string name="set_about">Datenquellen</string>
<string name="attribution">Wetterdaten: Open-Meteo.com (CC BY 4.0) · Ortsnamen: © OpenStreetMap-Mitwirkende</string>
<string name="widget_now_name">Wetter jetzt</string>
<string name="widget_now_desc">Temperatur und Wetterlage auf einen Blick</string>
<string name="widget_forecast_name">Vorhersage</string>
<string name="widget_forecast_desc">Jetzt, die nächsten Stunden und Tage</string>
<string name="widget_pick">Ort wählen</string>
<string name="widget_follow">Mein Standort (folgt dir)</string>
<string name="widget_loading">…</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_forecast_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp" android:minHeight="110dp"
android:targetCellWidth="4" android:targetCellHeight="2"
android:minResizeWidth="250dp" android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="3600000"
android:widgetCategory="home_screen" />
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_now_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp" android:minHeight="48dp"
android:targetCellWidth="2" android:targetCellHeight="2"
android:minResizeWidth="110dp" android:minResizeHeight="48dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="3600000"
android:widgetCategory="home_screen|keyguard" />