OXYGENHUB
# 1b55d7f47d9f oxygenhub-android / app / src / main / java / io / oxygenhub / android / widget / HubWidget.kt
14.1 KiB · last changed The widget's foot: a white mark and title, counts of unread, reviews and assigned issues that open their screens, and the phone's runner job; Assigned to you on the dashboard (closes #31) 4 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
) {
89
    companion object {
90
        /**
91
         * Each part on its own: one the hub cannot give is left out, not the
92
         * whole widget. [runnerJobs] is what this phone's runner runs, null
93
         * when it is not serving.
94
         */
95
        suspend fun load(api: HubApi?, runnerJobs: List<String>? = null): WidgetData = coroutineScope {
96
            if (api == null) return@coroutineScope WidgetData(signedIn = false)
97
            suspend fun <T> quietly(f: suspend () -> T): T? = try {
98
                f()
99
            } catch (e: CancellationException) {
100
                throw e
101
            } catch (_: Exception) {
102
                null
103
            }
104
            val runs = async { quietly { api.userFlowRuns() } }
105
            val unread = async { quietly { api.inbox(limit = 1).unread } }
106
            val reviews = async { quietly { api.userProposals().review } }
107
            val assigned = async { quietly { api.userIssues() } }
108
            val sky = async { quietly { api.userSky() } }
109
            val review = reviews.await()
110
            val issues = assigned.await()
111
            WidgetData(
112
                signedIn = true,
113
                runs = runs.await().orEmpty().take(MAX_RUNS),
114
                unread = unread.await(),
115
                reviews = review?.size,
116
                sky = sky.await(),
117
                onlyReview = review?.singleOrNull(),
118
                assigned = issues?.size,
119
                onlyAssigned = issues?.singleOrNull(),
120
                runnerJobs = runnerJobs,
121
            )
122
        }
123
124
        /** What this phone's runner runs, for [load]: null unless it is serving. */
125
        fun runnerJobs(): List<String>? = when (PhoneRunner.state.value) {
126
            PhoneRunner.State.Running, is PhoneRunner.State.Draining, is PhoneRunner.State.Leaving -> PhoneRunner.jobs.value
127
            else -> null
128
        }
129
130
        const val MAX_RUNS = 3
131
    }
132
}
133
134
/**
135
 * A home screen widget (#28): your latest runs, unread notifications and
136
 * reviews waiting; a tap opens the app. The OxygenHub mark heads it and Your
137
 * sky is drawn behind it, at the widget's own size (#30); a row of counts
138
 * and the phone's runner job runs along its foot (#31).
139
 */
140
class HubWidget : GlanceAppWidget() {
141
    /** Composed for each size the launcher shows it at, so the sky fits exactly. */
142
    override val sizeMode: SizeMode = SizeMode.Exact
143
144
    override suspend fun provideGlance(context: Context, id: GlanceId) {
145
        val api = (context.applicationContext as OxygenHubApp).container.sessions.api()
146
        val data = WidgetData.load(api, WidgetData.runnerJobs())
147
        provideContent {
148
            GlanceTheme(colors = ColorProviders(light = LightColorScheme, dark = DarkColorScheme)) { Content(data) }
149
        }
150
    }
151
}
152
153
class HubWidgetReceiver : GlanceAppWidgetReceiver() {
154
    override val glanceAppWidget: GlanceAppWidget = HubWidget()
155
}
156
157
private val ok = ColorProvider(LightOk, DarkOk)
158
private val bad = ColorProvider(LightBad, DarkBad)
159
private val warn = ColorProvider(LightWarn, DarkWarn)
160
161
/**
162
 * Where a tap on the widget's counts goes (#31): the extras it puts on the
163
 * app's intent, and the screen they name.
164
 */
