| 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 |
} |