Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9a754ba
Widgets spec: five widgets in three uniform sizes
sayed0am Oct 24, 2026
a1602e2
Widgets spec: text in system sans, Funnel not used on widgets
sayed0am Oct 24, 2026
a6ec6d2
Widgets step 1: light, dots and charts drawn by shared code
sayed0am Oct 25, 2026
2b68810
Widgets spec: the Steps line ends in the card's own end dot
sayed0am Oct 25, 2026
08c8ea8
Widgets step 2: plumbing for widgets, none shown yet
sayed0am Oct 25, 2026
71b258e
Widgets spec: decisions from step 2 (waking fallback, sync copy, step…
sayed0am Oct 25, 2026
6c19f69
Widgets spec: keep SCHEDULE_EXACT_ALARM for Moment's boundaries
sayed0am Oct 25, 2026
94eadf6
Widgets step 3: the Recovery widget in three sizes
sayed0am Oct 25, 2026
98cc1fb
Widgets step 4: the Steps widget, and shared widget parts
sayed0am Oct 25, 2026
1a804e9
Say "Goal met" everywhere, app and widgets
sayed0am Oct 25, 2026
d325104
Widgets step 4: the Sleep widget
sayed0am Oct 25, 2026
f10956f
Start a workout widget: usual sport, next four, this week
sayed0am Oct 25, 2026
c870325
Start widget while recording: a live timer fills the card
sayed0am Oct 25, 2026
f46c854
Start widget Small says Paused while paused
sayed0am Oct 25, 2026
8553e11
Moment widget: every moment at every size, and the duplicate rule
sayed0am Oct 25, 2026
da6b963
Widgets: strap status line, picker previews, descriptions
sayed0am Oct 25, 2026
8181a2b
Widgets: status as a round sync button beside the title
sayed0am Oct 25, 2026
73afd07
Start Small with a status: title row and play beside the tile
sayed0am Oct 25, 2026
9a10386
Start Small with a status: 16 dp between the tile and play
sayed0am Oct 25, 2026
ffbe143
Moment: count ticks never wake the phone
sayed0am Oct 25, 2026
1e873ee
Widget gallery: clear leftover widget ids on start
sayed0am Oct 25, 2026
80cccca
Widgets render on their own thread, not the main one
sayed0am Oct 25, 2026
59bf189
Turn the widget gallery off, with how to turn it on in CLAUDE.md
sayed0am Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# OpenStrap

## Widget gallery

A debug-only screen that hosts each home screen widget at its three sizes, with forced moods, moments and strap
states. Its code stays in `app/src/debug/kotlin/org/openstrap/app/widget/debug/WidgetGalleryActivity.kt` (it still
compiles), but its activity is commented out in `app/src/debug/AndroidManifest.xml`, so it isn't installed: while
open, its widgets count as placed and refresh along with the real ones.

To test widgets again:

1. Uncomment the `WidgetGalleryActivity` `<activity>` in `app/src/debug/AndroidManifest.xml`.
2. Build and install the debug app (check no workout is recording first: a `workout` notification channel in
`adb shell dumpsys notification --noredact | grep "pkg=org.openstrap.app"`).
3. `adb shell appwidget grantbind --package org.openstrap.app --user 0`
4. `adb shell am start -f 0x00020000 -n org.openstrap.app/.widget.debug.WidgetGalleryActivity --es widget moment`
Extras: `widget` recovery|steps|sleep|start|moment, `mood` day|evening|night|auto, `cells` oneui|min,
`moment` a moment name or auto, `placed` kinds or none|auto, `strap` notpaired|low|stale|yesterday|week|syncing|none|auto,
`pin` a kind. A render takes 15 to 20 s.
5. When done, close it with Back (so it unbinds its widgets), then comment the activity out again.
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ dependencies {
implementation(libs.haze.blur)
// Workout maps: open source, no API key; tiles from OpenFreeMap (README, Network use).
implementation(libs.maplibre)
// Home screen widgets (docs/design/widgets/spec.md 6.1).
implementation(libs.glance.appwidget)
implementation(libs.work.runtime.ktx)

testImplementation(libs.junit)
// Android's org.json is a stub in plain JVM unit tests.
Expand Down
13 changes: 13 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,18 @@
<activity
android:name=".gallery.ComponentGalleryActivity"
android:exported="true" />
<!--
The home screen widgets at their three sizes, hosted for real. Off while no widget work is going on:
while open its widgets count as placed and refresh with the real ones. To turn it back on, uncomment
the activity below (CLAUDE.md, "Widget gallery"). Grant binding first
(adb shell appwidget grantbind, see WidgetGalleryActivity for the whole command), then
adb shell am start -n org.openstrap.app/.widget.debug.WidgetGalleryActivity
with the string extras widget (recovery, steps), mood (day, evening, night, auto) and pin (a widget kind).

<activity
android:name=".widget.debug.WidgetGalleryActivity"
android:exported="true"
android:launchMode="singleTop" />
-->
</application>
</manifest>
47 changes: 47 additions & 0 deletions app/src/debug/kotlin/org/openstrap/app/DemoData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ import org.openstrap.app.data.db.SleepSession
import org.openstrap.app.data.db.SleepStage
import org.openstrap.app.data.db.Spo2Sample
import org.openstrap.app.data.db.StressSample
import org.openstrap.app.data.db.AppWorkout
import org.openstrap.app.data.db.AppWorkoutHeartRate
import org.openstrap.app.data.db.AppWorkoutRoutePoint
import java.time.LocalDate
import java.time.ZoneId
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
import kotlin.random.Random

Expand Down Expand Up @@ -135,5 +140,47 @@ object DemoData {
dao.insertSpo2(listOf(Spo2Sample(midnight + 3 * 3600, 96 + rnd.nextInt(0, 4))))
dao.insertPai(listOf(PaiSample(endOfData - 60, rnd.nextFloat() * 20, 40f + back, 58, 192)))
}
phoneRun(dao, LocalDate.now().minusDays(1).atStartOfDay(zone).toEpochSecond() + 7 * 3600, rnd)
}

/**
* A run recorded on the phone with GPS, its route kept: once round Hyde Park in London, a public place, so the
* workout page has a map to show without anyone's real location.
*/
private suspend fun phoneRun(dao: HealthDao, start: Long, rnd: Random) {
// The park's paths, corner to corner, rounded off by cutting each corner twice.
var path = listOf(51.5108 to -0.1747, 51.5126 to -0.1600, 51.5100 to -0.1585, 51.5029 to -0.1528,
51.5024 to -0.1610, 51.5030 to -0.1745, 51.5108 to -0.1747)
repeat(3) {
path = path.zipWithNext { (a, b), (c, d) -> listOf((0.75 * a + 0.25 * c) to (0.75 * b + 0.25 * d), (0.25 * a + 0.75 * c) to (0.25 * b + 0.75 * d)) }
.flatten().let { it + it.first() }
}
fun metres(p: Pair<Double, Double>, q: Pair<Double, Double>) =
hypot((q.first - p.first) * 111_320, (q.second - p.second) * 111_320 * cos(Math.toRadians(p.first)))
val legs = path.zipWithNext { p, q -> metres(p, q) }
val total = legs.sum()
// About 5:20 a kilometre, a fix every 3 seconds, a little faster in the middle.
val seconds = (total / 1000 * 320).toLong()
var leg = 0
var done = 0.0
val points = (0..seconds step 3L).map { s ->
val along = total * s / seconds
while (leg < legs.lastIndex && done + legs[leg] < along) done += legs[leg++]
val f = ((along - done) / legs[leg]).coerceIn(0.0, 1.0)
val (a, b) = path[leg]
val (c, d) = path[leg + 1]
AppWorkoutRoutePoint(start, start + s, a + (c - a) * f + rnd.nextDouble(-4e-6, 4e-6), b + (d - b) * f + rnd.nextDouble(-6e-6, 6e-6),
accuracy = 4f + rnd.nextFloat() * 3, speed = (3.1 + 0.25 * sin(s * Math.PI / seconds)).toFloat())
}
val hr = (0..seconds step 5L).map { s ->
AppWorkoutHeartRate(start, start + s, (118 + 34 * (s / 300.0).coerceAtMost(1.0) + 6 * sin(s / 200.0) + rnd.nextInt(-2, 3)).toInt())
}
dao.insertAppWorkouts(listOf(AppWorkout(
start, start + seconds, sportType = 1, averageHeartRate = hr.map { it.bpm }.average().toInt(), maxHeartRate = hr.maxOf { it.bpm },
useGps = true, keepRoute = true, distanceMeters = total.toFloat(), laps = (1..total.toInt() / 1000).joinToString(",") { "${start + it * 320L}" },
epoc = 82f, aerobicTrainingEffect = 3.4f, recoveryMinutes = 16 * 60, vo2max = 48f,
)))
dao.insertAppWorkoutHeartRate(hr)
dao.insertRoutePoints(points)
}
}
Loading
Loading