OXYGENHUB
◆ v0.1.2 oxygenhub-android / app / src / main / java / io / oxygenhub / android / widget / HubWidget.kt
8.1 KiB · last changed The widget: the OxygenHub mark and Your sky behind it (closes #30) 4 days ago source blame history raw
1
package io.oxygenhub.android.widget
2
3
import android.content.Context
4
import android.content.res.Configuration
5
import androidx.compose.runtime.Composable
6
import androidx.compose.ui.graphics.Color
7
import androidx.compose.ui.unit.dp
8
import androidx.compose.ui.unit.sp
9
import androidx.glance.GlanceId
10
import androidx.glance.GlanceModifier
11
import androidx.glance.Image
12
import androidx.glance.ImageProvider
13
import androidx.glance.LocalContext
14
import androidx.glance.LocalSize
15
import androidx.glance.GlanceTheme
16
import androidx.glance.action.actionStartActivity
17
import androidx.glance.action.clickable
18
import androidx.glance.appwidget.GlanceAppWidget
19
import androidx.glance.appwidget.GlanceAppWidgetReceiver
20
import androidx.glance.appwidget.SizeMode
21
import androidx.glance.appwidget.cornerRadius
22
import androidx.glance.appwidget.provideContent
23
import androidx.glance.background
24
import androidx.glance.color.ColorProvider
25
import androidx.glance.layout.Alignment
26
import androidx.glance.layout.Box
27
import androidx.glance.layout.Column
28
import androidx.glance.layout.ContentScale
29
import androidx.glance.layout.Row
30
import androidx.glance.layout.Spacer
31
import androidx.glance.layout.fillMaxSize
32
import androidx.glance.layout.fillMaxWidth
33
import androidx.glance.layout.height
34
import androidx.glance.layout.padding
35
import androidx.glance.layout.size
36
import androidx.glance.layout.width
37
import androidx.glance.material3.ColorProviders
38
import androidx.glance.text.FontWeight
39
import androidx.glance.text.Text
40
import androidx.glance.text.TextStyle
41
import io.oxygenhub.android.MainActivity
42
import io.oxygenhub.android.OxygenHubApp
43
import io.oxygenhub.android.R
44
import io.oxygenhub.android.api.HubApi
45
import io.oxygenhub.android.api.UserFlowRun
46
import io.oxygenhub.android.api.UserSky
47
import io.oxygenhub.android.ui.theme.DarkBad
48
import io.oxygenhub.android.ui.theme.DarkColorScheme
49
import io.oxygenhub.android.ui.theme.DarkOk
50
import io.oxygenhub.android.ui.theme.DarkWarn
51
import io.oxygenhub.android.ui.theme.LightBad
52
import io.oxygenhub.android.ui.theme.LightColorScheme
53
import io.oxygenhub.android.ui.theme.LightOk
54
import io.oxygenhub.android.ui.theme.LightWarn
55
import kotlinx.coroutines.CancellationException
56
import kotlinx.coroutines.async
57
import kotlinx.coroutines.coroutineScope
58
59
/** What the widget shows (#28); `signedIn` false asks for a sign-in instead. */
60
data class WidgetData(
61
    val signedIn: Boolean,
62
    val runs: List<UserFlowRun> = emptyList(),
63
    val unread: Long? = null,
64
    val reviews: Int? = null,
65
    /** Your sky, drawn behind the content; null keeps the plain surface. */
66
    val sky: UserSky? = null,
67
) {
68
    companion object {
69
        /** Each part on its own: one the hub cannot give is left out, not the whole widget. */
70
        suspend fun load(api: HubApi?): WidgetData = coroutineScope {
71
            if (api == null) return@coroutineScope WidgetData(signedIn = false)
72
            suspend fun <T> quietly(f: suspend () -> T): T? = try {
73
                f()
74
            } catch (e: CancellationException) {
75
                throw e
76
            } catch (_: Exception) {
77
                null
78
            }
79
            val runs = async { quietly { api.userFlowRuns() } }
80
            val unread = async { quietly { api.inbox(limit = 1).unread } }
81
            val reviews = async { quietly { api.userProposals().review.size } }
82
            val sky = async { quietly { api.userSky() } }
83
            WidgetData(true, runs.await().orEmpty().take(MAX_RUNS), unread.await(), reviews.await(), sky.await())
84
        }
85
86
        const val MAX_RUNS = 3
87
    }
88
}
89
90
/**
91
 * A home screen widget (#28): your latest runs, unread notifications and
92
 * reviews waiting; a tap opens the app. The OxygenHub mark heads it and Your
93
 * sky is drawn behind it, at the widget's own size (#30).
94
 */
95
class HubWidget : GlanceAppWidget() {
96
    /** Composed for each size the launcher shows it at, so the sky fits exactly. */
97
    override val sizeMode: SizeMode = SizeMode.Exact
98
99
    override suspend fun provideGlance(context: Context, id: GlanceId) {
100
        val api = (context.applicationContext as OxygenHubApp).container.sessions.api()
101
        val data = WidgetData.load(api)
102
        provideContent {
103
            GlanceTheme(colors = ColorProviders(light = LightColorScheme, dark = DarkColorScheme)) { Content(data) }
104
        }
105
    }
106
}
107
108
class HubWidgetReceiver : GlanceAppWidgetReceiver() {
109
    override val glanceAppWidget: GlanceAppWidget = HubWidget()
110
}
111
112
private val ok = ColorProvider(LightOk, DarkOk)
113
private val bad = ColorProvider(LightBad, DarkBad)
114
private val warn = ColorProvider(LightWarn, DarkWarn)
115
116
@Composable
117
private fun Content(d: WidgetData) {
118
    val title = TextStyle(color = GlanceTheme.colors.primary, fontSize = 15.sp, fontWeight = FontWeight.Bold)
119
    val body = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp)
120
    val mute = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp)
