| 1 |
package io.oxygenhub.android.widget |
| 2 |
|
| 3 |
import android.content.Context |
| 4 |
import androidx.compose.runtime.Composable |
| 5 |
import androidx.compose.ui.graphics.Color |
| 6 |
import androidx.compose.ui.unit.dp |
| 7 |
import androidx.compose.ui.unit.sp |
| 8 |
import androidx.glance.GlanceId |
| 9 |
import androidx.glance.GlanceModifier |
| 10 |
import androidx.glance.GlanceTheme |
| 11 |
import androidx.glance.action.actionStartActivity |
| 12 |
import androidx.glance.action.clickable |
| 13 |
import androidx.glance.appwidget.GlanceAppWidget |
| 14 |
import androidx.glance.appwidget.GlanceAppWidgetReceiver |
| 15 |
import androidx.glance.appwidget.cornerRadius |
| 16 |
import androidx.glance.appwidget.provideContent |
| 17 |
import androidx.glance.background |
| 18 |
import androidx.glance.color.ColorProvider |
| 19 |
import androidx.glance.layout.Alignment |
| 20 |
import androidx.glance.layout.Box |
| 21 |
import androidx.glance.layout.Column |
| 22 |
import androidx.glance.layout.Row |
| 23 |
import androidx.glance.layout.Spacer |
| 24 |
import androidx.glance.layout.fillMaxSize |
| 25 |
import androidx.glance.layout.fillMaxWidth |
| 26 |
import androidx.glance.layout.height |
| 27 |
import androidx.glance.layout.padding |
| 28 |
import androidx.glance.layout.size |
| 29 |
import androidx.glance.layout.width |
| 30 |
import androidx.glance.material3.ColorProviders |
| 31 |
import androidx.glance.text.FontWeight |
| 32 |
import androidx.glance.text.Text |
| 33 |
import androidx.glance.text.TextStyle |
| 34 |
import io.oxygenhub.android.MainActivity |
| 35 |
import io.oxygenhub.android.OxygenHubApp |
| 36 |
import io.oxygenhub.android.api.HubApi |
| 37 |
import io.oxygenhub.android.api.UserFlowRun |
| 38 |
import io.oxygenhub.android.ui.theme.DarkBad |
| 39 |
import io.oxygenhub.android.ui.theme.DarkColorScheme |
| 40 |
import io.oxygenhub.android.ui.theme.DarkOk |
| 41 |
import io.oxygenhub.android.ui.theme.DarkWarn |
| 42 |
import io.oxygenhub.android.ui.theme.LightBad |
| 43 |
import io.oxygenhub.android.ui.theme.LightColorScheme |
| 44 |
import io.oxygenhub.android.ui.theme.LightOk |
| 45 |
import io.oxygenhub.android.ui.theme.LightWarn |
| 46 |
import kotlinx.coroutines.CancellationException |
| 47 |
import kotlinx.coroutines.async |
| 48 |
import kotlinx.coroutines.coroutineScope |
| 49 |
|
| 50 |
/** What the widget shows (#28); `signedIn` false asks for a sign-in instead. */ |
| 51 |
data class WidgetData( |
| 52 |
val signedIn: Boolean, |
| 53 |
val runs: List<UserFlowRun> = emptyList(), |
| 54 |
val unread: Long? = null, |
| 55 |
val reviews: Int? = null, |
| 56 |
) { |
| 57 |
companion object { |
| 58 |
/** Each part on its own: one the hub cannot give is left out, not the whole widget. */ |
| 59 |
suspend fun load(api: HubApi?): WidgetData = coroutineScope { |
| 60 |
if (api == null) return@coroutineScope WidgetData(signedIn = false) |
| 61 |
suspend fun <T> quietly(f: suspend () -> T): T? = try { |
| 62 |
f() |
| 63 |
} catch (e: CancellationException) { |
| 64 |
throw e |
| 65 |
} catch (_: Exception) { |
| 66 |
null |
| 67 |
} |
| 68 |
val runs = async { quietly { api.userFlowRuns() } } |
| 69 |
val unread = async { quietly { api.inbox(limit = 1).unread } } |
| 70 |
val reviews = async { quietly { api.userProposals().review.size } } |
| 71 |
WidgetData(true, runs.await().orEmpty().take(MAX_RUNS), unread.await(), reviews.await()) |
| 72 |
} |
| 73 |
|
| 74 |
const val MAX_RUNS = 3 |
| 75 |
} |
| 76 |
} |
| 77 |
|
| 78 |
/** A home screen widget (#28): your latest runs, unread notifications and reviews waiting; a tap opens the app. */ |
| 79 |
class HubWidget : GlanceAppWidget() { |
| 80 |
override suspend fun provideGlance(context: Context, id: GlanceId) { |
| 81 |
val api = (context.applicationContext as OxygenHubApp).container.sessions.api() |
| 82 |
val data = WidgetData.load(api) |
| 83 |
provideContent { |
| 84 |
GlanceTheme(colors = ColorProviders(light = LightColorScheme, dark = DarkColorScheme)) { Content(data) } |
| 85 |
} |
| 86 |
} |
| 87 |
} |
| 88 |
|
| 89 |
class HubWidgetReceiver : GlanceAppWidgetReceiver() { |
| 90 |
override val glanceAppWidget: GlanceAppWidget = HubWidget() |
| 91 |
} |
| 92 |
|
| 93 |
private val ok = ColorProvider(LightOk, DarkOk) |
| 94 |
private val bad = ColorProvider(LightBad, DarkBad) |
| 95 |
private val warn = ColorProvider(LightWarn, DarkWarn) |
| 96 |
|
| 97 |
@Composable |
| 98 |
private fun Content(d: WidgetData) { |
| 99 |
val title = TextStyle(color = GlanceTheme.colors.primary, fontSize = 15.sp, fontWeight = FontWeight.Bold) |
| 100 |
val body = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp) |
| 101 |
val mute = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp) |
| 102 |
Column( |
| 103 |
GlanceModifier.fillMaxSize().cornerRadius(16.dp).background(GlanceTheme.colors.surfaceVariant).padding(12.dp) |
| 104 |
.clickable(actionStartActivity<MainActivity>()), |
| 105 |
) { |
| 106 |
Text("OxygenHub", style = title) |
| 107 |
Spacer(GlanceModifier.height(6.dp)) |
| 108 |
if (!d.signedIn) { |
| 109 |
Text("Sign in to see your runs, inbox and reviews.", style = mute) |
| 110 |
return@Column |
| 111 |
} |
| 112 |
if (d.runs.isEmpty()) Text("No runs yet.", style = mute) |
| 113 |
d.runs.forEach { r -> |
| 114 |
Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { |
| 115 |
Box(GlanceModifier.size(10.dp).cornerRadius(5.dp).background(colourOf(r.run.status))) {} |
| 116 |
Spacer(GlanceModifier.width(8.dp)) |
| 117 |
Text("${r.repository} #${r.run.number}", style = body, maxLines = 1, modifier = GlanceModifier.defaultWeight()) |
| 118 |
Text(r.run.status, style = mute) |
| 119 |
} |
| 120 |
} |
| 121 |
Spacer(GlanceModifier.height(6.dp)) |
| 122 |
Row { |
| 123 |
d.unread?.let { Text(if (it == 0L) "Inbox clear" else "$it unread", style = mute) } |
| 124 |
if (d.unread != null && d.reviews != null) Text(" · ", style = mute) |
| 125 |
d.reviews?.let { Text(if (it == 0) "no reviews waiting" else "$it to review", style = mute) } |
| 126 |
} |
| 127 |
} |
| 128 |
} |
| 129 |
|
| 130 |
private fun colourOf(status: String) = when (status) { |
| 131 |
"passed" -> ok |
| 132 |
"failed", "error" -> bad |
| 133 |
"running", "queued" -> warn |
| 134 |
else -> ColorProvider(Color.Gray, Color.Gray) |
| 135 |
} |