OXYGENHUB

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

18 of 68 lines run, 8 of 21 functions, 5 of 26 branches · run #1646 on main · run missed

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
}