OXYGENHUB
# 1b7703ec4b02 oxygenhub-android / app / src / main / java / io / oxygenhub / android / widget / HubWidget.kt
16.9 KiB · last changed The widget: Needs you (the top review and newest assigned issue) and a Flow health strip of the last 30 runs; the mark in its own colours (closes #33) 3 days ago source blame history raw
1
package io.oxygenhub.android.widget
2
3
import android.content.Context
4
import android.content.Intent
5
import android.content.res.Configuration
6
import androidx.compose.runtime.Composable
7
import androidx.compose.ui.graphics.Color
8
import androidx.compose.ui.unit.dp
9
import androidx.compose.ui.unit.sp
10
import androidx.glance.ColorFilter
11
import androidx.glance.GlanceId
12
import androidx.glance.GlanceModifier
13
import androidx.glance.Image
14
import androidx.glance.ImageProvider
15
import androidx.glance.LocalContext
16
import androidx.glance.LocalSize
17
import androidx.glance.GlanceTheme
18
import androidx.glance.action.Action
19
import androidx.glance.action.ActionParameters
20
import androidx.glance.action.actionParametersOf
21
import androidx.glance.action.actionStartActivity
22
import androidx.glance.action.clickable
23
import androidx.glance.appwidget.GlanceAppWidget
24
import androidx.glance.appwidget.GlanceAppWidgetReceiver
25
import androidx.glance.appwidget.SizeMode
26
import androidx.glance.appwidget.cornerRadius
27
import androidx.glance.appwidget.provideContent
28
import androidx.glance.background
29
import androidx.glance.color.ColorProvider
30
import androidx.glance.layout.Alignment
31
import androidx.glance.layout.Box
32
import androidx.glance.layout.Column
33
import androidx.glance.layout.ContentScale
34
import androidx.glance.layout.Row
35
import androidx.glance.layout.Spacer
36
import androidx.glance.layout.fillMaxSize
37
import androidx.glance.layout.fillMaxWidth
38
import androidx.glance.layout.height
39
import androidx.glance.layout.padding
40
import androidx.glance.layout.size
41
import androidx.glance.layout.width
42
import androidx.glance.material3.ColorProviders
43
import androidx.glance.text.FontWeight
44
import androidx.glance.text.Text
45
import androidx.glance.text.TextStyle
46
import io.oxygenhub.android.MainActivity
47
import io.oxygenhub.android.OxygenHubApp
48
import io.oxygenhub.android.R
49
import io.oxygenhub.android.api.HubApi
50
import io.oxygenhub.android.api.IssueBrief
51
import io.oxygenhub.android.api.ProposalBrief
52
import io.oxygenhub.android.api.UserFlowRun
53
import io.oxygenhub.android.api.UserSky
54
import io.oxygenhub.android.runner.PhoneRunner
55
import io.oxygenhub.android.ui.nav.AccountRoute
56
import io.oxygenhub.android.ui.nav.DashboardRoute
57
import io.oxygenhub.android.ui.nav.InboxRoute
58
import io.oxygenhub.android.ui.nav.IssueRoute
59
import io.oxygenhub.android.ui.nav.ProposalRoute
60
import io.oxygenhub.android.ui.theme.DarkBad
61
import io.oxygenhub.android.ui.theme.DarkColorScheme
62
import io.oxygenhub.android.ui.theme.DarkOk
63
import io.oxygenhub.android.ui.theme.DarkWarn
64
import io.oxygenhub.android.ui.theme.LightBad
65
import io.oxygenhub.android.ui.theme.LightColorScheme
66
import io.oxygenhub.android.ui.theme.LightOk
67
import io.oxygenhub.android.ui.theme.LightWarn
68
import kotlinx.coroutines.CancellationException
69
import kotlinx.coroutines.async
70
import kotlinx.coroutines.coroutineScope
71
72
/** What the widget shows (#28); `signedIn` false asks for a sign-in instead. */
73
data class WidgetData(
74
    val signedIn: Boolean,
75
    val runs: List<UserFlowRun> = emptyList(),
76
    val unread: Long? = null,
77
    val reviews: Int? = null,
78
    /** Your sky, drawn behind the content; null keeps the plain surface. */
79
    val sky: UserSky? = null,
80
    /** The one proposal waiting for review, when there is just one: a tap opens it (#31). */
81
    val onlyReview: ProposalBrief? = null,
82
    /** Open issues assigned to you (#31); null when the hub cannot say. */
83
    val assigned: Int? = null,
84
    /** The one assigned issue, when there is just one: a tap opens it. */
85
    val onlyAssigned: IssueBrief? = null,
86
    /** What the phone runs as an OxygenFlow runner while it serves (#31); null when it is not serving. */
87
    val runnerJobs: List<String>? = null,
88
    /** The proposal at the head of your reviews, and your newest assigned issue: Needs you (#33). */
89
    val topReview: ProposalBrief? = null,
90
    val topAssigned: IssueBrief? = null,
91
    /** Your last runs' statuses, newest first, for the health strip (#33). */
92
    val health: List<String> = emptyList(),
93
) {
94
    companion object {
95
        /**
96
         * Each part on its own: one the hub cannot give is left out, not the
97
         * whole widget. [runnerJobs] is what this phone's runner runs, null
98
         * when it is not serving.
99
         */
100
        suspend fun load(api: HubApi?, runnerJobs: List<String>? = null): WidgetData = coroutineScope {
101
            if (api == null) return@coroutineScope WidgetData(signedIn = false)
102
            suspend fun <T> quietly(f: suspend () -> T): T? = try {
103
                f()
104
            } catch (e: CancellationException) {
105
                throw e
106
            } catch (_: Exception) {
107
                null
108
            }
109
            val runs = async { quietly { api.userFlowRuns() } }
110
            val unread = async { quietly { api.inbox(limit = 1).unread } }
111
            val reviews = async { quietly { api.userProposals().review } }
112
            val assigned = async { quietly { api.userIssues() } }
113
            val sky = async { quietly { api.userSky() } }
114
            val review = reviews.await()
115
            val issues = assigned.await()
116
            val all = runs.await().orEmpty()
117
            WidgetData(
118
                signedIn = true,
119
                runs = all.take(MAX_RUNS),
120
                unread = unread.await(),
121
                reviews = review?.size,
122
                sky = sky.await(),
123
                onlyReview = review?.singleOrNull(),
124
                assigned = issues?.size,
125
                onlyAssigned = issues?.singleOrNull(),
126
                runnerJobs = runnerJobs,
127
                topReview = review?.firstOrNull(),
128
                topAssigned = issues?.firstOrNull(),
129
                health = all.take(HealthStrip.MAX).map { it.run.status },
130
            )
131
        }
132
133
        /** What this phone's runner runs, for [load]: null unless it is serving. */
134
        fun runnerJobs(): List<String>? = when (PhoneRunner.state.value) {
135
            PhoneRunner.State.Running, is PhoneRunner.State.Draining, is PhoneRunner.State.Leaving -> PhoneRunner.jobs.value
136
            else -> null
137
        }
138
139
        const val MAX_RUNS = 3
140
    }
141
}
142
143
/**
144
 * A home screen widget (#28): your latest runs, unread notifications and
145
 * reviews waiting; a tap opens the app. The OxygenHub mark heads it and Your
146
 * sky is drawn behind it, at the widget's own size (#30); a row of counts
147
 * and the phone's runner job runs along its foot (#31).
148
 */
149
class HubWidget : GlanceAppWidget() {
150
    /** Composed for each size the launcher shows it at, so the sky fits exactly. */
151
    override val sizeMode: SizeMode = SizeMode.Exact
152
153
    override suspend fun provideGlance(context: Context, id: GlanceId) {
154
        val api = (context.applicationContext as OxygenHubApp).container.sessions.api()
155
        val data = WidgetData.load(api, WidgetData.runnerJobs())
156
        provideContent {
157
            GlanceTheme(colors = ColorProviders(light = LightColorScheme, dark = DarkColorScheme)) { Content(data) }
158
        }
159
    }
160
}
161
162
class HubWidgetReceiver : GlanceAppWidgetReceiver() {
163
    override val glanceAppWidget: GlanceAppWidget = HubWidget()
164
}
165
166
private val ok = ColorProvider(LightOk, DarkOk)
167
private val bad = ColorProvider(LightBad, DarkBad)
168
private val warn = ColorProvider(LightWarn, DarkWarn)
169
170
/**
171
 * Where a tap on the widget's counts goes (#31): the extras it puts on the
172
 * app's intent, and the screen they name.
173
 */
174
object WidgetTaps {
175
    const val OPEN = "io.oxygenhub.android.widget.open"
176
    const val REPOSITORY = "io.oxygenhub.android.widget.repository"
177
    const val NUMBER = "io.oxygenhub.android.widget.number"
178
179
    /** The screen an intent's extras name, or null for none (the app opens as it would). */
180
    fun route(open: String?, repository: String?, number: Long): Any? {
181
        val owner = repository?.substringBefore('/', "")?.takeIf { it.isNotEmpty() }
182
        val name = repository?.substringAfter('/', "")?.takeIf { it.isNotEmpty() }
183
        return when (open) {
184
            "inbox" -> InboxRoute
185
            "dashboard" -> DashboardRoute
186
            "account" -> AccountRoute
187
            "proposal" -> if (owner != null && name != null && number > 0) ProposalRoute(owner, name, number) else DashboardRoute
188
            "issue" -> if (owner != null && name != null && number > 0) IssueRoute(owner, name, number) else DashboardRoute
189
            else -> null
190
        }
191
    }
192
193
    fun route(intent: Intent?): Any? =
194
        intent?.let { route(it.getStringExtra(OPEN), it.getStringExtra(REPOSITORY), it.getLongExtra(NUMBER, 0)) }
195
196
    internal fun action(open: String, repository: String? = null, number: Long = 0) = actionStartActivity<MainActivity>(
197
        actionParametersOf(
198
            ActionParameters.Key<String>(OPEN) to open,
199
            ActionParameters.Key<String>(REPOSITORY) to (repository ?: ""),
200
            ActionParameters.Key<Long>(NUMBER) to number,
201
        ),
202
    )
203
}
204
205
/** The title: white on the dark widget, the text colour on the light one (#31); the mark keeps its own colours (#33). */
206
private val headline = ColorProvider(LightColorScheme.onSurface, Color.White)
207
208
@Composable
209
private fun Content(d: WidgetData) {
210
    val title = TextStyle(color = headline, fontSize = 15.sp, fontWeight = FontWeight.Bold)
211
    val body = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp)
212
    val mute = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp)
