OXYGENHUB

Coverage / app/src/main/java/io/oxygenhub/android/widget/HubWidget.kt 30.7%

63 of 205 lines run, 20 of 55 functions, 49 of 111 branches · run #1660 on main · run missed

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); its counts sit
147
 * in its header, and the phone's runner job at its foot (#31, #33).
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
        // The counts sit in the header, right of the title, so the lines below can never push them off (#33).
244
        Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
245
            Image(ImageProvider(R.drawable.mark), contentDescription = null, modifier = GlanceModifier.size(width = 28.dp, height = 14.dp))
246
            Spacer(GlanceModifier.width(6.dp))
247
            Text("OxygenHub", style = title, modifier = GlanceModifier.defaultWeight())
248
            if (d.signedIn) Counts(d)
249
        }
250
        Spacer(GlanceModifier.height(6.dp))
251
        if (!d.signedIn) {
252
            Text("Sign in to see your runs, inbox and reviews.", style = mute)
253
            return@Column
254
        }
255
        // A widget's column shows ten children at most and drops the rest
256
        // without a word: each part is one child of its own (#33).
257
        Column(GlanceModifier.fillMaxWidth()) {
258
            if (d.runs.isEmpty()) Text("No runs yet.", style = mute)
259
            d.runs.forEach { r ->
260
                Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
261
                    Box(GlanceModifier.size(10.dp).cornerRadius(5.dp).background(colourOf(r.run.status))) {}
262
                    Spacer(GlanceModifier.width(8.dp))
263
                    Text("${r.repository} #${r.run.number}", style = body, maxLines = 1, modifier = GlanceModifier.defaultWeight())
264
                    Text(r.run.status, style = mute)
265
                }
266
            }
267
        }
268
        NeedsYou(d, body)
269
        Spacer(GlanceModifier.defaultWeight())
270
        Health(d, mute)
271
        Runner(d)
272
    }
273
}
274
275
/**
276
 * The counts in the header (#31, #33): unread messages, proposals to review and
277
 * issues assigned, each an icon and its number (dimmed at zero, left out
278
 * when the hub cannot say). Each opens its screen in the app.
279
 */
280
@Composable
281
private fun Counts(d: WidgetData) {
282
    Row(verticalAlignment = Alignment.CenterVertically) {
283
        d.unread?.let { Count(R.drawable.widget_inbox, "unread", it, WidgetTaps.action("inbox")) }
284
        d.reviews?.let { n ->
285
            val r = d.onlyReview
286
            Count(R.drawable.widget_proposal, "to review", n.toLong(), if (r != null) WidgetTaps.action("proposal", r.repository, r.number) else WidgetTaps.action("dashboard"))
287
        }
288
        d.assigned?.let { n ->
289
            val i = d.onlyAssigned
290
            Count(R.drawable.widget_issue, "assigned", n.toLong(), if (i != null) WidgetTaps.action("issue", i.repository, i.number) else WidgetTaps.action("dashboard"))
291
        }
292
    }
293
}
294
295
/** While the phone serves as a runner (#31): what it runs, under the health strip. */
296
@Composable
297
private fun Runner(d: WidgetData) {
298
    val jobs = d.runnerJobs ?: return
299
    Row(GlanceModifier.fillMaxWidth().padding(top = 4.dp).clickable(WidgetTaps.action("account")), verticalAlignment = Alignment.CenterVertically) {
300
        Image(ImageProvider(R.drawable.widget_runner), contentDescription = "This phone runs", colorFilter = ColorFilter.tint(ok), modifier = GlanceModifier.size(13.dp))
301
        Spacer(GlanceModifier.width(6.dp))
302
        Text(runnerLine(jobs), style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp), maxLines = 1)
303
    }
304
}
305
306
/** Needs you (#33): the top proposal to review and the newest issue assigned, a line each, opening it. */
307
@Composable
308
private fun NeedsYou(d: WidgetData, body: TextStyle) {
309
    val r = d.topReview
310
    val i = d.topAssigned
311
    if (r == null && i == null) return
312
    Column(GlanceModifier.fillMaxWidth().padding(top = 8.dp)) {
313
        Text("Needs you", style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 11.sp, fontWeight = FontWeight.Bold))
314
        r?.let { NeedLine(R.drawable.widget_proposal, "Review ${it.repository} #${it.number}: ${it.title}", body, WidgetTaps.action("proposal", it.repository, it.number)) }
315
        i?.let { NeedLine(R.drawable.widget_issue, "${it.repository} #${it.number}: ${it.title}", body, WidgetTaps.action("issue", it.repository, it.number)) }
316
    }
317
}
318
319
@Composable
320
private fun NeedLine(icon: Int, text: String, body: TextStyle, open: Action) {
321
    Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp).clickable(open), verticalAlignment = Alignment.CenterVertically) {
322
        Image(ImageProvider(icon), contentDescription = null, colorFilter = ColorFilter.tint(headline), modifier = GlanceModifier.size(13.dp))
323
        Spacer(GlanceModifier.width(8.dp))
324
        Text(text, style = body, maxLines = 1)
325
    }
326
}
327
328
/** Flow health (#33): a bar per recent run, and how many of the finished ones passed. */
329
@Composable
330
private fun Health(d: WidgetData, mute: TextStyle) {
331
    val summary = HealthStrip.summary(d.health) ?: return
332
    val context = LocalContext.current
333
    val size = LocalSize.current
334
    val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
335
    val density = context.resources.displayMetrics.density
336
    val strip = try {
337
        // The widget's width less its padding, at 10 dp high.
338
        ImageProvider(HealthStrip.render(d.health, ((size.width.value - 24f) * density).toInt(), (10f * density).toInt(), density, dark))
339
    } catch (_: Throwable) {
340
        null
341
    }
342
    Column(GlanceModifier.fillMaxWidth().clickable(actionStartActivity<MainActivity>())) {
343
        strip?.let { Image(it, contentDescription = summary, contentScale = ContentScale.FillBounds, modifier = GlanceModifier.fillMaxWidth().height(10.dp)) }
344
        Spacer(GlanceModifier.height(3.dp))
345
        Text(summary, style = mute, maxLines = 1)
346
    }
347
}
348
349
/** What the runner part of the row says: the job, how many, or that it waits. */
350
internal fun runnerLine(jobs: List<String>): String = when (jobs.size) {
351
    0 -> "Waiting for jobs"
352
    1 -> jobs[0]
353
    else -> "${jobs.size} jobs"
354
}
355
356
@Composable
357
private fun Count(icon: Int, what: String, n: Long, open: Action) {
358
    val colour = if (n == 0L) GlanceTheme.colors.onSurfaceVariant else headline
359
    Row(GlanceModifier.padding(start = 12.dp).clickable(open), verticalAlignment = Alignment.CenterVertically) {
360
        Image(ImageProvider(icon), contentDescription = "$n $what", colorFilter = ColorFilter.tint(colour), modifier = GlanceModifier.size(14.dp))
361
        Spacer(GlanceModifier.width(4.dp))
362
        Text("$n", style = TextStyle(color = colour, fontSize = 13.sp, fontWeight = FontWeight.Bold))
363
    }
364
}
365
366
private fun colourOf(status: String) = when (status) {
367
    "passed" -> ok
368
    "failed", "error" -> bad
369
    "running", "queued" -> warn
370
    else -> ColorProvider(Color.Gray, Color.Gray)
371
}