Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5c9bdf85 | ||
|
|
d1d8d01dd0 |
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **Coming next:** severe weather alerts (DWD / MeteoAlarm), precipitation radar map, card reordering, notifications.
|
||||||
|
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/S01-clear-day.png" width="200" alt="Home, sunny">
|
||||||
|
<img src="docs/screenshots/S01-clear-night-dark.png" width="200" alt="Home, clear night, dark mode">
|
||||||
|
<img src="docs/screenshots/S01-rain-nowcast.png" width="200" alt="Rain with the next-hours precipitation card">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/S01-full-light.png" width="200" alt="Full forecast with detail tiles">
|
||||||
|
<img src="docs/screenshots/S04-day-dark.png" width="200" alt="Day detail">
|
||||||
|
<img src="docs/screenshots/S08-settings.png" width="200" alt="Settings">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/widgets.png" width="380" alt="Home-screen widgets: clock, weather card, pill, bar, classic, tilted pill">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<sub>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.</sub>
|
||||||
|
|
||||||
Requires Android 10 (API 29) or newer.
|
Requires Android 10 (API 29) or newer.
|
||||||
|
|
||||||
## Install with Obtainium
|
## Install with Obtainium
|
||||||
@@ -93,7 +110,7 @@ cd wetter
|
|||||||
./gradlew :app:recordRoborazziDebug # re-render replica/clone-screens/
|
./gradlew :app:recordRoborazziDebug # re-render replica/clone-screens/
|
||||||
```
|
```
|
||||||
|
|
||||||
Local release builds are signed with the debug key so they install over each other. Published releases will use a dedicated signing key.
|
Release builds are signed with the project key from `~/.android/nogoogleapps-release.properties` (kept outside the repository); without it they fall back to the debug key for local testing. `scripts/release.sh <app>` builds, signs and publishes a release in the format Obtainium expects.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build, sign and publish one app as a Gitea release that Obtainium can find.
|
||||||
|
# Usage: scripts/release.sh wetter (version comes from app/build.gradle.kts)
|
||||||
|
# Needs: .giteatoken in the repo root (git-ignored) and ~/.android/nogoogleapps-release.properties
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
app="${1:?app folder, e.g. wetter}"
|
||||||
|
title="$(tr '[:lower:]' '[:upper:]' <<< "${app:0:1}")${app:1}"
|
||||||
|
version="$(sed -nE 's/.*versionName = "([^"]+)".*/\1/p' "$app/app/build.gradle.kts")"
|
||||||
|
tag="$app-v$version"
|
||||||
|
api="https://git.mrblake.cc/api/v1/repos/MrBlake/NoGooleApps"
|
||||||
|
token="$(tr -d ' \n' < .giteatoken)"
|
||||||
|
[ -f ~/.android/nogoogleapps-release.properties ] || { echo "release key missing" >&2; exit 1; }
|
||||||
|
|
||||||
|
(cd "$app" && ./gradlew -q :app:assembleRelease)
|
||||||
|
apk="$app/app/build/outputs/apk/release/app-release.apk"
|
||||||
|
"${ANDROID_HOME:-$HOME/Android/Sdk}"/build-tools/36.0.0/apksigner verify "$apk"
|
||||||
|
|
||||||
|
git -c credential.helper= push -q "https://MrBlake:$token@git.mrblake.cc/MrBlake/NoGooleApps.git" HEAD:main
|
||||||
|
|
||||||
|
body="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s %s","prerelease":true}' "$tag" "$(git rev-parse HEAD)" "$title" "$version")"
|
||||||
|
id="$(curl -sf -X POST -H "Authorization: token $token" -H "Content-Type: application/json" -d "$body" "$api/releases" \
|
||||||
|
| python3 -I -c 'import sys,json; print(json.load(sys.stdin)["id"])')"
|
||||||
|
curl -sf -X POST -H "Authorization: token $token" \
|
||||||
|
-F "attachment=@$apk;type=application/vnd.android.package-archive" \
|
||||||
|
"$api/releases/$id/assets?name=$app-$version.apk" > /dev/null
|
||||||
|
echo "published $title $version ($tag)"
|
||||||
@@ -17,8 +17,8 @@ android {
|
|||||||
applicationId = "app.wetter"
|
applicationId = "app.wetter"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "0.1.0"
|
versionName = "0.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
|
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
|
||||||
@@ -36,7 +36,13 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
// installs next to the release app from Obtainium
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
resValue("string", "app_name", "Wetter (Debug)")
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
|
resValue("string", "app_name", "Wetter")
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class WidgetGalleryActivity : androidx.activity.ComponentActivity() {
|
|||||||
PillWidget() to DpSize(170.dp, 64.dp),
|
PillWidget() to DpSize(170.dp, 64.dp),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if (intent.getBooleanExtra("export", false)) { exportPreviews(column); return }
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
for ((widget, size) in pages[page.coerceIn(0, pages.lastIndex)]) {
|
for ((widget, size) in pages[page.coerceIn(0, pages.lastIndex)]) {
|
||||||
val views = widget.compose(this@WidgetGalleryActivity, size = size)
|
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_<key>.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<Pair<String, FrameLayout>>()
|
||||||
|
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: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt()
|
||||||
private fun px(dp: Int) = px(dp.toFloat())
|
private fun px(dp: Int) = px(dp.toFloat())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /></intent-filter>
|
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /></intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<receiver android:name=".widget.ClockReceiver" android:exported="true" android:label="@string/widget_clock_name">
|
||||||
|
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_clock" />
|
||||||
|
</receiver>
|
||||||
<receiver android:name=".widget.WeatherCardReceiver" android:exported="true" android:label="@string/widget_card_name">
|
<receiver android:name=".widget.WeatherCardReceiver" android:exported="true" android:label="@string/widget_card_name">
|
||||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_card" />
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_card" />
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class WidgetConfigActivity : ComponentActivity() {
|
|||||||
ConfigScreen(appWidgetId, widget, size) { config ->
|
ConfigScreen(appWidgetId, widget, size) { config ->
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
Graph.repo.saveWidgetConfig(config)
|
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))
|
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,7 @@ class WidgetConfigActivity : ComponentActivity() {
|
|||||||
providerClass.endsWith("PillReceiver") -> PillWidget() to DpSize(300.dp, 130.dp)
|
providerClass.endsWith("PillReceiver") -> PillWidget() to DpSize(300.dp, 130.dp)
|
||||||
providerClass.endsWith("TiltReceiver") -> TiltWidget() to DpSize(170.dp, 170.dp)
|
providerClass.endsWith("TiltReceiver") -> TiltWidget() to DpSize(170.dp, 170.dp)
|
||||||
providerClass.endsWith("BarReceiver") -> BarWidget() to DpSize(320.dp, 84.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)
|
providerClass.endsWith("ClassicReceiver") -> ClassicWidget() to DpSize(320.dp, 160.dp)
|
||||||
else -> WeatherCardWidget() to DpSize(320.dp, 240.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) {
|
private fun ConfigScreen(appWidgetId: Int, widget: GlanceAppWidget, size: DpSize, onDone: (WidgetConfigEntity) -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val locations by Graph.repo.locations.collectAsState(initial = emptyList())
|
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) }
|
var loaded by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(appWidgetId) {
|
LaunchedEffect(appWidgetId) {
|
||||||
Graph.repo.widgetConfig(appWidgetId)?.let { config = it }
|
Graph.repo.widgetConfig(appWidgetId)?.let { config = it }
|
||||||
|
|||||||
@@ -54,22 +54,46 @@ import app.wetter.data.conditionOf
|
|||||||
import app.wetter.ui.components.conditionLabel
|
import app.wetter.ui.components.conditionLabel
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlin.reflect.KClass
|
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<Pair<() -> WeatherWidget, KClass<out GlanceAppWidgetReceiver>>> = 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) {
|
suspend fun updateAllWidgets(context: Context) {
|
||||||
WeatherCardWidget().updateAll(context)
|
val manager = GlanceAppWidgetManager(context)
|
||||||
PillWidget().updateAll(context)
|
for ((make, receiver) in ALL_WIDGETS) {
|
||||||
TiltWidget().updateAll(context)
|
val widget = make()
|
||||||
BarWidget().updateAll(context)
|
manager.getGlanceIds(widget.javaClass).forEach { runCatching { refreshWidget(context, widget, it) } }
|
||||||
ClassicWidget().updateAll(context)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Picker previews (Android 15+). The system rate-limits this, so failures are ignored. */
|
/** Picker previews (Android 15+). The system rate-limits this, so failures are ignored. */
|
||||||
suspend fun publishWidgetPreviews(context: Context) {
|
suspend fun publishWidgetPreviews(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < 35) return
|
if (Build.VERSION.SDK_INT < 35) return
|
||||||
val manager = GlanceAppWidgetManager(context)
|
val manager = GlanceAppWidgetManager(context)
|
||||||
listOf<KClass<out GlanceAppWidgetReceiver>>(
|
ALL_WIDGETS.forEach { (_, receiver) -> runCatching { manager.setWidgetPreviews(receiver) } }
|
||||||
WeatherCardReceiver::class, PillReceiver::class, TiltReceiver::class, BarReceiver::class, ClassicReceiver::class,
|
|
||||||
).forEach { runCatching { manager.setWidgetPreviews(it) } }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class WidgetData(val location: LocationEntity?, val forecast: Forecast?, val units: Units, val palette: WidgetPalette) {
|
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
|
@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)
|
Graph.init(context)
|
||||||
// ids that are not real widgets (previews, the debug gallery) fall back to the current location
|
// 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
|
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 location = config?.locationId?.let { Graph.repo.location(it) } ?: Graph.repo.widgetLocation(appWidgetId)
|
||||||
val forecast = location?.let { Graph.repo.forecastOnce(it.id) }
|
val forecast = location?.let { Graph.repo.forecastOnce(it.id) }
|
||||||
val settings = runCatching { Graph.settings.settings.first() }.getOrDefault(Settings())
|
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))
|
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. */
|
/** Base: loads data once per update, renders [Content], and offers the same content as picker preview. */
|
||||||
abstract class WeatherWidget : GlanceAppWidget() {
|
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) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = loadWidgetData(context, id)
|
val first = loadWidgetData(context, id, defaultStyle)
|
||||||
provideContent { Root(data) }
|
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) {
|
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) }
|
provideContent { Root(data) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,4 +437,37 @@ class WeatherCardReceiver : GlanceAppWidgetReceiver() { override val glanceAppWi
|
|||||||
class PillReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = PillWidget() }
|
class PillReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = PillWidget() }
|
||||||
class TiltReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = TiltWidget() }
|
class TiltReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = TiltWidget() }
|
||||||
class BarReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = BarWidget() }
|
class BarReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = BarWidget() }
|
||||||
|
class ClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = ClockWidget() }
|
||||||
class ClassicReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = ClassicWidget() }
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent" android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical" android:gravity="center" android:padding="12dp">
|
||||||
|
|
||||||
|
<TextClock
|
||||||
|
android:id="@+id/clock_time"
|
||||||
|
android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||||
|
android:format24Hour="HH:mm" android:format12Hour="h:mm"
|
||||||
|
android:fontFamily="sans-serif-light" android:textSize="72sp" android:includeFontPadding="false"
|
||||||
|
android:letterSpacing="-0.02"
|
||||||
|
android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal" android:gravity="center_vertical" android:layout_marginTop="4dp">
|
||||||
|
|
||||||
|
<TextClock
|
||||||
|
android:id="@+id/clock_date"
|
||||||
|
android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||||
|
android:format24Hour="EEE, d. MMM" android:format12Hour="EEE, MMM d"
|
||||||
|
android:fontFamily="sans-serif-medium" android:textSize="16sp"
|
||||||
|
android:shadowColor="#66000000" android:shadowRadius="6" android:shadowDy="1" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/clock_sep"
|
||||||
|
android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||||
|
android:text=" · " android:textSize="16sp"
|
||||||
|
android:shadowColor="#66000000" android:shadowRadius="6" android:shadowDy="1" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/clock_icon"
|
||||||
|
android:layout_width="22dp" android:layout_height="22dp" android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/clock_temp"
|
||||||
|
android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:fontFamily="sans-serif-medium" android:textSize="16sp"
|
||||||
|
android:shadowColor="#66000000" android:shadowRadius="6" android:shadowDy="1" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Wetter</string>
|
|
||||||
|
|
||||||
<string name="cond_clear_day">Sonnig</string>
|
<string name="cond_clear_day">Sonnig</string>
|
||||||
<string name="cond_clear_night">Klar</string>
|
<string name="cond_clear_night">Klar</string>
|
||||||
@@ -133,6 +132,8 @@
|
|||||||
<string-array name="widget_palettes">
|
<string-array name="widget_palettes">
|
||||||
<item>Wallpaper</item><item>Wetterlage</item><item>Ozean</item><item>Wald</item><item>Sonne</item><item>Lavendel</item><item>Graphit</item>
|
<item>Wallpaper</item><item>Wetterlage</item><item>Ozean</item><item>Wald</item><item>Sonne</item><item>Lavendel</item><item>Graphit</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<string name="widget_clock_name">Uhr & Wetter</string>
|
||||||
|
<string name="widget_clock_desc">Große Uhrzeit, Datum und das aktuelle Wetter</string>
|
||||||
<string name="widget_source">Quelle: Open-Meteo</string>
|
<string name="widget_source">Quelle: Open-Meteo</string>
|
||||||
<string name="widget_pick">Ort wählen</string>
|
<string name="widget_pick">Ort wählen</string>
|
||||||
<string name="widget_follow">Mein Standort (folgt dir)</string>
|
<string name="widget_follow">Mein Standort (folgt dir)</string>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:description="@string/widget_bar_desc"
|
android:description="@string/widget_bar_desc"
|
||||||
|
android:previewImage="@drawable/preview_bar"
|
||||||
android:initialLayout="@layout/glance_default_loading_layout"
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
android:minWidth="250dp" android:minHeight="60dp"
|
android:minWidth="250dp" android:minHeight="60dp"
|
||||||
android:targetCellWidth="4" android:targetCellHeight="1"
|
android:targetCellWidth="4" android:targetCellHeight="1"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:description="@string/widget_card_desc"
|
android:description="@string/widget_card_desc"
|
||||||
|
android:previewImage="@drawable/preview_card"
|
||||||
android:initialLayout="@layout/glance_default_loading_layout"
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
android:minWidth="250dp" android:minHeight="180dp"
|
android:minWidth="250dp" android:minHeight="180dp"
|
||||||
android:targetCellWidth="4" android:targetCellHeight="2"
|
android:targetCellWidth="4" android:targetCellHeight="2"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:description="@string/widget_classic_desc"
|
android:description="@string/widget_classic_desc"
|
||||||
|
android:previewImage="@drawable/preview_classic"
|
||||||
android:initialLayout="@layout/glance_default_loading_layout"
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
android:minWidth="250dp" android:minHeight="110dp"
|
android:minWidth="250dp" android:minHeight="150dp"
|
||||||
android:targetCellWidth="4" android:targetCellHeight="2"
|
android:targetCellWidth="4" android:targetCellHeight="2"
|
||||||
android:minResizeWidth="140dp" android:minResizeHeight="110dp"
|
android:minResizeWidth="140dp" android:minResizeHeight="150dp"
|
||||||
android:maxResizeWidth="400dp" android:maxResizeHeight="250dp"
|
android:maxResizeWidth="400dp" android:maxResizeHeight="250dp"
|
||||||
android:resizeMode="horizontal|vertical"
|
android:resizeMode="horizontal|vertical"
|
||||||
android:updatePeriodMillis="3600000"
|
android:updatePeriodMillis="3600000"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:description="@string/widget_clock_desc"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
|
android:previewImage="@drawable/preview_clock"
|
||||||
|
android:minWidth="250dp" android:minHeight="110dp"
|
||||||
|
android:targetCellWidth="4" android:targetCellHeight="2"
|
||||||
|
android:minResizeWidth="180dp" android:minResizeHeight="80dp"
|
||||||
|
android:maxResizeWidth="600dp" android:maxResizeHeight="300dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:updatePeriodMillis="3600000"
|
||||||
|
android:configure="app.wetter.widget.WidgetConfigActivity"
|
||||||
|
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||||
|
android:widgetCategory="home_screen|keyguard" />
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:description="@string/widget_pill_desc"
|
android:description="@string/widget_pill_desc"
|
||||||
|
android:previewImage="@drawable/preview_pill"
|
||||||
android:initialLayout="@layout/glance_default_loading_layout"
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
android:minWidth="200dp" android:minHeight="110dp"
|
android:minWidth="200dp" android:minHeight="110dp"
|
||||||
android:targetCellWidth="4" android:targetCellHeight="2"
|
android:targetCellWidth="4" android:targetCellHeight="2"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:description="@string/widget_tilt_desc"
|
android:description="@string/widget_tilt_desc"
|
||||||
|
android:previewImage="@drawable/preview_tilt"
|
||||||
android:initialLayout="@layout/glance_default_loading_layout"
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
android:minWidth="110dp" android:minHeight="110dp"
|
android:minWidth="110dp" android:minHeight="110dp"
|
||||||
android:targetCellWidth="2" android:targetCellHeight="2"
|
android:targetCellWidth="2" android:targetCellHeight="2"
|
||||||
|
|||||||