213
    val context = LocalContext.current
214
    val size = LocalSize.current
215
    val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
216
    val density = context.resources.displayMetrics.density
217
    val sky = if (d.signedIn) skyImage(d.sky, size.width.value, size.height.value, density, dark) else null
218
    Box(GlanceModifier.fillMaxSize().cornerRadius(16.dp).background(GlanceTheme.colors.surfaceVariant).clickable(actionStartActivity<MainActivity>())) {
219
        if (sky != null) {
220
            Image(sky, contentDescription = null, contentScale = ContentScale.Crop, modifier = GlanceModifier.fillMaxSize())
221
            // Text stays readable over the marks.
222
            Box(GlanceModifier.fillMaxSize().background(scrim)) {}
223
        }
224
        Column(GlanceModifier.fillMaxSize().padding(12.dp)) { Body(d, title, body, mute) }
225
    }
226
}
227
228
/** The widget's sky as an image, or null when there is nothing to draw or drawing fails (the plain surface stays). */
229
private fun skyImage(sky: UserSky?, widthDp: Float, heightDp: Float, density: Float, dark: Boolean): ImageProvider? = try {
230
    SkyBitmap.frame(sky, widthDp, heightDp, dark)?.let {
231
        ImageProvider(SkyBitmap.render(it, (widthDp * density).toInt().coerceAtLeast(1), (heightDp * density).toInt().coerceAtLeast(1), density, dark))
232
    }
233
} catch (_: Throwable) {
234
    // A missing native library, or a launcher's odd size: the widget is worth more than its backdrop.
235
    null
236
}
237
238
private val scrim = ColorProvider(LightColorScheme.surfaceVariant.copy(alpha = 0.55f), DarkColorScheme.surfaceVariant.copy(alpha = 0.5f))
239
240
@Composable
241
private fun Body(d: WidgetData, title: TextStyle, body: TextStyle, mute: TextStyle) {
242
    Column(GlanceModifier.fillMaxSize()) {
243
        Row(verticalAlignment = Alignment.CenterVertically) {
244
            Image(ImageProvider(R.drawable.mark), contentDescription = null, modifier = GlanceModifier.size(width = 28.dp, height = 14.dp))
245
            Spacer(GlanceModifier.width(6.dp))
246
            Text("OxygenHub", style = title)
247
        }
248
        Spacer(GlanceModifier.height(6.dp))
249
        if (!d.signedIn) {
250
            Text("Sign in to see your runs, inbox and reviews.", style = mute)
251
            return@Column
252
        }
253
        if (d.runs.isEmpty()) Text("No runs yet.", style = mute)
254
        d.runs.forEach { r ->
255
            Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
256
                Box(GlanceModifier.size(10.dp).cornerRadius(5.dp).background(colourOf(r.run.status))) {}
257
                Spacer(GlanceModifier.width(8.dp))
258
                Text("${r.repository} #${r.run.number}", style = body, maxLines = 1, modifier = GlanceModifier.defaultWeight())
259
                Text(r.run.status, style = mute)
260
            }
261
        }
262
        NeedsYou(d, body)
263
        Spacer(GlanceModifier.defaultWeight())
264
        Health(d, mute)
265
        Spacer(GlanceModifier.height(6.dp))
266
        Counts(d)
267
    }
