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. + +

+ Home, sunny + Home, clear night, dark mode + Rain with the next-hours precipitation card +

+

+ Full forecast with detail tiles + Day detail + Settings +

+

+ Home-screen widgets: clock, weather card, pill, bar, classic, tilted pill +

+ +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 @@ WallpaperWetterlageOzeanWaldSonneLavendelGraphit + 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 @@