Skip to content

Commit c352050

Browse files
committed
feat: Android widget visual parity with iOS + live config preview
Ports LoadpointVM's full status/metric logic (heating, finished/ waitForVehicle, kWh fallback), status dot + color coding, a canvas-rendered progress bar (Glance has no fractional-width modifier), chip-style mode buttons, a two-column forecast header, a chart Y-axis + step-vs-area modes + per-type color (previously always a flat green line), bold/colored footer stats, and day/night theming throughout - all mirrored from LoadpointViews.swift / Views.swift / Theme.swift. Both widget config Activities now fetch real data for the tapped choice and show an actual preview of the widget (plain Views reusing the same chart/ progress-bar bitmaps, since a live Glance render would need pulling in the full Compose UI stack) before committing via a new "Use this" button, instead of committing immediately on tap with no preview. Verified with expo prebuild + local assembleDebug/assembleRelease builds.
1 parent 52f7788 commit c352050

11 files changed

Lines changed: 1091 additions & 238 deletions

targets/android-widget/README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,39 @@ Done:
3131
local Expo native module) exposes `refresh()`, called from
3232
`utils/widgetRefresh.ts` after `widgetSync.ts` writes the file — no need to
3333
wait for the periodic `updatePeriodMillis` tick.
34-
- `kotlin/Theme.kt` — brand colors / text styles.
34+
- `kotlin/Theme.kt` — day/night colors (mirrors iOS's `scheme == .dark`
35+
branches), full typography scale, per-forecast-type palette (mirrors
36+
`Theme.swift`'s `Palette.make`).
3537
- `scripts/androidWidget/withAndroidWidget.ts` — Expo config plugin: injects the
3638
Kotlin, the `res/xml` widget info, the manifest `<receiver>`/`<activity>`
3739
entries, and the Glance/Compose gradle wiring. Registered in `app.config.ts`.
40+
- **Visual parity with iOS** (mirrors `LoadpointViews.swift`/`Views.swift`):
41+
status dot + color-coded status text, a rounded/striped progress bar
42+
(`ProgressBarRenderer.kt`, since Glance has no fractional-width layout
43+
modifier), chip-style mode buttons with a selected-state fill, full
44+
heating/finished/waitForVehicle status + kWh-fallback metric logic ported
45+
from `LoadpointVM.build`, a two-column forecast header, a Y-axis +
46+
step-vs-area chart modes + per-type color in `ChartRenderer.kt` (previously
47+
always a flat green area line regardless of data type), bold/colored footer
48+
stats, and light/dark card backgrounds throughout. Deliberately not ported:
49+
size variants (`systemMedium`'s mode-selector column - the mode chips are
50+
always shown inline instead), the reload button, deep links, and Swift
51+
Charts' `.monotone` spline smoothing (straight line segments instead).
52+
- **Live preview when configuring**: both config Activities now fetch real
53+
data for the tapped server/loadpoint/toggle and render an actual preview of
54+
the widget (`WidgetPreview.kt`) before committing via a new "Use this"
55+
button - previously the pick-a-row tap committed immediately with no
56+
preview. Built with plain Views (reusing `ChartRenderer`/`ProgressBarRenderer`
57+
bitmaps) rather than a live Glance render, since embedding real Glance
58+
content in a classic-Views Activity needs the full Compose UI stack plus an
59+
unpublished/experimental Google API - see the "Live preview" discussion this
60+
was scoped from for the trade-off.
3861

3962
Not done yet (follow-ups for parity with iOS):
4063

4164
- Localization (`.xcstrings` → Android string resources) — widget text is
4265
currently hardcoded English in the Kotlin.
43-
- Size variants, full visual parity with the iOS widgets.
66+
- Size variants (see above).
4467

4568
## Build / test
4669

targets/android-widget/kotlin/ApiClient.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,32 +87,49 @@ object ApiClient {
8787
}
8888
}
8989