268
}
269
270
/**
271
 * The row along the bottom (#31): unread messages, proposals to review and
272
 * issues assigned, each an icon and its number (dimmed at zero, left out
273
 * when the hub cannot say), then what the phone runs while it serves as a
274
 * runner. Each opens its screen in the app.
275
 */
276
@Composable
277
private fun Counts(d: WidgetData) {
278
    Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
279
        d.unread?.let { Count(R.drawable.widget_inbox, "unread", it, WidgetTaps.action("inbox")) }
280
        d.reviews?.let { n ->
281
            val r = d.onlyReview
282
            Count(R.drawable.widget_proposal, "to review", n.toLong(), if (r != null) WidgetTaps.action("proposal", r.repository, r.number) else WidgetTaps.action("dashboard"))
283
        }
284
        d.assigned?.let { n ->
285
            val i = d.onlyAssigned
286
            Count(R.drawable.widget_issue, "assigned", n.toLong(), if (i != null) WidgetTaps.action("issue", i.repository, i.number) else WidgetTaps.action("dashboard"))
287
        }
288
        d.runnerJobs?.let { jobs ->
289
            Row(GlanceModifier.defaultWeight().clickable(WidgetTaps.action("account")), verticalAlignment = Alignment.CenterVertically) {
290
                Image(ImageProvider(R.drawable.widget_runner), contentDescription = "This phone runs", colorFilter = ColorFilter.tint(ok), modifier = GlanceModifier.size(14.dp))
291
                Spacer(GlanceModifier.width(4.dp))
292
                Text(runnerLine(jobs), style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp), maxLines = 1)
293
            }
294
        }
