= 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, val clouds: List
, val drops: List
) {
+ 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) = 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) },
+ )
+ }
+ }
+}
diff --git a/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt b/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt
new file mode 100644
index 0000000..685d039
--- /dev/null
+++ b/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt
@@ -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)
+ }
+}
diff --git a/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt b/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt
new file mode 100644
index 0000000..3eaf9db
--- /dev/null
+++ b/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt
@@ -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
+}
diff --git a/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt b/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt
new file mode 100644
index 0000000..e0fa771
--- /dev/null
+++ b/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt
@@ -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(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()
+ .setInputData(androidx.work.workDataOf(KEY_FORCE to force))
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(NOW, ExistingWorkPolicy.REPLACE, request)
+ }
+ }
+}
diff --git a/wetter/app/src/main/res/drawable/ic_launcher.xml b/wetter/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..72c2b93
--- /dev/null
+++ b/wetter/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/wetter/app/src/main/res/drawable/ic_refresh.xml b/wetter/app/src/main/res/drawable/ic_refresh.xml
new file mode 100644
index 0000000..5818626
--- /dev/null
+++ b/wetter/app/src/main/res/drawable/ic_refresh.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/wetter/app/src/main/res/values/strings.xml b/wetter/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..0e23a74
--- /dev/null
+++ b/wetter/app/src/main/res/values/strings.xml
@@ -0,0 +1,121 @@
+
+
+ Wetter
+
+ Sonnig
+ Klar
+ Teils bewölkt
+ Bedeckt
+ Nebel
+ Nieselregen
+ Regen
+ Schnee
+ Schneeregen
+ Gewitter
+
+ Gefühlt %1$s
+ Max. %1$s · Min. %2$s
+ Mein Standort
+ Stand: vor %1$d Min.
+ Stand: vor %1$d Std.
+ Offline · zeige gespeicherte Daten
+
+ Die nächsten Stunden
+ 10 Tage
+ Jetzt
+ Heute
+ Kurz gesagt
+ Niederschlag ab %1$s
+ Niederschlag bis %1$s
+ %1$s von %2$s bis %3$s
+ leicht
+ mäßig
+ stark
+ Leichter Niederschlag
+ Mäßiger Niederschlag
+ Starker Niederschlag
+
+ Wind
+ UV
+ Sicht
+ Feuchte
+ Luftdruck
+ Sonne
+ Luftqualität
+ Pollen
+ Aus %1$s
+ Taupunkt
+ – –
+
+
+ - N
- NO
- O
- SO
- S
- SW
- W
- NW
+
+
+ - Niedrig
- Mittel
- Hoch
- Sehr hoch
- Extrem
+
+
+ - Sehr gute Luft
- Gute Luft
- Mittelmäßige Luft
- Schlechte Luft
- Sehr schlechte Luft
- Extrem schlechte Luft
+
+
+ - Erle
- Birke
- Gräser
- Beifuß
- Olive
- Ambrosia
+
+ Kaum Pollen
+
+ Ab %1$s Uhr Regen.
+ Ab %1$s Uhr Schnee.
+ Ab %1$s Uhr Gewitter möglich.
+ Gerade nass draußen, Schirm mitnehmen.
+ Gerade Niederschlag, ab etwa %1$s trocken.
+ Bis heute Abend bleibt es trocken.
+ Den Rest des Tages bleibt es trocken.
+ Morgen %1$d° wärmer.
+ Morgen %1$d° kühler.
+ Morgen ähnlich temperiert.
+ UV hoch: Sonnenschutz zwischen %1$s und %2$s Uhr.
+ Böen bis %1$s.
+
+ Orte
+ Stadt oder Postleitzahl
+ Kein Ort gefunden
+ Suche fehlgeschlagen
+ Erneut versuchen
+ Speichern
+ Gespeichert
+ %1$s entfernt
+ Rückgängig
+ Entfernen
+ Zurück
+ Einstellungen
+ Orte öffnen
+
+ Wetter für deinen Standort
+ 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.
+ Standort erlauben
+ Lieber einen Ort suchen
+ Wetterdaten konnten nicht geladen werden
+
+ Einheiten
+ Temperatur
+ Windgeschwindigkeit
+ Niederschlag
+ Luftdruck
+ Sichtweite
+ Darstellung
+ Design
+ System
+ Hell
+ Dunkel
+ Animierter Himmel
+ Wird im Energiesparmodus automatisch angehalten
+ Zusammenfassung anzeigen
+ Datenquellen
+ Wetterdaten: Open-Meteo.com (CC BY 4.0) · Ortsnamen: © OpenStreetMap-Mitwirkende
+
+ Wetter jetzt
+ Temperatur und Wetterlage auf einen Blick
+ Vorhersage
+ Jetzt, die nächsten Stunden und Tage
+ Ort wählen
+ Mein Standort (folgt dir)
+ …
+
diff --git a/wetter/app/src/main/res/xml/widget_forecast.xml b/wetter/app/src/main/res/xml/widget_forecast.xml
new file mode 100644
index 0000000..384fd96
--- /dev/null
+++ b/wetter/app/src/main/res/xml/widget_forecast.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/wetter/app/src/main/res/xml/widget_now.xml b/wetter/app/src/main/res/xml/widget_now.xml
new file mode 100644
index 0000000..1b1a2a7
--- /dev/null
+++ b/wetter/app/src/main/res/xml/widget_now.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/wetter/build.gradle.kts b/wetter/build.gradle.kts
new file mode 100644
index 0000000..437470f
--- /dev/null
+++ b/wetter/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ id("com.android.application") version "8.13.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
+ id("com.google.devtools.ksp") version "2.2.20-2.0.3" apply false
+ id("io.github.takahirom.roborazzi") version "1.46.1" apply false
+}
diff --git a/wetter/gradle.properties b/wetter/gradle.properties
new file mode 100644
index 0000000..f97184b
--- /dev/null
+++ b/wetter/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/wetter/gradle/wrapper/gradle-wrapper.jar b/wetter/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/wetter/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/wetter/gradle/wrapper/gradle-wrapper.properties b/wetter/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d4081da
--- /dev/null
+++ b/wetter/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/wetter/gradlew b/wetter/gradlew
new file mode 100755
index 0000000..23d15a9
--- /dev/null
+++ b/wetter/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/wetter/gradlew.bat b/wetter/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/wetter/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/wetter/settings.gradle.kts b/wetter/settings.gradle.kts
new file mode 100644
index 0000000..a5bcf7e
--- /dev/null
+++ b/wetter/settings.gradle.kts
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "Wetter"
+include(":app")