diff --git a/README.md b/README.md
index 3431457..0c6e54e 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,26 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
- **No account, no tracking, no analytics.** Everything stays on the device. Coordinates are rounded to two decimals (about 1 km) before they reach the weather service.
- **Open data sources only:** [Open-Meteo](https://open-meteo.com) for forecasts, 15-minute precipitation, air quality, pollen and place search (CC BY 4.0), and [Nominatim / OpenStreetMap](https://nominatim.org) for naming your current location.
- **Features today:** current location and saved cities (swipe between them), search with a full preview before saving, precipitation-in-the-next-hours card, a rule-based daily summary, day detail view, units and theme settings, light and dark mode.
-- **Five home-screen widgets:** a weather card that grows from 3×2 to 4×5 (hourly panel, then a daily list), a pill, a tilted pill, a one-line bar with a cookie badge, and a classic card. Each one can be customised when added or later: location, system/light/dark, colours (wallpaper Material You, current weather, or five fixed palettes, all WCAG AA checked) and background opacity from 100 % down to fully transparent.
+- **Six home-screen widgets:** a minimal clock with date and current weather (transparent by default), a weather card that grows from 3×2 to 4×5 (hourly panel, then a daily list), a pill, a tilted pill, a one-line bar with a cookie badge, and a classic card. The widget picker shows real previews. Each one can be customised when added or later: location, system/light/dark, colours (wallpaper Material You, current weather, or five fixed palettes, all WCAG AA checked) and background opacity from 100 % down to fully transparent.
- **Coming next:** severe weather alerts (DWD / MeteoAlarm), precipitation radar map, card reordering, notifications.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Screenshots are rendered from a real Open-Meteo response (Berlin) by the app's screenshot tests; widgets are rendered on a Pixel with the wallpaper colours of that phone.
+
Requires Android 10 (API 29) or newer.
## Install with Obtainium
diff --git a/docs/screenshots/S01-clear-day.png b/docs/screenshots/S01-clear-day.png
new file mode 100644
index 0000000..2fedb9f
Binary files /dev/null and b/docs/screenshots/S01-clear-day.png differ
diff --git a/docs/screenshots/S01-clear-night-dark.png b/docs/screenshots/S01-clear-night-dark.png
new file mode 100644
index 0000000..604b725
Binary files /dev/null and b/docs/screenshots/S01-clear-night-dark.png differ
diff --git a/docs/screenshots/S01-full-light.png b/docs/screenshots/S01-full-light.png
new file mode 100644
index 0000000..08f8891
Binary files /dev/null and b/docs/screenshots/S01-full-light.png differ
diff --git a/docs/screenshots/S01-rain-nowcast.png b/docs/screenshots/S01-rain-nowcast.png
new file mode 100644
index 0000000..3cb322a
Binary files /dev/null and b/docs/screenshots/S01-rain-nowcast.png differ
diff --git a/docs/screenshots/S04-day-dark.png b/docs/screenshots/S04-day-dark.png
new file mode 100644
index 0000000..55efafb
Binary files /dev/null and b/docs/screenshots/S04-day-dark.png differ
diff --git a/docs/screenshots/S08-settings.png b/docs/screenshots/S08-settings.png
new file mode 100644
index 0000000..b95cfd2
Binary files /dev/null and b/docs/screenshots/S08-settings.png differ
diff --git a/docs/screenshots/S15-preview.png b/docs/screenshots/S15-preview.png
new file mode 100644
index 0000000..be0ea0f
Binary files /dev/null and b/docs/screenshots/S15-preview.png differ
diff --git a/docs/screenshots/widgets.png b/docs/screenshots/widgets.png
new file mode 100644
index 0000000..d7964b4
Binary files /dev/null and b/docs/screenshots/widgets.png differ
diff --git a/wetter/app/build.gradle.kts b/wetter/app/build.gradle.kts
index 03a451d..76a8022 100644
--- a/wetter/app/build.gradle.kts
+++ b/wetter/app/build.gradle.kts
@@ -17,8 +17,8 @@ android {
applicationId = "app.wetter"
minSdk = 29
targetSdk = 36
- versionCode = 1
- versionName = "0.1.0"
+ versionCode = 2
+ versionName = "0.2.0"
}
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
@@ -36,7 +36,13 @@ android {
}
buildTypes {
+ debug {
+ // installs next to the release app from Obtainium
+ applicationIdSuffix = ".debug"
+ resValue("string", "app_name", "Wetter (Debug)")
+ }
release {
+ resValue("string", "app_name", "Wetter")
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
diff --git a/wetter/app/src/debug/java/app/wetter/WidgetGalleryActivity.kt b/wetter/app/src/debug/java/app/wetter/WidgetGalleryActivity.kt
index 25e145c..58d25cc 100644
--- a/wetter/app/src/debug/java/app/wetter/WidgetGalleryActivity.kt
+++ b/wetter/app/src/debug/java/app/wetter/WidgetGalleryActivity.kt
@@ -50,6 +50,7 @@ class WidgetGalleryActivity : androidx.activity.ComponentActivity() {
PillWidget() to DpSize(170.dp, 64.dp),
),
)
+ if (intent.getBooleanExtra("export", false)) { exportPreviews(column); return }
lifecycleScope.launch {
for ((widget, size) in pages[page.coerceIn(0, pages.lastIndex)]) {
val views = widget.compose(this@WidgetGalleryActivity, size = size)
@@ -60,6 +61,48 @@ class WidgetGalleryActivity : androidx.activity.ComponentActivity() {
}
}
+ /** Renders each widget at its default size and writes preview_.png to external files. */
+ private fun exportPreviews(column: LinearLayout) {
+ val items = listOf(
+ "card" to (WeatherCardWidget() to DpSize(300.dp, 230.dp)),
+ "pill" to (PillWidget() to DpSize(280.dp, 120.dp)),
+ "tilt" to (TiltWidget() to DpSize(160.dp, 160.dp)),
+ "bar" to (BarWidget() to DpSize(300.dp, 80.dp)),
+ "classic" to (ClassicWidget() to DpSize(300.dp, 170.dp)),
+ "clock" to (app.wetter.widget.ClockWidget() to DpSize(300.dp, 140.dp)),
+ )
+ lifecycleScope.launch {
+ val frames = mutableListOf>()
+ for ((key, pair) in items) {
+ val (widget, size) = pair
+ val frame = FrameLayout(this@WidgetGalleryActivity)
+ if (key == "clock") {
+ // the clock is transparent: show it over a soft wallpaper-like backdrop
+ app.wetter.widget.WidgetPreviewOverride.config =
+ app.wetter.data.WidgetConfigEntity(-1, null, "wallpaper", "dark", 0)
+ frame.background = android.graphics.drawable.GradientDrawable(
+ android.graphics.drawable.GradientDrawable.Orientation.TL_BR,
+ intArrayOf(Color.rgb(74, 98, 120), Color.rgb(150, 128, 112)),
+ ).apply { cornerRadius = px(28).toFloat() }
+ }
+ val views = widget.compose(this@WidgetGalleryActivity, size = size)
+ app.wetter.widget.WidgetPreviewOverride.config = null
+ frame.addView(views.apply(this@WidgetGalleryActivity, frame))
+ column.addView(frame, LinearLayout.LayoutParams(px(size.width.value), px(size.height.value)).apply { bottomMargin = px(12) })
+ frames += key to frame
+ }
+ column.postDelayed({
+ val dir = getExternalFilesDir(null)!!
+ for ((key, frame) in frames) {
+ val bmp = android.graphics.Bitmap.createBitmap(frame.width, frame.height, android.graphics.Bitmap.Config.ARGB_8888)
+ frame.draw(android.graphics.Canvas(bmp))
+ java.io.File(dir, "preview_$key.png").outputStream().use { bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
+ }
+ android.util.Log.i("WidgetGallery", "exported ${frames.size}")
+ }, 1500)
+ }
+ }
+
private fun px(dp: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt()
private fun px(dp: Int) = px(dp.toFloat())
}
diff --git a/wetter/app/src/main/AndroidManifest.xml b/wetter/app/src/main/AndroidManifest.xml
index 7f217fe..ab93df3 100644
--- a/wetter/app/src/main/AndroidManifest.xml
+++ b/wetter/app/src/main/AndroidManifest.xml
@@ -29,6 +29,10 @@
+
+
+
+
diff --git a/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt b/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt
index e289453..16deec8 100644
--- a/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt
+++ b/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt
@@ -100,7 +100,7 @@ class WidgetConfigActivity : ComponentActivity() {
ConfigScreen(appWidgetId, widget, size) { config ->
lifecycleScope.launch {
Graph.repo.saveWidgetConfig(config)
- runCatching { widget.update(this@WidgetConfigActivity, GlanceAppWidgetManager(this@WidgetConfigActivity).getGlanceIdBy(appWidgetId)) }
+ runCatching { refreshWidget(this@WidgetConfigActivity, widget, GlanceAppWidgetManager(this@WidgetConfigActivity).getGlanceIdBy(appWidgetId)) }
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
finish()
}
@@ -114,6 +114,7 @@ class WidgetConfigActivity : ComponentActivity() {
providerClass.endsWith("PillReceiver") -> PillWidget() to DpSize(300.dp, 130.dp)
providerClass.endsWith("TiltReceiver") -> TiltWidget() to DpSize(170.dp, 170.dp)
providerClass.endsWith("BarReceiver") -> BarWidget() to DpSize(320.dp, 84.dp)
+ providerClass.endsWith("ClockReceiver") -> ClockWidget() to DpSize(320.dp, 150.dp)
providerClass.endsWith("ClassicReceiver") -> ClassicWidget() to DpSize(320.dp, 160.dp)
else -> WeatherCardWidget() to DpSize(320.dp, 240.dp)
}
@@ -125,7 +126,10 @@ class WidgetConfigActivity : ComponentActivity() {
private fun ConfigScreen(appWidgetId: Int, widget: GlanceAppWidget, size: DpSize, onDone: (WidgetConfigEntity) -> Unit) {
val context = LocalContext.current
val locations by Graph.repo.locations.collectAsState(initial = emptyList())
- var config by remember { mutableStateOf(WidgetConfigEntity(appWidgetId, null)) }
+ var config by remember {
+ val d = (widget as? WeatherWidget)?.defaultStyle ?: WidgetStyle()
+ mutableStateOf(WidgetConfigEntity(appWidgetId, null, d.palette, d.theme, d.opacity))
+ }
var loaded by remember { mutableStateOf(false) }
LaunchedEffect(appWidgetId) {
Graph.repo.widgetConfig(appWidgetId)?.let { config = it }
diff --git a/wetter/app/src/main/java/app/wetter/widget/Widgets.kt b/wetter/app/src/main/java/app/wetter/widget/Widgets.kt
index eeacd53..2c46b48 100644
--- a/wetter/app/src/main/java/app/wetter/widget/Widgets.kt
+++ b/wetter/app/src/main/java/app/wetter/widget/Widgets.kt
@@ -54,22 +54,46 @@ import app.wetter.data.conditionOf
import app.wetter.ui.components.conditionLabel
import kotlinx.coroutines.flow.first
import kotlin.reflect.KClass
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.glance.appwidget.state.updateAppWidgetState
+import androidx.glance.currentState
+
+private val DATA_VERSION = longPreferencesKey("data_version")
+
+/** All widget types, paired with their receivers. */
+val ALL_WIDGETS: List WeatherWidget, KClass>> = listOf(
+ ::WeatherCardWidget to WeatherCardReceiver::class,
+ ::PillWidget to PillReceiver::class,
+ ::TiltWidget to TiltReceiver::class,
+ ::BarWidget to BarReceiver::class,
+ ::ClassicWidget to ClassicReceiver::class,
+ ::ClockWidget to ClockReceiver::class,
+)
+
+/**
+ * Glance's update() only recomposes a running session, it does not run provideGlance again.
+ * Bumping a value in the widget's state makes the composition reload settings and forecast.
+ */
+suspend fun refreshWidget(context: Context, widget: GlanceAppWidget, id: GlanceId) {
+ updateAppWidgetState(context, id) { it[DATA_VERSION] = System.nanoTime() }
+ widget.update(context, id)
+}
suspend fun updateAllWidgets(context: Context) {
- WeatherCardWidget().updateAll(context)
- PillWidget().updateAll(context)
- TiltWidget().updateAll(context)
- BarWidget().updateAll(context)
- ClassicWidget().updateAll(context)
+ val manager = GlanceAppWidgetManager(context)
+ for ((make, receiver) in ALL_WIDGETS) {
+ val widget = make()
+ manager.getGlanceIds(widget.javaClass).forEach { runCatching { refreshWidget(context, widget, it) } }
+ }
}
/** Picker previews (Android 15+). The system rate-limits this, so failures are ignored. */
suspend fun publishWidgetPreviews(context: Context) {
if (Build.VERSION.SDK_INT < 35) return
val manager = GlanceAppWidgetManager(context)
- listOf>(
- WeatherCardReceiver::class, PillReceiver::class, TiltReceiver::class, BarReceiver::class, ClassicReceiver::class,
- ).forEach { runCatching { manager.setWidgetPreviews(it) } }
+ ALL_WIDGETS.forEach { (_, receiver) -> runCatching { manager.setWidgetPreviews(receiver) } }
}
class WidgetData(val location: LocationEntity?, val forecast: Forecast?, val units: Units, val palette: WidgetPalette) {
@@ -81,7 +105,7 @@ object WidgetPreviewOverride {
@Volatile var config: app.wetter.data.WidgetConfigEntity? = null
}
-suspend fun loadWidgetData(context: Context, id: GlanceId?): WidgetData {
+suspend fun loadWidgetData(context: Context, id: GlanceId?, defaultStyle: WidgetStyle = WidgetStyle()): WidgetData {
Graph.init(context)
// ids that are not real widgets (previews, the debug gallery) fall back to the current location
val appWidgetId = id?.let { runCatching { GlanceAppWidgetManager(context).getAppWidgetId(it) }.getOrNull() } ?: -1
@@ -89,19 +113,28 @@ suspend fun loadWidgetData(context: Context, id: GlanceId?): WidgetData {
val location = config?.locationId?.let { Graph.repo.location(it) } ?: Graph.repo.widgetLocation(appWidgetId)
val forecast = location?.let { Graph.repo.forecastOnce(it.id) }
val settings = runCatching { Graph.settings.settings.first() }.getOrDefault(Settings())
- val style = config?.let { WidgetStyle(it.style, it.theme, it.opacity) } ?: WidgetStyle()
+ val style = config?.let { WidgetStyle(it.style, it.theme, it.opacity) } ?: defaultStyle
return WidgetData(location, forecast, Units(settings), resolvePalette(context, style, forecast))
}
/** Base: loads data once per update, renders [Content], and offers the same content as picker preview. */
abstract class WeatherWidget : GlanceAppWidget() {
+ /** Style used until the person configures this widget. */
+ open val defaultStyle: WidgetStyle get() = WidgetStyle()
+
override suspend fun provideGlance(context: Context, id: GlanceId) {
- val data = loadWidgetData(context, id)
- provideContent { Root(data) }
+ val first = loadWidgetData(context, id, defaultStyle)
+ provideContent {
+ val version = currentState(DATA_VERSION) ?: 0L
+ val data by produceState(first, version) { if (version != 0L) value = loadWidgetData(context, id, defaultStyle) }
+ Root(data)
+ }
}
override suspend fun providePreview(context: Context, widgetCategory: Int) {
- val data = loadWidgetData(context, null)
+ // a transparent widget would vanish against the picker, so previews always get a background
+ val style = defaultStyle.let { if (it.opacity < 100) it.copy(opacity = 100) else it }
+ val data = loadWidgetData(context, null, style)
provideContent { Root(data) }
}
@@ -404,4 +437,37 @@ class WeatherCardReceiver : GlanceAppWidgetReceiver() { override val glanceAppWi
class PillReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = PillWidget() }
class TiltReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = TiltWidget() }
class BarReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = BarWidget() }
+class ClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = ClockWidget() }
class ClassicReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = ClassicWidget() }
+
+// ---------------------------------------------------------------------------------------------
+// 6. Clock: big live time, date and the current weather. Transparent by default, for a clean home screen
+// ---------------------------------------------------------------------------------------------
+
+class ClockWidget : WeatherWidget() {
+ override val sizeMode = SizeMode.Exact
+ override val defaultStyle = WidgetStyle(palette = WidgetStyle.WALLPAPER, theme = WidgetStyle.DARK, opacity = 0)
+
+ @Composable
+ override fun Content(data: WidgetData, f: Forecast) {
+ val ctx = LocalContext.current
+ val p = data.palette
+ val u = data.units
+ val size = LocalSize.current
+ // on a bare wallpaper white text with a soft shadow reads best; with a background use the palette
+ val fg = if (p.bg.alpha < .3f) Color.White else p.onBg
+ val timeSp = (size.height.value * .42f).coerceIn(40f, 96f)
+ val views = android.widget.RemoteViews(ctx.packageName, R.layout.widget_clock).apply {
+ val c = android.graphics.Color.argb((fg.alpha * 255).toInt(), (fg.red * 255).toInt(), (fg.green * 255).toInt(), (fg.blue * 255).toInt())
+ listOf(R.id.clock_time, R.id.clock_date, R.id.clock_sep, R.id.clock_temp).forEach { setTextColor(it, c) }
+ setTextViewTextSize(R.id.clock_time, android.util.TypedValue.COMPLEX_UNIT_SP, timeSp)
+ setImageViewBitmap(R.id.clock_icon, WidgetArt.icon(conditionOf(f.current.code), f.current.isDay, 96))
+ setTextViewText(R.id.clock_temp, u.temp(f.current.temp))
+ setContentDescription(R.id.clock_temp, "${u.temp(f.current.temp)} ${conditionText(f)}")
+ }
+ Box(
+ GlanceModifier.fillMaxSize().cornerRadius(28.dp).background(p.bg).clickable(open(data)),
+ contentAlignment = Alignment.Center,
+ ) { androidx.glance.appwidget.AndroidRemoteViews(views) }
+ }
+}
diff --git a/wetter/app/src/main/res/drawable-nodpi/preview_bar.png b/wetter/app/src/main/res/drawable-nodpi/preview_bar.png
new file mode 100644
index 0000000..df929ab
Binary files /dev/null and b/wetter/app/src/main/res/drawable-nodpi/preview_bar.png differ
diff --git a/wetter/app/src/main/res/drawable-nodpi/preview_card.png b/wetter/app/src/main/res/drawable-nodpi/preview_card.png
new file mode 100644
index 0000000..d315c31
Binary files /dev/null and b/wetter/app/src/main/res/drawable-nodpi/preview_card.png differ
diff --git a/wetter/app/src/main/res/drawable-nodpi/preview_classic.png b/wetter/app/src/main/res/drawable-nodpi/preview_classic.png
new file mode 100644
index 0000000..43d3ea8
Binary files /dev/null and b/wetter/app/src/main/res/drawable-nodpi/preview_classic.png differ
diff --git a/wetter/app/src/main/res/drawable-nodpi/preview_clock.png b/wetter/app/src/main/res/drawable-nodpi/preview_clock.png
new file mode 100644
index 0000000..cfa1c29
Binary files /dev/null and b/wetter/app/src/main/res/drawable-nodpi/preview_clock.png differ
diff --git a/wetter/app/src/main/res/drawable-nodpi/preview_pill.png b/wetter/app/src/main/res/drawable-nodpi/preview_pill.png
new file mode 100644
index 0000000..79374a8
Binary files /dev/null and b/wetter/app/src/main/res/drawable-nodpi/preview_pill.png differ
diff --git a/wetter/app/src/main/res/drawable-nodpi/preview_tilt.png b/wetter/app/src/main/res/drawable-nodpi/preview_tilt.png
new file mode 100644
index 0000000..6b073fe
Binary files /dev/null and b/wetter/app/src/main/res/drawable-nodpi/preview_tilt.png differ
diff --git a/wetter/app/src/main/res/layout/widget_clock.xml b/wetter/app/src/main/res/layout/widget_clock.xml
new file mode 100644
index 0000000..b459f2d
--- /dev/null
+++ b/wetter/app/src/main/res/layout/widget_clock.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wetter/app/src/main/res/values/strings.xml b/wetter/app/src/main/res/values/strings.xml
index 38c546a..544cd98 100644
--- a/wetter/app/src/main/res/values/strings.xml
+++ b/wetter/app/src/main/res/values/strings.xml
@@ -1,6 +1,5 @@
- Wetter
Sonnig
Klar
@@ -133,6 +132,8 @@
- Wallpaper
- Wetterlage
- Ozean
- Wald
- Sonne
- Lavendel
- Graphit
+ Uhr & Wetter
+ Große Uhrzeit, Datum und das aktuelle Wetter
Quelle: Open-Meteo
Ort wählen
Mein Standort (folgt dir)
diff --git a/wetter/app/src/main/res/xml/widget_bar.xml b/wetter/app/src/main/res/xml/widget_bar.xml
index c8eb2e4..90d6ad0 100644
--- a/wetter/app/src/main/res/xml/widget_bar.xml
+++ b/wetter/app/src/main/res/xml/widget_bar.xml
@@ -1,6 +1,7 @@
+
diff --git a/wetter/app/src/main/res/xml/widget_pill.xml b/wetter/app/src/main/res/xml/widget_pill.xml
index b54cdab..e1cfb89 100644
--- a/wetter/app/src/main/res/xml/widget_pill.xml
+++ b/wetter/app/src/main/res/xml/widget_pill.xml
@@ -1,6 +1,7 @@