165
object WidgetTaps {
166
    const val OPEN = "io.oxygenhub.android.widget.open"
167
    const val REPOSITORY = "io.oxygenhub.android.widget.repository"
168
    const val NUMBER = "io.oxygenhub.android.widget.number"
169
170
    /** The screen an intent's extras name, or null for none (the app opens as it would). */
171
    fun route(open: String?, repository: String?, number: Long): Any? {
172
        val owner = repository?.substringBefore('/', "")?.takeIf { it.isNotEmpty() }
173
        val name = repository?.substringAfter('/', "")?.takeIf { it.isNotEmpty() }
174
        return when (open) {
175
            "inbox" -> InboxRoute
176
            "dashboard" -> DashboardRoute
177
            "account" -> AccountRoute
178
            "proposal" -> if (owner != null && name != null && number > 0) ProposalRoute(owner, name, number) else DashboardRoute
179
            "issue" -> if (owner != null && name != null && number > 0) IssueRoute(owner, name, number) else DashboardRoute
180
            else -> null
181
        }
182
    }
183
184
    fun route(intent: Intent?): Any? =
185
        intent?.let { route(it.getStringExtra(OPEN), it.getStringExtra(REPOSITORY), it.getLongExtra(NUMBER, 0)) }
186
187
    internal fun action(open: String, repository: String? = null, number: Long = 0) = actionStartActivity<MainActivity>(
188
        actionParametersOf(
189
            ActionParameters.Key<String>(OPEN) to open,
190
            ActionParameters.Key<String>(REPOSITORY) to (repository ?: ""),
191
            ActionParameters.Key<Long>(NUMBER) to number,
192
        ),
193
    )
194
}
195
196
/** The mark and the title: white on the dark widget, the text colour on the light one (#31). */
197
private val headline = ColorProvider(LightColorScheme.onSurface, Color.White)
198
199
@Composable
200
private fun Content(d: WidgetData) {
201
    val title = TextStyle(color = headline, fontSize = 15.sp, fontWeight = FontWeight.Bold)
202
    val body = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp)
203
    val mute = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp)
204
    val context = LocalContext.current
205
    val size = LocalSize.current
206
    val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
207
    val density = context.resources.displayMetrics.density
208
    val sky = if (d.signedIn) skyImage(d.sky, size.width.value, size.height.value, density, dark) else null
209
    Box(GlanceModifier.fillMaxSize().cornerRadius(16.dp).background(GlanceTheme.colors.surfaceVariant).clickable(actionStartActivity<MainActivity>())) {
210
        if (sky != null) {
211
            Image(sky, contentDescription = null, contentScale = ContentScale.Crop, modifier = GlanceModifier.fillMaxSize())
212
            // Text stays readable over the marks.
213
            Box(GlanceModifier.fillMaxSize().background(scrim)) {}
214
        }
215
        Column(GlanceModifier.fillMaxSize().padding(12.dp)) { Body(d, title, body, mute) }
216
    }
217
}
218
219
/** The widget's sky as an image, or null when there is nothing to draw or drawing fails (the plain surface stays). */
220
private fun skyImage(sky: UserSky?, widthDp: Float, heightDp: Float, density: Float, dark: Boolean): ImageProvider? = try {
221
    SkyBitmap.frame(sky, widthDp, heightDp, dark)?.let {
222
        ImageProvider(SkyBitmap.render(it, (widthDp * density).toInt().coerceAtLeast(1), (heightDp * density).toInt().coerceAtLeast(1), density, dark))
223
    }
224
} catch (_: Throwable) {
225
    // A missing native library, or a launcher's odd size: the widget is worth more than its backdrop.
226
    null
227
}
228
229
private val scrim = ColorProvider(LightColorScheme.surfaceVariant.copy(alpha = 0.55f), DarkColorScheme.surfaceVariant.copy(alpha = 0.5f))
230
231
@Composable
232
private fun Body(d: WidgetData, title: TextStyle, body: TextStyle, mute: TextStyle) {
233
    Column(GlanceModifier.fillMaxSize()) {
234
        Row(verticalAlignment = Alignment.CenterVertically) {
235
            Image(
236
                ImageProvider(R.drawable.mark),
237
                contentDescription = null,
238
                colorFilter = ColorFilter.tint(headline),
239
                modifier = GlanceModifier.size(width = 28.dp, height = 14.dp),
240
            )
241
            Spacer(GlanceModifier.width(6.dp))
242
            Text("OxygenHub", style = title)
243
        }
244
        Spacer(GlanceModifier.height(6.dp))
245
        if (!d.signedIn) {
246
            Text("Sign in to see your runs, inbox and reviews.", style = mute)
247
            return@Column
248
        }
249
        if (d.runs.isEmpty()) Text("No runs yet.", style = mute)
250
        d.runs.forEach { r ->
251
            Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
252
                Box(GlanceModifier.size(10.dp).cornerRadius(5.dp).background(colourOf(r.run.status))) {}
253
                Spacer(GlanceModifier.width(8.dp))
254
                Text("${r.repository} #${r.run.number}", style = body, maxLines = 1, modifier = GlanceModifier.defaultWeight())
255
                Text(r.run.status, style = mute)
256
            }
257
        }
258
        Spacer(GlanceModifier.defaultWeight())
259
        Counts(d)
260
    }