295
    }
296
}
297
298
/** Needs you (#33): the top proposal to review and the newest issue assigned, a line each, opening it. */
299
@Composable
300
private fun NeedsYou(d: WidgetData, body: TextStyle) {
301
    val r = d.topReview
302
    val i = d.topAssigned
303
    if (r == null && i == null) return
304
    Spacer(GlanceModifier.height(8.dp))
305
    Text("Needs you", style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 11.sp, fontWeight = FontWeight.Bold))
306
    r?.let { NeedLine(R.drawable.widget_proposal, "Review ${it.repository} #${it.number}: ${it.title}", body, WidgetTaps.action("proposal", it.repository, it.number)) }
307
    i?.let { NeedLine(R.drawable.widget_issue, "${it.repository} #${it.number}: ${it.title}", body, WidgetTaps.action("issue", it.repository, it.number)) }
308
}
309
310
@Composable
311
private fun NeedLine(icon: Int, text: String, body: TextStyle, open: Action) {
312
    Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp).clickable(open), verticalAlignment = Alignment.CenterVertically) {
313
        Image(ImageProvider(icon), contentDescription = null, colorFilter = ColorFilter.tint(headline), modifier = GlanceModifier.size(13.dp))
314
        Spacer(GlanceModifier.width(8.dp))
315
        Text(text, style = body, maxLines = 1)
316
    }