121
    val context = LocalContext.current
122
    val size = LocalSize.current
123
    val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
124
    val density = context.resources.displayMetrics.density
125
    val sky = if (d.signedIn) skyImage(d.sky, size.width.value, size.height.value, density, dark) else null
126
    Box(GlanceModifier.fillMaxSize().cornerRadius(16.dp).background(GlanceTheme.colors.surfaceVariant).clickable(actionStartActivity<MainActivity>())) {
127
        if (sky != null) {
128
            Image(sky, contentDescription = null, contentScale = ContentScale.Crop, modifier = GlanceModifier.fillMaxSize())
129
            // Text stays readable over the marks.
130
            Box(GlanceModifier.fillMaxSize().background(scrim)) {}
131
        }
132
        Column(GlanceModifier.fillMaxSize().padding(12.dp)) { Body(d, title, body, mute) }
133
    }
134
}
135
136
/** The widget's sky as an image, or null when there is nothing to draw or drawing fails (the plain surface stays). */
137
private fun skyImage(sky: UserSky?, widthDp: Float, heightDp: Float, density: Float, dark: Boolean): ImageProvider? = try {
138
    SkyBitmap.frame(sky, widthDp, heightDp, dark)?.let {
139
        ImageProvider(SkyBitmap.render(it, (widthDp * density).toInt().coerceAtLeast(1), (heightDp * density).toInt().coerceAtLeast(1), density, dark))
140
    }
141
} catch (_: Throwable) {
142
    // A missing native library, or a launcher's odd size: the widget is worth more than its backdrop.
143
    null
144
}
145
146
private val scrim = ColorProvider(LightColorScheme.surfaceVariant.copy(alpha = 0.55f), DarkColorScheme.surfaceVariant.copy(alpha = 0.5f))
147
148
@Composable
149
private fun Body(d: WidgetData, title: TextStyle, body: TextStyle, mute: TextStyle) {
150
    Column {
151
        Row(verticalAlignment = Alignment.CenterVertically) {
152
            Image(ImageProvider(R.drawable.mark), contentDescription = null, modifier = GlanceModifier.size(width = 28.dp, height = 14.dp))
153
            Spacer(GlanceModifier.width(6.dp))
154
            Text("OxygenHub", style = title)
155
        }
156
        Spacer(GlanceModifier.height(6.dp))
157
        if (!d.signedIn) {
158
            Text("Sign in to see your runs, inbox and reviews.", style = mute)
159
            return@Column
160
        }
161
        if (d.runs.isEmpty()) Text("No runs yet.", style = mute)
162
        d.runs.forEach { r ->
163
            Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
164
                Box(GlanceModifier.size(10.dp).cornerRadius(5.dp).background(colourOf(r.run.status))) {}
165
                Spacer(GlanceModifier.width(8.dp))
166
                Text("${r.repository} #${r.run.number}", style = body, maxLines = 1, modifier = GlanceModifier.defaultWeight())
167
                Text(r.run.status, style = mute)
168
            }
169
        }
170
        Spacer(GlanceModifier.height(6.dp))
171
        Row {
172
            d.unread?.let { Text(if (it == 0L) "Inbox clear" else "$it unread", style = mute) }
173
            if (d.unread != null && d.reviews != null) Text("  ·  ", style = mute)
174
            d.reviews?.let { Text(if (it == 0) "no reviews waiting" else "$it to review", style = mute) }
175
        }
176
    }
177
}
178
179
private fun colourOf(status: String) = when (status) {
180
    "passed" -> ok
181
    "failed", "error" -> bad
182
    "running", "queued" -> warn
183
    else -> ColorProvider(Color.Gray, Color.Gray)
184
}