OXYGENHUB

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

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