317
}
318
319
/** Flow health (#33): a bar per recent run, and how many of the finished ones passed. */
320
@Composable
321
private fun Health(d: WidgetData, mute: TextStyle) {
322
    val summary = HealthStrip.summary(d.health) ?: return
323
    val context = LocalContext.current
324
    val size = LocalSize.current
325
    val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
326
    val density = context.resources.displayMetrics.density
327
    val strip = try {
328
        // The widget's width less its padding, at 10 dp high.
329
        ImageProvider(HealthStrip.render(d.health, ((size.width.value - 24f) * density).toInt(), (10f * density).toInt(), density, dark))
330
    } catch (_: Throwable) {
331
        null
332
    }
333
    Column(GlanceModifier.fillMaxWidth().clickable(actionStartActivity<MainActivity>())) {
334
        strip?.let { Image(it, contentDescription = summary, contentScale = ContentScale.FillBounds, modifier = GlanceModifier.fillMaxWidth().height(10.dp)) }
335
        Spacer(GlanceModifier.height(3.dp))
336
        Text(summary, style = mute, maxLines = 1)
337
    }
338
}
339
340
/** What the runner part of the row says: the job, how many, or that it waits. */
341
internal fun runnerLine(jobs: List<String>): String = when (jobs.size) {
342
    0 -> "Waiting for jobs"
343
    1 -> jobs[0]
344
    else -> "${jobs.size} jobs"
345
}
346
347
@Composable
348
private fun Count(icon: Int, what: String, n: Long, open: Action) {
349
    val colour = if (n == 0L) GlanceTheme.colors.onSurfaceVariant else headline
350
    Row(GlanceModifier.padding(end = 14.dp).clickable(open), verticalAlignment = Alignment.CenterVertically) {
351
        Image(ImageProvider(icon), contentDescription = "$n $what", colorFilter = ColorFilter.tint(colour), modifier = GlanceModifier.size(14.dp))
352
        Spacer(GlanceModifier.width(4.dp))
353
        Text("$n", style = TextStyle(color = colour, fontSize = 13.sp, fontWeight = FontWeight.Bold))
354
    }
355
}
356
357
private fun colourOf(status: String) = when (status) {
358
    "passed" -> ok
359
    "failed", "error" -> bad
360
    "running", "queued" -> warn
361
    else -> ColorProvider(Color.Gray, Color.Gray)
362
}