261
}
262
263
/**
264
 * The row along the bottom (#31): unread messages, proposals to review and
265
 * issues assigned, each an icon and its number (dimmed at zero, left out
266
 * when the hub cannot say), then what the phone runs while it serves as a
267
 * runner. Each opens its screen in the app.
268
 */
269
@Composable
270
private fun Counts(d: WidgetData) {
271
    Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
272
        d.unread?.let { Count(R.drawable.widget_inbox, "unread", it, WidgetTaps.action("inbox")) }
273
        d.reviews?.let { n ->
274
            val r = d.onlyReview
275
            Count(R.drawable.widget_proposal, "to review", n.toLong(), if (r != null) WidgetTaps.action("proposal", r.repository, r.number) else WidgetTaps.action("dashboard"))
276
        }
277
        d.assigned?.let { n ->
278
            val i = d.onlyAssigned
279
            Count(R.drawable.widget_issue, "assigned", n.toLong(), if (i != null) WidgetTaps.action("issue", i.repository, i.number) else WidgetTaps.action("dashboard"))
280
        }
281
        d.runnerJobs?.let { jobs ->
282
            Row(GlanceModifier.defaultWeight().clickable(WidgetTaps.action("account")), verticalAlignment = Alignment.CenterVertically) {
283
                Image(ImageProvider(R.drawable.widget_runner), contentDescription = "This phone runs", colorFilter = ColorFilter.tint(ok), modifier = GlanceModifier.size(14.dp))
284
                Spacer(GlanceModifier.width(4.dp))
285
                Text(runnerLine(jobs), style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp), maxLines = 1)
286
            }
287
        }
288
    }
289
}
290
291
/** What the runner part of the row says: the job, how many, or that it waits. */
292
internal fun runnerLine(jobs: List<String>): String = when (jobs.size) {
293
    0 -> "Waiting for jobs"
294
    1 -> jobs[0]
295
    else -> "${jobs.size} jobs"
296
}
297
298
@Composable
299
private fun Count(icon: Int, what: String, n: Long, open: Action) {
300
    val colour = if (n == 0L) GlanceTheme.colors.onSurfaceVariant else headline
301
    Row(GlanceModifier.padding(end = 14.dp).clickable(open), verticalAlignment = Alignment.CenterVertically) {
302
        Image(ImageProvider(icon), contentDescription = "$n $what", colorFilter = ColorFilter.tint(colour), modifier = GlanceModifier.size(14.dp))
303
        Spacer(GlanceModifier.width(4.dp))
304
        Text("$n", style = TextStyle(color = colour, fontSize = 13.sp, fontWeight = FontWeight.Bold))
305
    }
306
}
307
308
private fun colourOf(status: String) = when (status) {
309
    "passed" -> ok
310
    "failed", "error" -> bad
311
    "running", "queued" -> warn
312
    else -> ColorProvider(Color.Gray, Color.Gray)
313
}