90+
/** Subset of /api/state .loadpoints[].ui used by the widget (see Loadpoint.swift). */
91+
data class LoadpointUi(val minTemp: Double?, val maxTemp: Double?)
92+
9093
/** Subset of /api/state .loadpoints[] used by the widget (see Loadpoint.swift). */
9194
data class Loadpoint(
9295
val title: String?,
9396
val vehicleTitle: String?,
9497
val vehicleSoc: Double?,
9598
val effectiveLimitSoc: Double?,
9699
val chargePower: Double?,
100+
val sessionEnergy: Double?,
101+
val chargedEnergy: Double?,
97102
val mode: String?,
98103
val charging: Boolean,
99104
val connected: Boolean,
100105
val enabled: Boolean,
106+
val chargerFeatureHeating: Boolean,
107+
val chargerFeatureSwitchDevice: Boolean,
108+
val ui: LoadpointUi?,
101109
) {
102110
companion object {
103111
fun parse(json: String): Loadpoint? = runCatching {
104112
val o = JSONObject(json)
105113
fun d(k: String) = if (o.has(k) && !o.isNull(k)) o.optDouble(k) else null
114+
val ui = o.optJSONObject("ui")?.let {
115+
fun uiD(k: String) = if (it.has(k) && !it.isNull(k)) it.optDouble(k) else null
116+
LoadpointUi(minTemp = uiD("minTemp"), maxTemp = uiD("maxTemp"))
117+
}
106118
Loadpoint(
107119
title = o.optString("title").takeIf { it.isNotEmpty() },
108120
vehicleTitle = o.optString("vehicleTitle").takeIf { it.isNotEmpty() },
109121
vehicleSoc = d("vehicleSoc"),
110122
effectiveLimitSoc = d("effectiveLimitSoc"),
111123
chargePower = d("chargePower"),
124+
sessionEnergy = d("sessionEnergy"),
125+
chargedEnergy = d("chargedEnergy"),
112126
mode = o.optString("mode").takeIf { it.isNotEmpty() },
113127
charging = o.optBoolean("charging", false),
114128
connected = o.optBoolean("connected", false),
115129
enabled = o.optBoolean("enabled", false),
130+
chargerFeatureHeating = o.optBoolean("chargerFeatureHeating", false),
131+
chargerFeatureSwitchDevice = o.optBoolean("chargerFeatureSwitchDevice", false),
132+
ui = ui,
116133
)
117134
}.getOrNull()
118135
}
Lines changed: 111 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,82 @@
11
package io.evcc.android.widget
22

3+
import android.content.Context
34
import android.graphics.Bitmap
45
import android.graphics.Canvas
56
import android.graphics.Paint
67
import android.graphics.Path
8+
import androidx.glance.color.isNightMode
79
import java.text.SimpleDateFormat
810
import java.util.Calendar
911
import java.util.Date
1012
import java.util.Locale
13+
import kotlin.math.ceil
14+
15+
/** Mirrors ChartKind in Views.swift: area = solar (monotone-ish line + fill),
16+
* step = price/CO2 (stepEnd line, no fill), stepArea = feed-in (stepEnd + fill). */
17+
enum class ChartKind { AREA, STEP, STEP_AREA }
1118

1219
/**
13-
* Renders a forecast series to a Bitmap (line + translucent area fill, with
14-
* local-midnight day dividers and weekday labels), shown in the widget via a
20+
* Renders a forecast series to a Bitmap (line + optional area fill, a Y axis,
21+
* local-midnight day dividers, and weekday labels), shown in the widget via a
1522
* Glance Image. Glance has no chart primitive, so this Canvas bitmap is how we
16-
* approximate the iOS Swift Charts look.
23+
* approximate the iOS Swift Charts look (see ForecastChart in Views.swift) -
24+
* straight line segments rather than Swift Charts' `.monotone` spline
25+
* smoothing is a known simplification.
1726
*/
1827
object ChartRenderer {
1928
private const val W = 720
2029
private const val H = 240
21-
22-
private val green = 0xFF0FDE41.toInt()
23-
private val fill = 0x330FDE41.toInt()
24-
private val divider = 0x33FFFFFF.toInt()
25-
private val labelColor = 0x99FFFFFF.toInt()
30+
private const val PAD_TOP = 18f
31+
private const val PAD_BOTTOM = 30f
32+
private const val PAD_LEFT = 32f
2633

2734
/** values and times must be index-aligned; times in epoch millis (may be empty). */
28-
fun render(values: List<Double>, times: List<Long>): Bitmap {
35+
fun render(
36+
context: Context,
37+
values: List<Double>,
38+
times: List<Long>,
39+
kind: ChartKind,
40+
accentDay: Int,
41+
accentNight: Int,
42+
): Bitmap {
2943
val bmp = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
3044
val canvas = Canvas(bmp)
3145
if (values.size < 2) return bmp
3246

33-
val minV = values.min()
34-
val maxV = values.max()
35-
val span = (maxV - minV).let { if (it <= 0.0) 1.0 else it }
47+
val dark = context.isNightMode
48+
val accent = if (dark) accentNight else accentDay
49+
val fillColor = (accent and 0x00FFFFFF) or 0x33000000
50+
val dividerColor = if (dark) 0x33FFFFFF.toInt() else 0x22000000.toInt()
51+
val labelColor = if (dark) 0x99FFFFFF.toInt() else 0x99000000.toInt()
52+
val zeroLineColor = if (dark) 0x40FFFFFF.toInt() else 0x33000000.toInt()
3653

37-
val padTop = 18f
38-
val padBottom = 30f
39-
val plotW = W.toFloat()
40-
val plotH = H - padTop - padBottom
54+
val axisBottom = minOf(0.0, values.min())
55+
val axisTop = axisTop(values)
56+
val span = (axisTop - axisBottom).let { if (it <= 0.0) 1.0 else it }
4157

42-
fun x(i: Int) = plotW * (i.toFloat() / (values.size - 1))
43-
fun y(v: Double) = padTop + plotH * (1f - ((v - minV) / span).toFloat())
58+
val plotW = W - PAD_LEFT
59+
val plotH = H - PAD_TOP - PAD_BOTTOM
60+
61+
fun x(i: Int) = PAD_LEFT + plotW * (i.toFloat() / (values.size - 1))
62+
fun y(v: Double) = PAD_TOP + plotH * (1f - ((v - axisBottom) / span).toFloat())
63+
64+
// Y axis: 0 line + min/max labels (mirrors chartYAxis in Views.swift)
65+
val axisLabelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
66+
color = labelColor
67+
textSize = 18f
68+
}
69+
canvas.drawLine(PAD_LEFT, y(0.0), W.toFloat(), y(0.0), Paint(Paint.ANTI_ALIAS_FLAG).apply {
70+
color = zeroLineColor
71+
strokeWidth = 1f
72+
})
73+
canvas.drawText(axisLabel(0.0), 2f, y(0.0) + 6f, axisLabelPaint)
74+
canvas.drawText(axisLabel(axisTop), 2f, y(axisTop) + 6f, axisLabelPaint)
4475

4576
// day dividers + weekday labels (drawn first, behind the series)
4677
if (times.size == values.size) {
4778
val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
48-
color = divider
79+
color = dividerColor
4980
strokeWidth = 1.5f
5081
}
5182
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@@ -61,39 +92,83 @@ object ChartRenderer {
6192
if (day != lastDay) {
6293
if (lastDay != -1) {
6394
val xx = x(i)
64-
canvas.drawLine(xx, padTop, xx, padTop + plotH, dividerPaint)
95+
canvas.drawLine(xx, PAD_TOP, xx, PAD_TOP + plotH, dividerPaint)
6596
canvas.drawText(weekday.format(Date(times[i])), xx + 6f, H - 8f, textPaint)
6697
}
6798
lastDay = day
6899
}
69100
}
70101
}
71102

72-
// area fill under the line
73-
val area = Path().apply {
74-
moveTo(x(0), padTop + plotH)
75-
for (i in values.indices) lineTo(x(i), y(values[i]))
76-
lineTo(x(values.size - 1), padTop + plotH)
77-
close()
103+
// area fill under the line (skipped for pure STEP, like iOS's `if kind != .step`)
104+
if (kind != ChartKind.STEP) {
105+
val area = if (kind == ChartKind.AREA) {
106+
areaPath(::x, ::y, values, y(axisBottom))
107+
} else {
108+
stepAreaPath(::x, ::y, values, y(axisBottom))
109+
}
110+
canvas.drawPath(area, Paint(Paint.ANTI_ALIAS_FLAG).apply {
111+
style = Paint.Style.FILL
112+
color = fillColor
113+
})
78114
}
79-
canvas.drawPath(area, Paint(Paint.ANTI_ALIAS_FLAG).apply {
80-
style = Paint.Style.FILL
81-
color = fill
82-
})
83115

84116
// series line
85-
val line = Path().apply {
86-
moveTo(x(0), y(values[0]))
87-
for (i in 1 until values.size) lineTo(x(i), y(values[i]))
88-
}
117+
val line = if (kind == ChartKind.AREA) linePath(::x, ::y, values) else stepLinePath(::x, ::y, values)
89118
canvas.drawPath(line, Paint(Paint.ANTI_ALIAS_FLAG).apply {
90119
style = Paint.Style.STROKE
91-
strokeWidth = 4f
92-
color = green
120+
strokeWidth = if (kind == ChartKind.AREA) 4.4f else 4f
121+
color = accent
93122
strokeJoin = Paint.Join.ROUND
94123
strokeCap = Paint.Cap.ROUND
95124
})
96125

97126
return bmp
98127
}
128+
129+
// labels: 0 + max, ceil to next integer; fractional (<1) series ceil to 0.1
130+
// so sub-unit currencies aren't flattened. Mirrors axisTop/axisBottom in
131+
// ForecastChart (Views.swift).
132+
private fun axisTop(values: List<Double>): Double {
133+
val m = values.maxOrNull() ?: 0.0
134+
if (m <= 0.0) return 1.0
135+
return if (m < 1.0) ceil(m * 10) / 10 else ceil(m)
136+
}
137+
138+
private fun axisLabel(v: Double): String =
139+
if (v == v.toLong().toDouble()) v.toLong().toString() else String.format(Locale.getDefault(), "%.1f", v)
140+
141+
private fun linePath(x: (Int) -> Float, y: (Double) -> Float, values: List<Double>): Path = Path().apply {
142+
moveTo(x(0), y(values[0]))
143+
for (i in 1 until values.size) lineTo(x(i), y(values[i]))
144+
}
145+
146+
private fun areaPath(x: (Int) -> Float, y: (Double) -> Float, values: List<Double>, baselineY: Float): Path =
147+
Path().apply {
148+
moveTo(x(0), baselineY)
149+
for (i in values.indices) lineTo(x(i), y(values[i]))
150+
lineTo(x(values.size - 1), baselineY)
151+
close()
152+
}
153+
154+
/** Staircase: horizontal to the next x at the current value, then a vertical jump. */
155+
private fun stepLinePath(x: (Int) -> Float, y: (Double) -> Float, values: List<Double>): Path = Path().apply {
156+
moveTo(x(0), y(values[0]))
157+
for (i in 1 until values.size) {
158+
lineTo(x(i), y(values[i - 1]))
159+
lineTo(x(i), y(values[i]))
160+
}
161+
}
162+
163+
private fun stepAreaPath(x: (Int) -> Float, y: (Double) -> Float, values: List<Double>, baselineY: Float): Path =
164+
Path().apply {
165+
moveTo(x(0), baselineY)
166+
lineTo(x(0), y(values[0]))
167+
for (i in 1 until values.size) {
168+
lineTo(x(i), y(values[i - 1]))
169+
lineTo(x(i), y(values[i]))
170+
}
171+
lineTo(x(values.size - 1), baselineY)
172+
close()
173+
}
99174
}

0 commit comments

Comments
 (0)