Sign-in, dashboard, repositories, issues and inbox screens, adaptive (refs #2) 144cbf7bf2b5
Navigation Compose with type-safe routes inside a NavigationSuiteScaffold: a bar on a phone, a rail on a foldable or tablet. Repositories are a list-detail pane scaffold, two panes from a medium width up, so a foldable opened shows a repository's issues beside the list, kept off a vertical hinge. The sign-in screen takes the hub's address (oxygenhub.io by default), shows the device code and opens the hub's /device page. The dashboard greets the user and lists their newest repositories with the unread count; the inbox shares one view model with the navigation badge, marks read as it opens a notification, and can mark all read. Loading, empty and error states throughout, with pull to refresh. The account screen signs out, revoking the token on the hub. The placeholder login and tabs are gone. View model tests with a fake hub.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f4ec0f2e7863
- When
- · 2026-10-24 22:23 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
144cbf7bf2b528c27a55f0230bd15b90313ae1d89916b726b1bb07439fb7bcf0- Depends on
- 57c0824300db
- Depended on by
- cccf06468345 29a375612f92 263c6cad5e51 aa627197ec18 b610bc6eeab7 821035db5a1c d98e5a147428 840c75813f86 6e2af63ae616 dce8ebd2482a 1dfa631436b6 2a15621eca66 40ab4df121e0 db7727e5de19 e0004d61958c 62429abe5869 6fcaca2bd500 dfbc3e972872 fbc95ee8777c 1c42a70fcba3 e833eb0a9e7f 31e4f3755627 98dbd1220929 ff65ea456ff1 4aee5293461d d1f635be9442 e2df27a81e5d db00632a61d6 75ceba747960 3f3508bd5f16 0a2858c0dad3
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 25 atoms, 31 tree operations, 23 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/ui/account/AccountScreen.kt +122 −0
| 1 | + | package io.oxygenhub.android.ui.account |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 4 | + | import androidx.compose.foundation.layout.Box |
|
| 5 | + | import androidx.compose.foundation.layout.Column |
|
| 6 | + | import androidx.compose.foundation.layout.Spacer |
|
| 7 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 8 | + | import androidx.compose.foundation.layout.height |
|
| 9 | + | import androidx.compose.foundation.layout.padding |
|
| 10 | + | import androidx.compose.foundation.layout.size |
|
| 11 | + | import androidx.compose.foundation.layout.widthIn |
|
| 12 | + | import androidx.compose.foundation.rememberScrollState |
|
| 13 | + | import androidx.compose.foundation.shape.CircleShape |
|
| 14 | + | import androidx.compose.foundation.verticalScroll |
|
| 15 | + | import androidx.compose.material.icons.Icons |
|
| 16 | + | import androidx.compose.material.icons.automirrored.outlined.Logout |
|
| 17 | + | import androidx.compose.material.icons.outlined.Dns |
|
| 18 | + | import androidx.compose.material.icons.outlined.Key |
|
| 19 | + | import androidx.compose.material.icons.outlined.Memory |
|
| 20 | + | import androidx.compose.material3.AlertDialog |
|
| 21 | + | import androidx.compose.material3.Card |
|
| 22 | + | import androidx.compose.material3.CircularProgressIndicator |
|
| 23 | + | import androidx.compose.material3.Icon |
|
| 24 | + | import androidx.compose.material3.ListItem |
|
| 25 | + | import androidx.compose.material3.MaterialTheme |
|
| 26 | + | import androidx.compose.material3.OutlinedButton |
|
| 27 | + | import androidx.compose.material3.Surface |
|
| 28 | + | import androidx.compose.material3.Text |
|
| 29 | + | import androidx.compose.material3.TextButton |
|
| 30 | + | import androidx.compose.runtime.Composable |
|
| 31 | + | import androidx.compose.runtime.getValue |
|
| 32 | + | import androidx.compose.runtime.mutableStateOf |
|
| 33 | + | import androidx.compose.runtime.remember |
|
| 34 | + | import androidx.compose.runtime.rememberCoroutineScope |
|
| 35 | + | import androidx.compose.runtime.setValue |
|
| 36 | + | import androidx.compose.ui.Alignment |
|
| 37 | + | import androidx.compose.ui.Modifier |
|
| 38 | + | import androidx.compose.ui.text.font.FontWeight |
|
| 39 | + | import androidx.compose.ui.unit.dp |
|
| 40 | + | import io.oxygenhub.android.auth.Session |
|
| 41 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 42 | + | import kotlinx.coroutines.launch |
|
| 43 | + | ||
| 44 | + | /** Who is signed in, to which hub, with what; and signing out. */ |
|
| 45 | + | @Composable |
|
| 46 | + | fun AccountScreen(session: Session, nativeCore: () -> String, onSignOut: suspend () -> Unit) { |
|
| 47 | + | var confirming by remember { mutableStateOf(false) } |
|
| 48 | + | var signingOut by remember { mutableStateOf(false) } |
|
| 49 | + | val scope = rememberCoroutineScope() |
|
| 50 | + | val core = remember { runCatching(nativeCore).getOrElse { "unavailable: ${it.message}" } } |
|
| 51 | + | ||
| 52 | + | AppScreen(title = "Account") { |
|
| 53 | + | Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { |
|
| 54 | + | Column( |
|
| 55 | + | Modifier.widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp), |
|
| 56 | + | verticalArrangement = Arrangement.spacedBy(16.dp), |
|
| 57 | + | ) { |
|
| 58 | + | Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { |
|
| 59 | + | Surface(Modifier.size(72.dp), shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer) { |
|
| 60 | + | Box(contentAlignment = Alignment.Center) { |
|
| 61 | + | Text( |
|
| 62 | + | session.username.take(1).uppercase(), |
|
| 63 | + | style = MaterialTheme.typography.headlineMedium, |
|
| 64 | + | color = MaterialTheme.colorScheme.onPrimaryContainer, |
|
| 65 | + | ) |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | Spacer(Modifier.height(8.dp)) |
|
| 69 | + | Text(session.username, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) |
|
| 70 | + | } |
|
| 71 | + | Card(Modifier.fillMaxWidth()) { |
|
| 72 | + | ListItem( |
|
| 73 | + | leadingContent = { Icon(Icons.Outlined.Dns, contentDescription = null) }, |
|
| 74 | + | headlineContent = { Text("Hub") }, |
|
| 75 | + | supportingContent = { Text(session.hub) }, |
|
| 76 | + | ) |
|
| 77 | + | ListItem( |
|
| 78 | + | leadingContent = { Icon(Icons.Outlined.Key, contentDescription = null) }, |
|
| 79 | + | headlineContent = { Text("Allowed") }, |
|
| 80 | + | supportingContent = { Text(session.scopes.ifEmpty { listOf("(the hub did not say)") }.joinToString(", ")) }, |
|
| 81 | + | ) |
|
| 82 | + | ListItem( |
|
| 83 | + | leadingContent = { Icon(Icons.Outlined.Memory, contentDescription = null) }, |
|
| 84 | + | headlineContent = { Text("Native core") }, |
|
| 85 | + | supportingContent = { Text(core) }, |
|
| 86 | + | ) |
|
| 87 | + | } |
|
| 88 | + | Text( |
|
| 89 | + | "This phone's sign-in is a device token, kept encrypted with a key in the Android Keystore. " + |
|
| 90 | + | "It lasts while it is used, and ends after 90 days without use. Signing out revokes it on the hub.", |
|
| 91 | + | style = MaterialTheme.typography.bodySmall, |
|
| 92 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 93 | + | ) |
|
| 94 | + | OutlinedButton(onClick = { confirming = true }, enabled = !signingOut, modifier = Modifier.fillMaxWidth()) { |
|
| 95 | + | if (signingOut) { |
|
| 96 | + | CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) |
|
| 97 | + | } else { |
|
| 98 | + | Icon(Icons.AutoMirrored.Outlined.Logout, contentDescription = null) |
|
| 99 | + | } |
|
| 100 | + | Spacer(Modifier.size(8.dp)) |
|
| 101 | + | Text("Sign out") |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | if (confirming) { |
|
| 108 | + | AlertDialog( |
|
| 109 | + | onDismissRequest = { confirming = false }, |
|
| 110 | + | title = { Text("Sign out?") }, |
|
| 111 | + | text = { Text("The token this phone uses is revoked on ${session.hub}. You can sign in again at any time.") }, |
|
| 112 | + | confirmButton = { |
|
| 113 | + | TextButton(onClick = { |
|
| 114 | + | confirming = false |
|
| 115 | + | signingOut = true |
|
| 116 | + | scope.launch { onSignOut() } |
|
| 117 | + | }) { Text("Sign out") } |
|
| 118 | + | }, |
|
| 119 | + | dismissButton = { TextButton(onClick = { confirming = false }) { Text("Cancel") } }, |
|
| 120 | + | ) |
|
| 121 | + | } |
|
| 122 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/common/AppScreen.kt +71 −0
| 1 | + | package io.oxygenhub.android.ui.common |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.layout.Box |
|
| 4 | + | import androidx.compose.foundation.layout.Column |
|
| 5 | + | import androidx.compose.foundation.layout.RowScope |
|
| 6 | + | import androidx.compose.foundation.layout.WindowInsets |
|
| 7 | + | import androidx.compose.foundation.layout.fillMaxSize |
|
| 8 | + | import androidx.compose.foundation.layout.padding |
|
| 9 | + | import androidx.compose.material.icons.Icons |
|
| 10 | + | import androidx.compose.material.icons.automirrored.filled.ArrowBack |
|
| 11 | + | import androidx.compose.material3.ExperimentalMaterial3Api |
|
| 12 | + | import androidx.compose.material3.Icon |
|
| 13 | + | import androidx.compose.material3.MaterialTheme |
|
| 14 | + | import androidx.compose.material3.IconButton |
|
| 15 | + | import androidx.compose.material3.Scaffold |
|
| 16 | + | import androidx.compose.material3.SnackbarHost |
|
| 17 | + | import androidx.compose.material3.SnackbarHostState |
|
| 18 | + | import androidx.compose.material3.Text |
|
| 19 | + | import androidx.compose.material3.TopAppBar |
|
| 20 | + | import androidx.compose.runtime.Composable |
|
| 21 | + | import androidx.compose.runtime.remember |
|
| 22 | + | import androidx.compose.ui.Modifier |
|
| 23 | + | import androidx.compose.ui.text.style.TextOverflow |
|
| 24 | + | ||
| 25 | + | /** |
|
| 26 | + | * A screen with its top bar. The navigation bar or rail around it takes |
|
| 27 | + | * the bottom and side insets, so only the top bar's own are applied here. |
|
| 28 | + | */ |
|
| 29 | + | @OptIn(ExperimentalMaterial3Api::class) |
|
| 30 | + | @Composable |
|
| 31 | + | fun AppScreen( |
|
| 32 | + | title: String, |
|
| 33 | + | modifier: Modifier = Modifier, |
|
| 34 | + | subtitle: String? = null, |
|
| 35 | + | onBack: (() -> Unit)? = null, |
|
| 36 | + | snackbar: SnackbarHostState = remember { SnackbarHostState() }, |
|
| 37 | + | actions: @Composable RowScope.() -> Unit = {}, |
|
| 38 | + | content: @Composable () -> Unit, |
|
| 39 | + | ) { |
|
| 40 | + | Scaffold( |
|
| 41 | + | modifier = modifier, |
|
| 42 | + | contentWindowInsets = WindowInsets(0, 0, 0, 0), |
|
| 43 | + | snackbarHost = { SnackbarHost(snackbar) }, |
|
| 44 | + | topBar = { |
|
| 45 | + | TopAppBar( |
|
| 46 | + | title = { |
|
| 47 | + | Column { |
|
| 48 | + | Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) |
|
| 49 | + | if (subtitle != null) { |
|
| 50 | + | Text( |
|
| 51 | + | subtitle, |
|
| 52 | + | style = MaterialTheme.typography.labelMedium, |
|
| 53 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 54 | + | maxLines = 1, |
|
| 55 | + | overflow = TextOverflow.Ellipsis, |
|
| 56 | + | ) |
|
| 57 | + | } |
|
| 58 | + | } |
|
| 59 | + | }, |
|
| 60 | + | navigationIcon = { |
|
| 61 | + | if (onBack != null) { |
|
| 62 | + | IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } |
|
| 63 | + | } |
|
| 64 | + | }, |
|
| 65 | + | actions = actions, |
|
| 66 | + | ) |
|
| 67 | + | }, |
|
| 68 | + | ) { padding -> |
|
| 69 | + | Box(Modifier.fillMaxSize().padding(padding)) { content() } |
|
| 70 | + | } |
|
| 71 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/common/Load.kt +49 −0
| 1 | + | package io.oxygenhub.android.ui.common |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import androidx.lifecycle.viewModelScope |
|
| 5 | + | import io.oxygenhub.android.api.userMessage |
|
| 6 | + | import kotlinx.coroutines.CancellationException |
|
| 7 | + | import kotlinx.coroutines.Job |
|
| 8 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 9 | + | import kotlinx.coroutines.launch |
|
| 10 | + | ||
| 11 | + | /** Something fetched from the hub: on its way, here, or failed. */ |
|
| 12 | + | sealed interface Load<out T> { |
|
| 13 | + | data object Loading : Load<Nothing> |
|
| 14 | + | ||
| 15 | + | /** [refreshing] while a pull-to-refresh fetches it again. */ |
|
| 16 | + | data class Ready<T>(val value: T, val refreshing: Boolean = false) : Load<T> |
|
| 17 | + | ||
| 18 | + | data class Failed(val message: String) : Load<Nothing> |
|
| 19 | + | ||
| 20 | + | val valueOrNull: T? get() = (this as? Ready<T>)?.value |
|
| 21 | + | ||
| 22 | + | companion object { |
|
| 23 | + | fun failed(e: Throwable): Failed = Failed(e.userMessage()) |
|
| 24 | + | } |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | /** The same value, marked as being fetched again (or Loading if there is none yet). */ |
|
| 28 | + | fun <T> Load<T>.refreshing(): Load<T> = when (this) { |
|
| 29 | + | is Load.Ready -> copy(refreshing = true) |
|
| 30 | + | else -> Load.Loading |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | /** |
|
| 34 | + | * Fetches into [into], keeping what is shown while it refreshes; cancels |
|
| 35 | + | * [previous] first, so a second pull never races the first. |
|
| 36 | + | */ |
|
| 37 | + | fun <T> ViewModel.load(into: MutableStateFlow<Load<T>>, previous: Job?, fetch: suspend () -> T): Job { |
|
| 38 | + | previous?.cancel() |
|
| 39 | + | into.value = into.value.refreshing() |
|
| 40 | + | return viewModelScope.launch { |
|
| 41 | + | into.value = try { |
|
| 42 | + | Load.Ready(fetch()) |
|
| 43 | + | } catch (e: CancellationException) { |
|
| 44 | + | throw e |
|
| 45 | + | } catch (e: Exception) { |
|
| 46 | + | Load.failed(e) |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/common/States.kt +107 −0
| 1 | + | package io.oxygenhub.android.ui.common |
|
| 2 | + | ||
| 3 | + | import android.text.format.DateUtils |
|
| 4 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 5 | + | import androidx.compose.foundation.layout.Box |
|
| 6 | + | import androidx.compose.foundation.layout.Column |
|
| 7 | + | import androidx.compose.foundation.layout.Spacer |
|
| 8 | + | import androidx.compose.foundation.layout.fillMaxSize |
|
| 9 | + | import androidx.compose.foundation.layout.height |
|
| 10 | + | import androidx.compose.foundation.layout.padding |
|
| 11 | + | import androidx.compose.foundation.layout.size |
|
| 12 | + | import androidx.compose.material.icons.Icons |
|
| 13 | + | import androidx.compose.material.icons.outlined.CloudOff |
|
| 14 | + | import androidx.compose.material3.Button |
|
| 15 | + | import androidx.compose.material3.CircularProgressIndicator |
|
| 16 | + | import androidx.compose.material3.ExperimentalMaterial3Api |
|
| 17 | + | import androidx.compose.material3.Icon |
|
| 18 | + | import androidx.compose.material3.MaterialTheme |
|
| 19 | + | import androidx.compose.material3.Text |
|
| 20 | + | import androidx.compose.material3.pulltorefresh.PullToRefreshBox |
|
| 21 | + | import androidx.compose.runtime.Composable |
|
| 22 | + | import androidx.compose.ui.Alignment |
|
| 23 | + | import androidx.compose.ui.Modifier |
|
| 24 | + | import androidx.compose.ui.graphics.vector.ImageVector |
|
| 25 | + | import androidx.compose.ui.text.style.TextAlign |
|
| 26 | + | import androidx.compose.ui.unit.dp |
|
| 27 | + | ||
| 28 | + | /** A spinner in the middle of the space. */ |
|
| 29 | + | @Composable |
|
| 30 | + | fun LoadingState(modifier: Modifier = Modifier) { |
|
| 31 | + | Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
|
| 32 | + | CircularProgressIndicator() |
|
| 33 | + | } |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | /** Nothing to show, said plainly, with an icon. */ |
|
| 37 | + | @Composable |
|
| 38 | + | fun EmptyState( |
|
| 39 | + | icon: ImageVector, |
|
| 40 | + | title: String, |
|
| 41 | + | body: String, |
|
| 42 | + | modifier: Modifier = Modifier, |
|
| 43 | + | action: (@Composable () -> Unit)? = null, |
|
| 44 | + | ) { |
|
| 45 | + | Message(icon, title, body, modifier) { |
|
| 46 | + | if (action != null) { |
|
| 47 | + | Spacer(Modifier.height(8.dp)) |
|
| 48 | + | action() |
|
| 49 | + | } |
|
| 50 | + | } |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | /** What went wrong, and a way to try again. */ |
|
| 54 | + | @Composable |
|
| 55 | + | fun ErrorState(message: String, onRetry: () -> Unit, modifier: Modifier = Modifier) { |
|
| 56 | + | Message(Icons.Outlined.CloudOff, "Couldn't load this", message, modifier) { |
|
| 57 | + | Spacer(Modifier.height(16.dp)) |
|
| 58 | + | Button(onClick = onRetry) { Text("Try again") } |
|
| 59 | + | } |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | @Composable |
|
| 63 | + | private fun Message( |
|
| 64 | + | icon: ImageVector, |
|
| 65 | + | title: String, |
|
| 66 | + | body: String, |
|
| 67 | + | modifier: Modifier = Modifier, |
|
| 68 | + | extra: @Composable () -> Unit = {}, |
|
| 69 | + | ) { |
|
| 70 | + | Column( |
|
| 71 | + | modifier.fillMaxSize().padding(32.dp), |
|
| 72 | + | horizontalAlignment = Alignment.CenterHorizontally, |
|
| 73 | + | verticalArrangement = Arrangement.Center, |
|
| 74 | + | ) { |
|
| 75 | + | Icon(icon, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) |
|
| 76 | + | Spacer(Modifier.height(16.dp)) |
|
| 77 | + | Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) |
|
| 78 | + | Spacer(Modifier.height(8.dp)) |
|
| 79 | + | Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) |
|
| 80 | + | extra() |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | /** |
|
| 85 | + | * The usual body of a screen over a [Load]: a spinner, the error with a |
|
| 86 | + | * retry, or [content] in a pull-to-refresh box. |
|
| 87 | + | */ |
|
| 88 | + | @OptIn(ExperimentalMaterial3Api::class) |
|
| 89 | + | @Composable |
|
| 90 | + | fun <T> LoadContent( |
|
| 91 | + | load: Load<T>, |
|
| 92 | + | onRefresh: () -> Unit, |
|
| 93 | + | modifier: Modifier = Modifier, |
|
| 94 | + | content: @Composable (T) -> Unit, |
|
| 95 | + | ) { |
|
| 96 | + | when (load) { |
|
| 97 | + | Load.Loading -> LoadingState(modifier) |
|
| 98 | + | is Load.Failed -> ErrorState(load.message, onRefresh, modifier) |
|
| 99 | + | is Load.Ready -> PullToRefreshBox(isRefreshing = load.refreshing, onRefresh = onRefresh, modifier = modifier.fillMaxSize()) { |
|
| 100 | + | content(load.value) |
|
| 101 | + | } |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | /** "3 hours ago", from milliseconds since the epoch. */ |
|
| 106 | + | fun relativeTime(ms: Long, now: Long = System.currentTimeMillis()): String = |
|
| 107 | + | if (ms <= 0) "" else DateUtils.getRelativeTimeSpanString(ms, now, DateUtils.MINUTE_IN_MILLIS).toString() |
added app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardScreen.kt +138 −0
| 1 | + | package io.oxygenhub.android.ui.dashboard |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.clickable |
|
| 4 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 5 | + | import androidx.compose.foundation.layout.BoxWithConstraints |
|
| 6 | + | import androidx.compose.foundation.layout.Column |
|
| 7 | + | import androidx.compose.foundation.layout.Row |
|
| 8 | + | import androidx.compose.foundation.layout.Spacer |
|
| 9 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 10 | + | import androidx.compose.foundation.layout.height |
|
| 11 | + | import androidx.compose.foundation.layout.padding |
|
| 12 | + | import androidx.compose.foundation.rememberScrollState |
|
| 13 | + | import androidx.compose.foundation.verticalScroll |
|
| 14 | + | import androidx.compose.material.icons.Icons |
|
| 15 | + | import androidx.compose.material.icons.outlined.Inbox |
|
| 16 | + | import androidx.compose.material.icons.outlined.MarkEmailUnread |
|
| 17 | + | import androidx.compose.material3.Card |
|
| 18 | + | import androidx.compose.material3.HorizontalDivider |
|
| 19 | + | import androidx.compose.material3.Icon |
|
| 20 | + | import androidx.compose.material3.ListItem |
|
| 21 | + | import androidx.compose.material3.MaterialTheme |
|
| 22 | + | import androidx.compose.material3.Text |
|
| 23 | + | import androidx.compose.material3.TextButton |
|
| 24 | + | import androidx.compose.runtime.Composable |
|
| 25 | + | import androidx.compose.runtime.getValue |
|
| 26 | + | import androidx.compose.ui.Modifier |
|
| 27 | + | import androidx.compose.ui.text.font.FontWeight |
|
| 28 | + | import androidx.compose.ui.unit.dp |
|
| 29 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 30 | + | import io.oxygenhub.android.api.Inbox |
|
| 31 | + | import io.oxygenhub.android.api.Repository |
|
| 32 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 33 | + | import io.oxygenhub.android.ui.common.Load |
|
| 34 | + | import io.oxygenhub.android.ui.common.LoadContent |
|
| 35 | + | import io.oxygenhub.android.ui.repos.RepositoryRow |
|
| 36 | + | ||
| 37 | + | @Composable |
|
| 38 | + | fun DashboardScreen( |
|
| 39 | + | vm: DashboardViewModel, |
|
| 40 | + | hub: String, |
|
| 41 | + | inbox: Load<Inbox>, |
|
| 42 | + | onRefreshInbox: () -> Unit, |
|
| 43 | + | onOpenRepository: (Repository) -> Unit, |
|
| 44 | + | onAllRepositories: () -> Unit, |
|
| 45 | + | onInbox: () -> Unit, |
|
| 46 | + | ) { |
|
| 47 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 48 | + | AppScreen(title = "Dashboard", subtitle = hub.substringAfter("://")) { |
|
| 49 | + | LoadContent(state, onRefresh = { vm.refresh(); onRefreshInbox() }) { d -> |
|
| 50 | + | DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox) |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | @Composable |
|
| 56 | + | fun DashboardContent( |
|
| 57 | + | d: Dashboard, |
|
| 58 | + | inbox: Load<Inbox>, |
|
| 59 | + | onOpenRepository: (Repository) -> Unit, |
|
| 60 | + | onAllRepositories: () -> Unit, |
|
| 61 | + | onInbox: () -> Unit, |
|
| 62 | + | ) { |
|
| 63 | + | BoxWithConstraints { |
|
| 64 | + | // Side by side once there is room (a tablet, or a foldable open). |
|
| 65 | + | val wide = maxWidth >= 720.dp |
|
| 66 | + | Column( |
|
| 67 | + | Modifier |
|
| 68 | + | .fillMaxWidth() |
|
| 69 | + | .verticalScroll(rememberScrollState()) |
|
| 70 | + | .padding(16.dp), |
|
| 71 | + | ) { |
|
| 72 | + | Text(d.greeting, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) |
|
| 73 | + | Spacer(Modifier.height(16.dp)) |
|
| 74 | + | if (wide) { |
|
| 75 | + | Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { |
|
| 76 | + | Column(Modifier.weight(2f)) { Recent(d, onOpenRepository, onAllRepositories) } |
|
| 77 | + | Column(Modifier.weight(1f)) { Unread(inbox, onInbox) } |
|
| 78 | + | } |
|
| 79 | + | } else { |
|
| 80 | + | Unread(inbox, onInbox) |
|
| 81 | + | Spacer(Modifier.height(16.dp)) |
|
| 82 | + | Recent(d, onOpenRepository, onAllRepositories) |
|
| 83 | + | } |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | @Composable |
|
| 89 | + | private fun Unread(inbox: Load<Inbox>, onInbox: () -> Unit) { |
|
| 90 | + | Card(Modifier.fillMaxWidth().clickable(onClick = onInbox)) { |
|
| 91 | + | ListItem( |
|
| 92 | + | leadingContent = { |
|
| 93 | + | Icon(if ((inbox.valueOrNull?.unread ?: 0) > 0) Icons.Outlined.MarkEmailUnread else Icons.Outlined.Inbox, contentDescription = null) |
|
| 94 | + | }, |
|
| 95 | + | headlineContent = { |
|
| 96 | + | Text( |
|
| 97 | + | when (inbox) { |
|
| 98 | + | Load.Loading -> "Inbox" |
|
| 99 | + | is Load.Failed -> "Inbox unavailable" |
|
| 100 | + | is Load.Ready -> when (val n = inbox.value.unread) { |
|
| 101 | + | 0L -> "Nothing unread" |
|
| 102 | + | 1L -> "1 unread notification" |
|
| 103 | + | else -> "$n unread notifications" |
|
| 104 | + | } |
|
| 105 | + | }, |
|
| 106 | + | ) |
|
| 107 | + | }, |
|
| 108 | + | supportingContent = (inbox as? Load.Failed)?.let { f -> { Text(f.message) } }, |
|
| 109 | + | ) |
|
| 110 | + | } |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | @Composable |
|
| 114 | + | private fun Recent(d: Dashboard, onOpenRepository: (Repository) -> Unit, onAllRepositories: () -> Unit) { |
|
| 115 | + | Card(Modifier.fillMaxWidth()) { |
|
| 116 | + | Text( |
|
| 117 | + | if (d.recentAreYours) "Your recent repositories" else "Recently updated on the hub", |
|
| 118 | + | style = MaterialTheme.typography.titleMedium, |
|
| 119 | + | modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp), |
|
| 120 | + | ) |
|
| 121 | + | if (!d.recentAreYours) { |
|
| 122 | + | Text( |
|
| 123 | + | "You have no repositories of your own yet.", |
|
| 124 | + | style = MaterialTheme.typography.bodySmall, |
|
| 125 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 126 | + | modifier = Modifier.padding(horizontal = 16.dp), |
|
| 127 | + | ) |
|
| 128 | + | } |
|
| 129 | + | if (d.recent.isEmpty()) { |
|
| 130 | + | Text("No repositories to show.", modifier = Modifier.padding(16.dp)) |
|
| 131 | + | } |
|
| 132 | + | d.recent.forEachIndexed { i, repo -> |
|
| 133 | + | if (i > 0) HorizontalDivider() |
|
| 134 | + | RepositoryRow(repo, onClick = { onOpenRepository(repo) }) |
|
| 135 | + | } |
|
| 136 | + | TextButton(onClick = onAllRepositories, modifier = Modifier.padding(8.dp)) { Text("All repositories") } |
|
| 137 | + | } |
|
| 138 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardViewModel.kt +84 −0
| 1 | + | package io.oxygenhub.android.ui.dashboard |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import io.oxygenhub.android.api.HubApi |
|
| 5 | + | import io.oxygenhub.android.api.Repository |
|
| 6 | + | import io.oxygenhub.android.api.User |
|
| 7 | + | import io.oxygenhub.android.auth.Session |
|
| 8 | + | import io.oxygenhub.android.ui.common.Load |
|
| 9 | + | import io.oxygenhub.android.ui.common.load |
|
| 10 | + | import kotlinx.coroutines.CancellationException |
|
| 11 | + | import kotlinx.coroutines.Job |
|
| 12 | + | import kotlinx.coroutines.async |
|
| 13 | + | import kotlinx.coroutines.coroutineScope |
|
| 14 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 15 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 16 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 17 | + | import java.util.Calendar |
|
| 18 | + | ||
| 19 | + | data class Dashboard( |
|
| 20 | + | val greeting: String, |
|
| 21 | + | /** The account, or null when the token may not read it (no `user:read`). */ |
|
| 22 | + | val user: User?, |
|
| 23 | + | /** The newest-updated repositories: the user's own, or everyone's when they own none. */ |
|
| 24 | + | val recent: List<Repository>, |
|
| 25 | + | val recentAreYours: Boolean, |
|
| 26 | + | val repositoryCount: Int, |
|
| 27 | + | ) |
|
| 28 | + | ||
| 29 | + | class DashboardViewModel( |
|
| 30 | + | private val api: HubApi, |
|
| 31 | + | private val session: Session, |
|
| 32 | + | private val hourOfDay: () -> Int = { Calendar.getInstance().get(Calendar.HOUR_OF_DAY) }, |
|
| 33 | + | ) : ViewModel() { |
|
| 34 | + | private val _state = MutableStateFlow<Load<Dashboard>>(Load.Loading) |
|
| 35 | + | val state: StateFlow<Load<Dashboard>> = _state.asStateFlow() |
|
| 36 | + | ||
| 37 | + | private var job: Job? = null |
|
| 38 | + | ||
| 39 | + | init { |
|
| 40 | + | refresh() |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | fun refresh() { |
|
| 44 | + | job = load(_state, job) { fetch() } |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | private suspend fun fetch(): Dashboard = coroutineScope { |
|
| 48 | + | // The account is a nicety (a display name); the repositories are the page. |
|
| 49 | + | val user = async { |
|
| 50 | + | try { |
|
| 51 | + | api.user() |
|
| 52 | + | } catch (e: CancellationException) { |
|
| 53 | + | throw e |
|
| 54 | + | } catch (_: Exception) { |
|
| 55 | + | null |
|
| 56 | + | } |
|
| 57 | + | } |
|
| 58 | + | val repos = api.repositories() |
|
| 59 | + | val me = user.await() |
|
| 60 | + | val username = me?.username ?: session.username |
|
| 61 | + | val mine = repos.filter { it.owner == username } |
|
| 62 | + | val pool = mine.ifEmpty { repos } |
|
| 63 | + | Dashboard( |
|
| 64 | + | greeting = greeting(hourOfDay(), me?.shownName ?: session.username), |
|
| 65 | + | user = me, |
|
| 66 | + | recent = pool.sortedByDescending { it.updatedMs }.take(RECENT), |
|
| 67 | + | recentAreYours = mine.isNotEmpty(), |
|
| 68 | + | repositoryCount = mine.size, |
|
| 69 | + | ) |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | companion object { |
|
| 73 | + | const val RECENT = 5 |
|
| 74 | + | ||
| 75 | + | fun greeting(hour: Int, name: String): String { |
|
| 76 | + | val part = when (hour) { |
|
| 77 | + | in 5..11 -> "Good morning" |
|
| 78 | + | in 12..17 -> "Good afternoon" |
|
| 79 | + | else -> "Good evening" |
|
| 80 | + | } |
|
| 81 | + | return "$part, $name" |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/inbox/InboxScreen.kt +146 −0
| 1 | + | package io.oxygenhub.android.ui.inbox |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.clickable |
|
| 4 | + | import androidx.compose.foundation.layout.Box |
|
| 5 | + | import androidx.compose.foundation.layout.fillMaxSize |
|
| 6 | + | import androidx.compose.foundation.layout.size |
|
| 7 | + | import androidx.compose.foundation.lazy.LazyColumn |
|
| 8 | + | import androidx.compose.foundation.lazy.items |
|
| 9 | + | import androidx.compose.foundation.shape.CircleShape |
|
| 10 | + | import androidx.compose.material.icons.Icons |
|
| 11 | + | import androidx.compose.material.icons.outlined.AlternateEmail |
|
| 12 | + | import androidx.compose.material.icons.outlined.BugReport |
|
| 13 | + | import androidx.compose.material.icons.automirrored.outlined.CallMerge |
|
| 14 | + | import androidx.compose.material.icons.outlined.Backup |
|
| 15 | + | import androidx.compose.material.icons.outlined.ChatBubbleOutline |
|
| 16 | + | import androidx.compose.material.icons.outlined.DoneAll |
|
| 17 | + | import androidx.compose.material.icons.outlined.Inbox |
|
| 18 | + | import androidx.compose.material.icons.outlined.Notifications |
|
| 19 | + | import androidx.compose.material.icons.outlined.PlayCircle |
|
| 20 | + | import androidx.compose.material.icons.outlined.RateReview |
|
| 21 | + | import androidx.compose.material3.HorizontalDivider |
|
| 22 | + | import androidx.compose.material3.Icon |
|
| 23 | + | import androidx.compose.material3.IconButton |
|
| 24 | + | import androidx.compose.material3.ListItem |
|
| 25 | + | import androidx.compose.material3.ListItemDefaults |
|
| 26 | + | import androidx.compose.material3.MaterialTheme |
|
| 27 | + | import androidx.compose.material3.SnackbarHostState |
|
| 28 | + | import androidx.compose.material3.Surface |
|
| 29 | + | import androidx.compose.material3.Text |
|
| 30 | + | import androidx.compose.runtime.Composable |
|
| 31 | + | import androidx.compose.runtime.LaunchedEffect |
|
| 32 | + | import androidx.compose.runtime.getValue |
|
| 33 | + | import androidx.compose.runtime.remember |
|
| 34 | + | import androidx.compose.ui.Alignment |
|
| 35 | + | import androidx.compose.ui.Modifier |
|
| 36 | + | import androidx.compose.ui.graphics.vector.ImageVector |
|
| 37 | + | import androidx.compose.ui.platform.LocalUriHandler |
|
| 38 | + | import androidx.compose.ui.text.font.FontWeight |
|
| 39 | + | import androidx.compose.ui.text.style.TextOverflow |
|
| 40 | + | import androidx.compose.ui.unit.dp |
|
| 41 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 42 | + | import io.oxygenhub.android.api.Notification |
|
| 43 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 44 | + | import io.oxygenhub.android.ui.common.EmptyState |
|
| 45 | + | import io.oxygenhub.android.ui.common.LoadContent |
|
| 46 | + | import io.oxygenhub.android.ui.common.relativeTime |
|
| 47 | + | ||
| 48 | + | /** The inbox. A notification opens its page on the hub (in-app pages come with #4 and #5). */ |
|
| 49 | + | @Composable |
|
| 50 | + | fun InboxScreen(vm: InboxViewModel) { |
|
| 51 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 52 | + | val message by vm.message.collectAsStateWithLifecycle() |
|
| 53 | + | val snackbar = remember { SnackbarHostState() } |
|
| 54 | + | val uri = LocalUriHandler.current |
|
| 55 | + | LaunchedEffect(message) { |
|
| 56 | + | message?.let { |
|
| 57 | + | snackbar.showSnackbar(it) |
|
| 58 | + | vm.messageShown() |
|
| 59 | + | } |
|
| 60 | + | } |
|
| 61 | + | val unread = state.valueOrNull?.unread ?: 0 |
|
| 62 | + | AppScreen( |
|
| 63 | + | title = "Inbox", |
|
| 64 | + | subtitle = if (unread > 0) "$unread unread" else null, |
|
| 65 | + | snackbar = snackbar, |
|
| 66 | + | actions = { |
|
| 67 | + | if (unread > 0) { |
|
| 68 | + | IconButton(onClick = vm::markAllRead) { Icon(Icons.Outlined.DoneAll, contentDescription = "Mark all read") } |
|
| 69 | + | } |
|
| 70 | + | }, |
|
| 71 | + | ) { |
|
| 72 | + | LoadContent(state, onRefresh = vm::refresh) { inbox -> |
|
| 73 | + | if (inbox.items.isEmpty()) { |
|
| 74 | + | EmptyState(Icons.Outlined.Inbox, "Your inbox is empty", "Mentions, reviews and activity on what you watch arrive here.") |
|
| 75 | + | } else { |
|
| 76 | + | LazyColumn(Modifier.fillMaxSize()) { |
|
| 77 | + | items(inbox.items, key = { it.id }) { n -> |
|
| 78 | + | NotificationRow(n, onClick = { |
|
| 79 | + | vm.markRead(n) |
|
| 80 | + | if (n.url.isNotBlank()) uri.openUri(n.url) |
|
| 81 | + | }) |
|
| 82 | + | HorizontalDivider() |
|
| 83 | + | } |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | @Composable |
|
| 91 | + | fun NotificationRow(n: Notification, onClick: () -> Unit) { |
|
| 92 | + | ListItem( |
|
| 93 | + | modifier = Modifier.clickable(onClick = onClick), |
|
| 94 | + | colors = if (n.read) ListItemDefaults.colors() else ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), |
|
| 95 | + | leadingContent = { |
|
| 96 | + | Box(contentAlignment = Alignment.TopEnd) { |
|
| 97 | + | Icon(iconFor(n.kind), contentDescription = null) |
|
| 98 | + | if (!n.read) { |
|
| 99 | + | Surface(Modifier.size(8.dp), shape = CircleShape, color = MaterialTheme.colorScheme.primary) {} |
|
| 100 | + | } |
|
| 101 | + | } |
|
| 102 | + | }, |
|
| 103 | + | headlineContent = { |
|
| 104 | + | Text(n.title, maxLines = 2, overflow = TextOverflow.Ellipsis, fontWeight = if (n.read) FontWeight.Normal else FontWeight.SemiBold) |
|
| 105 | + | }, |
|
| 106 | + | supportingContent = { |
|
| 107 | + | val who = if (n.actor.isNotBlank()) "${n.actor} · " else "" |
|
| 108 | + | Text("$who${describe(n.kind)} · ${relativeTime(n.atMs)}", maxLines = 1, overflow = TextOverflow.Ellipsis) |
|
| 109 | + | }, |
|
| 110 | + | ) |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | private fun iconFor(kind: String): ImageVector = when { |
|
| 114 | + | kind == "mention" -> Icons.Outlined.AlternateEmail |
|
| 115 | + | kind.startsWith("proposal.review") -> Icons.Outlined.RateReview |
|
| 116 | + | kind.endsWith(".comment") -> Icons.Outlined.ChatBubbleOutline |
|
| 117 | + | kind.startsWith("proposal") -> Icons.AutoMirrored.Outlined.CallMerge |
|
| 118 | + | kind.startsWith("issue") -> Icons.Outlined.BugReport |
|
| 119 | + | kind.startsWith("flow") -> Icons.Outlined.PlayCircle |
|
| 120 | + | kind.startsWith("backup") -> Icons.Outlined.Backup |
|
| 121 | + | else -> Icons.Outlined.Notifications |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | /** |
|
| 125 | + | * What a kind of notification is called, as the hub's emails name them |
|
| 126 | + | * (oxygenhub crates/hub/src/notify/mod.rs, `label`), without the emoji. |
|
| 127 | + | */ |
|
| 128 | + | internal fun describe(kind: String): String = when (kind) { |
|
| 129 | + | "flow.failed" -> "Build broken" |
|
| 130 | + | "flow.fixed" -> "Build fixed" |
|
| 131 | + | "flow.runner_drained" -> "Runner drained" |
|
| 132 | + | "backup" -> "Backup failed" |
|
| 133 | + | "backup.recovered" -> "Backup recovered" |
|
| 134 | + | "backup.summary" -> "Daily backup summary" |
|
| 135 | + | "proposal.opened" -> "New proposal" |
|
| 136 | + | "proposal.review_requested" -> "Review requested" |
|
| 137 | + | "proposal.review" -> "New review" |
|
| 138 | + | "proposal.landed" -> "Proposal landed" |
|
| 139 | + | "proposal.comment", "issue.comment", "drop.comment" -> "New comment" |
|
| 140 | + | "issue.opened" -> "New issue" |
|
| 141 | + | "issue.assigned" -> "Issue assigned" |
|
| 142 | + | "issue.closed" -> "Issue closed" |
|
| 143 | + | "account.registered" -> "New sign-up" |
|
| 144 | + | "mention" -> "Mentioned you" |
|
| 145 | + | else -> kind.replace('.', ' ').replace('_', ' ').replaceFirstChar { it.uppercase() } |
|
| 146 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/inbox/InboxViewModel.kt +91 −0
| 1 | + | package io.oxygenhub.android.ui.inbox |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import androidx.lifecycle.viewModelScope |
|
| 5 | + | import io.oxygenhub.android.api.HubApi |
|
| 6 | + | import io.oxygenhub.android.api.Inbox |
|
| 7 | + | import io.oxygenhub.android.api.Notification |
|
| 8 | + | import io.oxygenhub.android.api.userMessage |
|
| 9 | + | import io.oxygenhub.android.ui.common.Load |
|
| 10 | + | import io.oxygenhub.android.ui.common.load |
|
| 11 | + | import kotlinx.coroutines.CancellationException |
|
| 12 | + | import kotlinx.coroutines.Job |
|
| 13 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 14 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 15 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 16 | + | import kotlinx.coroutines.flow.update |
|
| 17 | + | import kotlinx.coroutines.launch |
|
| 18 | + | ||
| 19 | + | /** |
|
| 20 | + | * The inbox, shared by the inbox screen, the dashboard's unread count and |
|
| 21 | + | * the navigation badge. Marking read shows at once and is put back if the |
|
| 22 | + | * hub refuses it. |
|
| 23 | + | */ |
|
| 24 | + | class InboxViewModel(private val api: HubApi) : ViewModel() { |
|
| 25 | + | private val _state = MutableStateFlow<Load<Inbox>>(Load.Loading) |
|
| 26 | + | val state: StateFlow<Load<Inbox>> = _state.asStateFlow() |
|
| 27 | + | ||
| 28 | + | /** A one-line message for a snackbar (a failed mark-read). */ |
|
| 29 | + | private val _message = MutableStateFlow<String?>(null) |
|
| 30 | + | val message: StateFlow<String?> = _message.asStateFlow() |
|
| 31 | + | ||
| 32 | + | private var job: Job? = null |
|
| 33 | + | ||
| 34 | + | init { |
|
| 35 | + | refresh() |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | fun refresh() { |
|
| 39 | + | job = load(_state, job) { api.inbox(LIMIT) } |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | /** Marks [n] read (if it is not already); the caller then opens its page. */ |
|
| 43 | + | fun markRead(n: Notification) { |
|
| 44 | + | if (n.read) return |
|
| 45 | + | val before = _state.value |
|
| 46 | + | edit { inbox -> |
|
| 47 | + | inbox.copy( |
|
| 48 | + | unread = (inbox.unread - 1).coerceAtLeast(0), |
|
| 49 | + | items = inbox.items.map { if (it.id == n.id) it.copy(read = true) else it }, |
|
| 50 | + | ) |
|
| 51 | + | } |
|
| 52 | + | viewModelScope.launch { |
|
| 53 | + | try { |
|
| 54 | + | api.markRead(n.id) |
|
| 55 | + | } catch (e: CancellationException) { |
|
| 56 | + | throw e |
|
| 57 | + | } catch (e: Exception) { |
|
| 58 | + | _state.value = before |
|
| 59 | + | _message.value = "Couldn't mark it read: ${e.userMessage()}" |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | fun markAllRead() { |
|
| 65 | + | val before = _state.value |
|
| 66 | + | if ((before.valueOrNull?.unread ?: 0) == 0L) return |
|
| 67 | + | edit { inbox -> inbox.copy(unread = 0, items = inbox.items.map { it.copy(read = true) }) } |
|
| 68 | + | viewModelScope.launch { |
|
| 69 | + | try { |
|
| 70 | + | api.markAllRead() |
|
| 71 | + | } catch (e: CancellationException) { |
|
| 72 | + | throw e |
|
| 73 | + | } catch (e: Exception) { |
|
| 74 | + | _state.value = before |
|
| 75 | + | _message.value = "Couldn't mark the inbox read: ${e.userMessage()}" |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | fun messageShown() { |
|
| 81 | + | _message.value = null |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | private fun edit(change: (Inbox) -> Inbox) { |
|
| 85 | + | _state.update { s -> if (s is Load.Ready) s.copy(value = change(s.value)) else s } |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | companion object { |
|
| 89 | + | const val LIMIT = 100 |
|
| 90 | + | } |
|
| 91 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/issues/IssuesScreen.kt +163 −0
| 1 | + | package io.oxygenhub.android.ui.issues |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.background |
|
| 4 | + | import androidx.compose.foundation.clickable |
|
| 5 | + | import androidx.compose.foundation.horizontalScroll |
|
| 6 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 7 | + | import androidx.compose.foundation.layout.Column |
|
| 8 | + | import androidx.compose.foundation.layout.FlowRow |
|
| 9 | + | import androidx.compose.foundation.layout.Row |
|
| 10 | + | import androidx.compose.foundation.layout.fillMaxSize |
|
| 11 | + | import androidx.compose.foundation.layout.padding |
|
| 12 | + | import androidx.compose.foundation.lazy.LazyColumn |
|
| 13 | + | import androidx.compose.foundation.lazy.items |
|
| 14 | + | import androidx.compose.foundation.rememberScrollState |
|
| 15 | + | import androidx.compose.foundation.shape.RoundedCornerShape |
|
| 16 | + | import androidx.compose.material.icons.Icons |
|
| 17 | + | import androidx.compose.material.icons.outlined.CheckCircle |
|
| 18 | + | import androidx.compose.material.icons.outlined.ChatBubbleOutline |
|
| 19 | + | import androidx.compose.material.icons.outlined.PushPin |
|
| 20 | + | import androidx.compose.material.icons.outlined.RadioButtonUnchecked |
|
| 21 | + | import androidx.compose.material.icons.outlined.TaskAlt |
|
| 22 | + | import androidx.compose.material3.FilterChip |
|
| 23 | + | import androidx.compose.material3.HorizontalDivider |
|
| 24 | + | import androidx.compose.material3.Icon |
|
| 25 | + | import androidx.compose.material3.ListItem |
|
| 26 | + | import androidx.compose.material3.MaterialTheme |
|
| 27 | + | import androidx.compose.material3.Text |
|
| 28 | + | import androidx.compose.runtime.Composable |
|
| 29 | + | import androidx.compose.runtime.getValue |
|
| 30 | + | import androidx.compose.ui.Alignment |
|
| 31 | + | import androidx.compose.ui.Modifier |
|
| 32 | + | import androidx.compose.ui.draw.clip |
|
| 33 | + | import androidx.compose.ui.graphics.Color |
|
| 34 | + | import androidx.compose.ui.graphics.luminance |
|
| 35 | + | import androidx.compose.ui.platform.LocalUriHandler |
|
| 36 | + | import androidx.compose.ui.text.style.TextOverflow |
|
| 37 | + | import androidx.compose.ui.unit.dp |
|
| 38 | + | import androidx.compose.ui.unit.sp |
|
| 39 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 40 | + | import io.oxygenhub.android.api.Issue |
|
| 41 | + | import io.oxygenhub.android.api.IssueState |
|
| 42 | + | import io.oxygenhub.android.api.Label |
|
| 43 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 44 | + | import io.oxygenhub.android.ui.common.EmptyState |
|
| 45 | + | import io.oxygenhub.android.ui.common.LoadContent |
|
| 46 | + | import io.oxygenhub.android.ui.common.relativeTime |
|
| 47 | + | import io.oxygenhub.android.ui.theme.LocalStatusColors |
|
| 48 | + | ||
| 49 | + | /** |
|
| 50 | + | * A repository's issues. For now this is the repository's whole detail |
|
| 51 | + | * pane; the repository overview (oxygenhub-android#3) will hold it as one |
|
| 52 | + | * of its tabs. An issue opens on the hub's web page until #4 brings the |
|
| 53 | + | * issue screen. |
|
| 54 | + | */ |
|
| 55 | + | @Composable |
|
| 56 | + | fun IssuesScreen(vm: IssuesViewModel, onBack: (() -> Unit)?) { |
|
| 57 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 58 | + | val filter by vm.filter.collectAsStateWithLifecycle() |
|
| 59 | + | val uri = LocalUriHandler.current |
|
| 60 | + | AppScreen(title = "${vm.owner}/${vm.name}", subtitle = "Issues", onBack = onBack) { |
|
| 61 | + | Column(Modifier.fillMaxSize()) { |
|
| 62 | + | Row( |
|
| 63 | + | Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp), |
|
| 64 | + | horizontalArrangement = Arrangement.spacedBy(8.dp), |
|
| 65 | + | ) { |
|
| 66 | + | IssueState.entries.forEach { s -> |
|
| 67 | + | FilterChip(selected = filter == s, onClick = { vm.setFilter(s) }, label = { Text(s.name) }) |
|
| 68 | + | } |
|
| 69 | + | } |
|
| 70 | + | LoadContent(state, onRefresh = vm::refresh) { issues -> |
|
| 71 | + | if (issues.isEmpty()) { |
|
| 72 | + | EmptyState( |
|
| 73 | + | Icons.Outlined.TaskAlt, |
|
| 74 | + | when (filter) { |
|
| 75 | + | IssueState.Open -> "No open issues" |
|
| 76 | + | IssueState.Closed -> "No closed issues" |
|
| 77 | + | IssueState.All -> "No issues yet" |
|
| 78 | + | }, |
|
| 79 | + | "Issues for ${vm.owner}/${vm.name} show here.", |
|
| 80 | + | ) |
|
| 81 | + | } else { |
|
| 82 | + | LazyColumn(Modifier.fillMaxSize()) { |
|
| 83 | + | items(issues, key = { it.number }) { issue -> |
|
| 84 | + | IssueRow(issue, onClick = { if (issue.url.isNotBlank()) uri.openUri(issue.url) }) |
|
| 85 | + | HorizontalDivider() |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | } |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | @Composable |
|
| 95 | + | fun IssueRow(issue: Issue, onClick: () -> Unit) { |
|
| 96 | + | val status = LocalStatusColors.current |
|
| 97 | + | ListItem( |
|
| 98 | + | modifier = Modifier.clickable(onClick = onClick), |
|
| 99 | + | leadingContent = { |
|
| 100 | + | if (issue.isOpen) { |
|
| 101 | + | Icon(Icons.Outlined.RadioButtonUnchecked, contentDescription = "Open", tint = status.ok) |
|
| 102 | + | } else { |
|
| 103 | + | Icon(Icons.Outlined.CheckCircle, contentDescription = "Closed", tint = status.warn) |
|
| 104 | + | } |
|
| 105 | + | }, |
|
| 106 | + | overlineContent = if (issue.pinned) { |
|
| 107 | + | { |
|
| 108 | + | Row(verticalAlignment = Alignment.CenterVertically) { |
|
| 109 | + | Icon(Icons.Outlined.PushPin, contentDescription = null, modifier = Modifier.padding(end = 4.dp)) |
|
| 110 | + | Text("Pinned") |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | } else { |
|
| 114 | + | null |
|
| 115 | + | }, |
|
| 116 | + | headlineContent = { Text(issue.title, maxLines = 2, overflow = TextOverflow.Ellipsis) }, |
|
| 117 | + | supportingContent = { |
|
| 118 | + | Column { |
|
| 119 | + | val verb = if (issue.isOpen) "opened" else "closed" |
|
| 120 | + | val at = if (issue.isOpen) issue.createdMs else (issue.closedMs ?: issue.updatedMs) |
|
| 121 | + | Text("#${issue.number} $verb ${relativeTime(at)} by ${issue.author}", maxLines = 1, overflow = TextOverflow.Ellipsis) |
|
| 122 | + | if (issue.labels.isNotEmpty()) { |
|
| 123 | + | FlowRow(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { |
|
| 124 | + | issue.labels.forEach { LabelChip(it) } |
|
| 125 | + | } |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | }, |
|
| 129 | + | trailingContent = if (issue.comments > 0) { |
|
| 130 | + | { |
|
| 131 | + | Row(verticalAlignment = Alignment.CenterVertically) { |
|
| 132 | + | Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Comments", modifier = Modifier.padding(end = 4.dp)) |
|
| 133 | + | Text(issue.comments.toString()) |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | } else { |
|
| 137 | + | null |
|
| 138 | + | }, |
|
| 139 | + | ) |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | @Composable |
|
| 143 | + | private fun LabelChip(label: Label) { |
|
| 144 | + | val colour = parseColour(label.colour) ?: MaterialTheme.colorScheme.secondaryContainer |
|
| 145 | + | val ink = if (colour.luminance() > 0.5f) Color.Black else Color.White |
|
| 146 | + | Text( |
|
| 147 | + | label.name, |
|
| 148 | + | color = ink, |
|
| 149 | + | fontSize = 12.sp, |
|
| 150 | + | modifier = Modifier |
|
| 151 | + | .clip(RoundedCornerShape(50)) |
|
| 152 | + | .background(colour) |
|
| 153 | + | .padding(horizontal = 8.dp, vertical = 2.dp), |
|
| 154 | + | ) |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | /** `#rrggbb` (or `rrggbb`) to a colour; null for anything else. */ |
|
| 158 | + | internal fun parseColour(s: String): Color? { |
|
| 159 | + | val hex = s.trim().removePrefix("#") |
|
| 160 | + | if (hex.length != 6) return null |
|
| 161 | + | val v = hex.toLongOrNull(16) ?: return null |
|
| 162 | + | return Color(0xFF000000 or v) |
|
| 163 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/issues/IssuesViewModel.kt +50 −0
| 1 | + | package io.oxygenhub.android.ui.issues |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import io.oxygenhub.android.api.HubApi |
|
| 5 | + | import io.oxygenhub.android.api.Issue |
|
| 6 | + | import io.oxygenhub.android.api.IssueState |
|
| 7 | + | import io.oxygenhub.android.ui.common.Load |
|
| 8 | + | import io.oxygenhub.android.ui.common.load |
|
| 9 | + | import kotlinx.coroutines.Job |
|
| 10 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 11 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 12 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 13 | + | ||
| 14 | + | /** A repository's issues, open, closed or all; pinned ones first. */ |
|
| 15 | + | class IssuesViewModel( |
|
| 16 | + | private val api: HubApi, |
|
| 17 | + | val owner: String, |
|
| 18 | + | val name: String, |
|
| 19 | + | ) : ViewModel() { |
|
| 20 | + | private val _state = MutableStateFlow<Load<List<Issue>>>(Load.Loading) |
|
| 21 | + | val state: StateFlow<Load<List<Issue>>> = _state.asStateFlow() |
|
| 22 | + | ||
| 23 | + | private val _filter = MutableStateFlow(IssueState.Open) |
|
| 24 | + | val filter: StateFlow<IssueState> = _filter.asStateFlow() |
|
| 25 | + | ||
| 26 | + | private var job: Job? = null |
|
| 27 | + | ||
| 28 | + | init { |
|
| 29 | + | refresh() |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | fun setFilter(state: IssueState) { |
|
| 33 | + | if (state == _filter.value) return |
|
| 34 | + | _filter.value = state |
|
| 35 | + | // Another list: show the spinner rather than the last filter's issues. |
|
| 36 | + | _state.value = Load.Loading |
|
| 37 | + | refresh() |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | fun refresh() { |
|
| 41 | + | val state = _filter.value |
|
| 42 | + | job = load(_state, job) { order(api.issues(owner, name, state)) } |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | companion object { |
|
| 46 | + | /** Pinned first, then newest first, as the hub's issue page lists them. */ |
|
| 47 | + | fun order(issues: List<Issue>): List<Issue> = |
|
| 48 | + | issues.sortedWith(compareByDescending<Issue> { it.pinned }.thenByDescending { it.number }) |
|
| 49 | + | } |
|
| 50 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +149 −0
| 1 | + | package io.oxygenhub.android.ui.nav |
|
| 2 | + | ||
| 3 | + | import androidx.compose.material.icons.Icons |
|
| 4 | + | import androidx.compose.material.icons.filled.AccountCircle |
|
| 5 | + | import androidx.compose.material.icons.filled.Book |
|
| 6 | + | import androidx.compose.material.icons.filled.Dashboard |
|
| 7 | + | import androidx.compose.material.icons.filled.Inbox |
|
| 8 | + | import androidx.compose.material.icons.outlined.AccountCircle |
|
| 9 | + | import androidx.compose.material.icons.outlined.Book |
|
| 10 | + | import androidx.compose.material.icons.outlined.Dashboard |
|
| 11 | + | import androidx.compose.material.icons.outlined.Inbox |
|
| 12 | + | import androidx.compose.material3.Badge |
|
| 13 | + | import androidx.compose.material3.BadgedBox |
|
| 14 | + | import androidx.compose.material3.Icon |
|
| 15 | + | import androidx.compose.material3.Text |
|
| 16 | + | import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold |
|
| 17 | + | import androidx.compose.runtime.Composable |
|
| 18 | + | import androidx.compose.runtime.getValue |
|
| 19 | + | import androidx.compose.ui.graphics.vector.ImageVector |
|
| 20 | + | import androidx.lifecycle.ViewModel |
|
| 21 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 22 | + | import androidx.lifecycle.viewmodel.compose.viewModel |
|
| 23 | + | import androidx.lifecycle.viewmodel.initializer |
|
| 24 | + | import androidx.lifecycle.viewmodel.viewModelFactory |
|
| 25 | + | import androidx.navigation.NavDestination.Companion.hasRoute |
|
| 26 | + | import androidx.navigation.NavDestination.Companion.hierarchy |
|
| 27 | + | import androidx.navigation.NavGraph.Companion.findStartDestination |
|
| 28 | + | import androidx.navigation.NavHostController |
|
| 29 | + | import androidx.navigation.compose.NavHost |
|
| 30 | + | import androidx.navigation.compose.composable |
|
| 31 | + | import androidx.navigation.compose.currentBackStackEntryAsState |
|
| 32 | + | import androidx.navigation.compose.rememberNavController |
|
| 33 | + | import androidx.navigation.toRoute |
|
| 34 | + | import io.oxygenhub.android.api.HubApi |
|
| 35 | + | import io.oxygenhub.android.auth.Session |
|
| 36 | + | import io.oxygenhub.android.ui.account.AccountScreen |
|
| 37 | + | import io.oxygenhub.android.ui.dashboard.DashboardScreen |
|
| 38 | + | import io.oxygenhub.android.ui.dashboard.DashboardViewModel |
|
| 39 | + | import io.oxygenhub.android.ui.inbox.InboxScreen |
|
| 40 | + | import io.oxygenhub.android.ui.inbox.InboxViewModel |
|
| 41 | + | import io.oxygenhub.android.ui.issues.IssuesScreen |
|
| 42 | + | import io.oxygenhub.android.ui.issues.IssuesViewModel |
|
| 43 | + | import io.oxygenhub.android.ui.repos.RepositoriesScreen |
|
| 44 | + | import io.oxygenhub.android.ui.repos.RepositoriesViewModel |
|
| 45 | + | import kotlinx.serialization.Serializable |
|
| 46 | + | import kotlin.reflect.KClass |
|
| 47 | + | ||
| 48 | + | // The app's routes. Top-level ones are in the navigation bar (or rail, or |
|
| 49 | + | // drawer, as the window allows); the rest are pushed over them. Browse |
|
| 50 | + | // (oxygenhub-android#3) adds a repository's overview, code and history |
|
| 51 | + | // under the repository's route. |
|
| 52 | + | ||
| 53 | + | @Serializable data object DashboardRoute |
|
| 54 | + | ||
| 55 | + | @Serializable data object RepositoriesRoute |
|
| 56 | + | ||
| 57 | + | @Serializable data object InboxRoute |
|
| 58 | + | ||
| 59 | + | @Serializable data object AccountRoute |
|
| 60 | + | ||
| 61 | + | /** A repository's issues, opened from outside the repositories list (the dashboard). */ |
|
| 62 | + | @Serializable data class IssuesRoute(val owner: String, val name: String) |
|
| 63 | + | ||
| 64 | + | enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) { |
|
| 65 | + | Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class), |
|
| 66 | + | Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class), |
|
| 67 | + | Inbox("Inbox", Icons.Outlined.Inbox, Icons.Filled.Inbox, InboxRoute, InboxRoute::class), |
|
| 68 | + | Account("Account", Icons.Outlined.AccountCircle, Icons.Filled.AccountCircle, AccountRoute, AccountRoute::class), |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | /** A view model made by [make], kept by the screen's back stack entry (or [key] within it). */ |
|
| 72 | + | @Composable |
|
| 73 | + | inline fun <reified VM : ViewModel> rememberViewModel(key: String? = null, crossinline make: () -> VM): VM = |
|
| 74 | + | viewModel(key = key, factory = viewModelFactory { initializer { make() } }) |
|
| 75 | + | ||
| 76 | + | /** The signed-in app: navigation around the dashboard, repositories, inbox and account. */ |
|
| 77 | + | @Composable |
|
| 78 | + | fun SignedInApp( |
|
| 79 | + | session: Session, |
|
| 80 | + | api: HubApi, |
|
| 81 | + | nativeCore: () -> String, |
|
| 82 | + | onSignOut: suspend () -> Unit, |
|
| 83 | + | ) { |
|
| 84 | + | val nav = rememberNavController() |
|
| 85 | + | // One inbox for the badge, the dashboard's count and the inbox screen. |
|
| 86 | + | val inbox = rememberViewModel(key = "inbox:${session.hub}:${session.username}") { InboxViewModel(api) } |
|
| 87 | + | val inboxState by inbox.state.collectAsStateWithLifecycle() |
|
| 88 | + | val unread = inboxState.valueOrNull?.unread ?: 0 |
|
| 89 | + | val entry by nav.currentBackStackEntryAsState() |
|
| 90 | + | val destination = entry?.destination |
|
| 91 | + | ||
| 92 | + | NavigationSuiteScaffold( |
|
| 93 | + | navigationSuiteItems = { |
|
| 94 | + | TopLevel.entries.forEach { top -> |
|
| 95 | + | val selected = destination?.hierarchy?.any { it.hasRoute(top.routeClass) } == true |
|
| 96 | + | item( |
|
| 97 | + | selected = selected, |
|
| 98 | + | onClick = { nav.goTo(top) }, |
|
| 99 | + | icon = { |
|
| 100 | + | val icon = if (selected) top.selectedIcon else top.icon |
|
| 101 | + | if (top == TopLevel.Inbox && unread > 0) { |
|
| 102 | + | BadgedBox(badge = { Badge { Text(if (unread > 99) "99+" else unread.toString()) } }) { |
|
| 103 | + | Icon(icon, contentDescription = "${top.label}, $unread unread") |
|
| 104 | + | } |
|
| 105 | + | } else { |
|
| 106 | + | Icon(icon, contentDescription = top.label) |
|
| 107 | + | } |
|
| 108 | + | }, |
|
| 109 | + | label = { Text(top.label) }, |
|
| 110 | + | ) |
|
| 111 | + | } |
|
| 112 | + | }, |
|
| 113 | + | ) { |
|
| 114 | + | NavHost(nav, startDestination = DashboardRoute) { |
|
| 115 | + | composable<DashboardRoute> { |
|
| 116 | + | DashboardScreen( |
|
| 117 | + | vm = rememberViewModel { DashboardViewModel(api, session) }, |
|
| 118 | + | hub = session.hub, |
|
| 119 | + | inbox = inboxState, |
|
| 120 | + | onRefreshInbox = inbox::refresh, |
|
| 121 | + | onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) }, |
|
| 122 | + | onAllRepositories = { nav.goTo(TopLevel.Repositories) }, |
|
| 123 | + | onInbox = { nav.goTo(TopLevel.Inbox) }, |
|
| 124 | + | ) |
|
| 125 | + | } |
|
| 126 | + | composable<RepositoriesRoute> { |
|
| 127 | + | RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }) { slug, onBack -> |
|
| 128 | + | val (owner, name) = slug.split('/', limit = 2) |
|
| 129 | + | IssuesScreen(rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) }, onBack) |
|
| 130 | + | } |
|
| 131 | + | } |
|
| 132 | + | composable<IssuesRoute> { e -> |
|
| 133 | + | val r = e.toRoute<IssuesRoute>() |
|
| 134 | + | IssuesScreen(rememberViewModel { IssuesViewModel(api, r.owner, r.name) }, onBack = { nav.popBackStack() }) |
|
| 135 | + | } |
|
| 136 | + | composable<InboxRoute> { InboxScreen(inbox) } |
|
| 137 | + | composable<AccountRoute> { AccountScreen(session, nativeCore, onSignOut) } |
|
| 138 | + | } |
|
| 139 | + | } |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | /** To a top-level destination, keeping one copy of each and its state, as the bar's own taps do. */ |
|
| 143 | + | private fun NavHostController.goTo(top: TopLevel) { |
|
| 144 | + | navigate(top.route) { |
|
| 145 | + | popUpTo(graph.findStartDestination().id) { saveState = true } |
|
| 146 | + | launchSingleTop = true |
|
| 147 | + | restoreState = true |
|
| 148 | + | } |
|
| 149 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/repos/RepositoriesScreen.kt +125 −0
| 1 | + | package io.oxygenhub.android.ui.repos |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 4 | + | import androidx.compose.foundation.layout.Column |
|
| 5 | + | import androidx.compose.foundation.layout.Row |
|
| 6 | + | import androidx.compose.foundation.layout.fillMaxSize |
|
| 7 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 8 | + | import androidx.compose.foundation.layout.padding |
|
| 9 | + | import androidx.compose.foundation.lazy.LazyColumn |
|
| 10 | + | import androidx.compose.foundation.lazy.items |
|
| 11 | + | import androidx.compose.material.icons.Icons |
|
| 12 | + | import androidx.compose.material.icons.outlined.Book |
|
| 13 | + | import androidx.compose.material.icons.outlined.Search |
|
| 14 | + | import androidx.compose.material3.FilterChip |
|
| 15 | + | import androidx.compose.material3.HorizontalDivider |
|
| 16 | + | import androidx.compose.material3.Icon |
|
| 17 | + | import androidx.compose.material3.OutlinedTextField |
|
| 18 | + | import androidx.compose.material3.Text |
|
| 19 | + | import androidx.compose.material3.TextButton |
|
| 20 | + | import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi |
|
| 21 | + | import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo |
|
| 22 | + | import androidx.compose.material3.adaptive.layout.AnimatedPane |
|
| 23 | + | import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole |
|
| 24 | + | import androidx.compose.material3.adaptive.layout.PaneAdaptedValue |
|
| 25 | + | import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective |
|
| 26 | + | import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold |
|
| 27 | + | import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator |
|
| 28 | + | import androidx.compose.runtime.Composable |
|
| 29 | + | import androidx.compose.runtime.getValue |
|
| 30 | + | import androidx.compose.runtime.rememberCoroutineScope |
|
| 31 | + | import androidx.compose.ui.Modifier |
|
| 32 | + | import androidx.compose.ui.unit.dp |
|
| 33 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 34 | + | import androidx.window.core.layout.WindowSizeClass |
|
| 35 | + | import io.oxygenhub.android.api.Repository |
|
| 36 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 37 | + | import io.oxygenhub.android.ui.common.EmptyState |
|
| 38 | + | import io.oxygenhub.android.ui.common.LoadContent |
|
| 39 | + | import kotlinx.coroutines.launch |
|
| 40 | + | ||
| 41 | + | /** |
|
| 42 | + | * Repositories as a list and detail: one pane on a phone (the detail over |
|
| 43 | + | * the list, back returning to it), both side by side from a medium width |
|
| 44 | + | * up, so a foldable opened shows a repository's issues beside the list. |
|
| 45 | + | * The scaffold keeps panes off a vertical hinge. |
|
| 46 | + | */ |
|
| 47 | + | @OptIn(ExperimentalMaterial3AdaptiveApi::class) |
|
| 48 | + | @Composable |
|
| 49 | + | fun RepositoriesScreen( |
|
| 50 | + | vm: RepositoriesViewModel, |
|
| 51 | + | detail: @Composable (slug: String, onBack: (() -> Unit)?) -> Unit, |
|
| 52 | + | ) { |
|
| 53 | + | val info = currentWindowAdaptiveInfo() |
|
| 54 | + | val base = calculatePaneScaffoldDirective(info) |
|
| 55 | + | val twoPanes = info.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) |
|
| 56 | + | val directive = if (twoPanes && base.maxHorizontalPartitions < 2) base.copy(maxHorizontalPartitions = 2) else base |
|
| 57 | + | val navigator = rememberListDetailPaneScaffoldNavigator<String>(scaffoldDirective = directive) |
|
| 58 | + | val scope = rememberCoroutineScope() |
|
| 59 | + | val selected = navigator.currentDestination?.contentKey |
|
| 60 | + | val detailShownAlone = navigator.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Hidden |
|
| 61 | + | ||
| 62 | + | NavigableListDetailPaneScaffold( |
|
| 63 | + | navigator = navigator, |
|
| 64 | + | listPane = { |
|
| 65 | + | AnimatedPane { |
|
| 66 | + | RepositoryList(vm, selected = selected, onOpen = { repo -> |
|
| 67 | + | scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, repo.slug) } |
|
| 68 | + | }) |
|
| 69 | + | } |
|
| 70 | + | }, |
|
| 71 | + | detailPane = { |
|
| 72 | + | AnimatedPane { |
|
| 73 | + | if (selected == null) { |
|
| 74 | + | EmptyState(Icons.Outlined.Book, "Choose a repository", "Its issues show here.") |
|
| 75 | + | } else { |
|
| 76 | + | detail(selected, if (detailShownAlone) ({ scope.launch { navigator.navigateBack() } }) else null) |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | }, |
|
| 80 | + | ) |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | @Composable |
|
| 84 | + | private fun RepositoryList(vm: RepositoriesViewModel, selected: String?, onOpen: (Repository) -> Unit) { |
|
| 85 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 86 | + | val query by vm.query.collectAsStateWithLifecycle() |
|
| 87 | + | AppScreen(title = "Repositories") { |
|
| 88 | + | Column(Modifier.fillMaxSize()) { |
|
| 89 | + | OutlinedTextField( |
|
| 90 | + | value = query.text, |
|
| 91 | + | onValueChange = vm::setText, |
|
| 92 | + | placeholder = { Text("Filter") }, |
|
| 93 | + | leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, |
|
| 94 | + | singleLine = true, |
|
| 95 | + | modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), |
|
| 96 | + | ) |
|
| 97 | + | Row(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 98 | + | FilterChip(query.filter == RepoFilter.Yours, onClick = { vm.setFilter(RepoFilter.Yours) }, label = { Text("Yours") }) |
|
| 99 | + | FilterChip(query.filter == RepoFilter.All, onClick = { vm.setFilter(RepoFilter.All) }, label = { Text("All you can see") }) |
|
| 100 | + | } |
|
| 101 | + | LoadContent(state, onRefresh = vm::refresh) { all -> |
|
| 102 | + | val shown = vm.shown(all, query) |
|
| 103 | + | if (shown.isEmpty()) { |
|
| 104 | + | EmptyState( |
|
| 105 | + | Icons.Outlined.Book, |
|
| 106 | + | if (query.text.isBlank()) "No repositories" else "Nothing matches “${query.text.trim()}”", |
|
| 107 | + | if (query.filter == RepoFilter.Yours) "You own none here yet." else "The hub has none you can see.", |
|
| 108 | + | action = if (query.filter == RepoFilter.Yours) { |
|
| 109 | + | { TextButton(onClick = { vm.setFilter(RepoFilter.All) }) { Text("Show all you can see") } } |
|
| 110 | + | } else { |
|
| 111 | + | null |
|
| 112 | + | }, |
|
| 113 | + | ) |
|
| 114 | + | } else { |
|
| 115 | + | LazyColumn(Modifier.fillMaxSize()) { |
|
| 116 | + | items(shown, key = { it.slug }) { repo -> |
|
| 117 | + | RepositoryRow(repo, onClick = { onOpen(repo) }, selected = repo.slug == selected) |
|
| 118 | + | HorizontalDivider() |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/repos/RepositoriesViewModel.kt +57 −0
| 1 | + | package io.oxygenhub.android.ui.repos |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import io.oxygenhub.android.api.HubApi |
|
| 5 | + | import io.oxygenhub.android.api.Repository |
|
| 6 | + | import io.oxygenhub.android.ui.common.Load |
|
| 7 | + | import io.oxygenhub.android.ui.common.load |
|
| 8 | + | import kotlinx.coroutines.Job |
|
| 9 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 10 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 11 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 12 | + | import kotlinx.coroutines.flow.update |
|
| 13 | + | ||
| 14 | + | /** Whose repositories to list. */ |
|
| 15 | + | enum class RepoFilter { Yours, All } |
|
| 16 | + | ||
| 17 | + | data class RepoQuery(val filter: RepoFilter = RepoFilter.Yours, val text: String = "") |
|
| 18 | + | ||
| 19 | + | class RepositoriesViewModel( |
|
| 20 | + | private val api: HubApi, |
|
| 21 | + | private val username: String, |
|
| 22 | + | ) : ViewModel() { |
|
| 23 | + | private val _state = MutableStateFlow<Load<List<Repository>>>(Load.Loading) |
|
| 24 | + | ||
| 25 | + | /** Every repository the hub lets the user read, newest-updated first. */ |
|
| 26 | + | val state: StateFlow<Load<List<Repository>>> = _state.asStateFlow() |
|
| 27 | + | ||
| 28 | + | private val _query = MutableStateFlow(RepoQuery()) |
|
| 29 | + | val query: StateFlow<RepoQuery> = _query.asStateFlow() |
|
| 30 | + | ||
| 31 | + | private var job: Job? = null |
|
| 32 | + | ||
| 33 | + | init { |
|
| 34 | + | refresh() |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | fun refresh() { |
|
| 38 | + | job = load(_state, job) { api.repositories().sortedByDescending { it.updatedMs } } |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | fun setFilter(filter: RepoFilter) = _query.update { it.copy(filter = filter) } |
|
| 42 | + | ||
| 43 | + | fun setText(text: String) = _query.update { it.copy(text = text) } |
|
| 44 | + | ||
| 45 | + | /** What the list shows for [query]. */ |
|
| 46 | + | fun shown(all: List<Repository>, query: RepoQuery): List<Repository> = filter(all, query, username) |
|
| 47 | + | ||
| 48 | + | companion object { |
|
| 49 | + | fun filter(all: List<Repository>, query: RepoQuery, username: String): List<Repository> { |
|
| 50 | + | val text = query.text.trim() |
|
| 51 | + | return all.filter { r -> |
|
| 52 | + | (query.filter == RepoFilter.All || r.owner == username) && |
|
| 53 | + | (text.isEmpty() || r.slug.contains(text, ignoreCase = true) || r.description.contains(text, ignoreCase = true)) |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryRow.kt +54 −0
| 1 | + | package io.oxygenhub.android.ui.repos |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.clickable |
|
| 4 | + | import androidx.compose.foundation.layout.Row |
|
| 5 | + | import androidx.compose.foundation.layout.Spacer |
|
| 6 | + | import androidx.compose.foundation.layout.size |
|
| 7 | + | import androidx.compose.foundation.layout.width |
|
| 8 | + | import androidx.compose.material.icons.Icons |
|
| 9 | + | import androidx.compose.material.icons.outlined.Book |
|
| 10 | + | import androidx.compose.material.icons.outlined.Domain |
|
| 11 | + | import androidx.compose.material.icons.outlined.Lock |
|
| 12 | + | import androidx.compose.material3.Icon |
|
| 13 | + | import androidx.compose.material3.ListItem |
|
| 14 | + | import androidx.compose.material3.ListItemDefaults |
|
| 15 | + | import androidx.compose.material3.MaterialTheme |
|
| 16 | + | import androidx.compose.material3.Text |
|
| 17 | + | import androidx.compose.runtime.Composable |
|
| 18 | + | import androidx.compose.ui.Alignment |
|
| 19 | + | import androidx.compose.ui.Modifier |
|
| 20 | + | import androidx.compose.ui.text.style.TextOverflow |
|
| 21 | + | import androidx.compose.ui.unit.dp |
|
| 22 | + | import io.oxygenhub.android.api.Repository |
|
| 23 | + | import io.oxygenhub.android.api.Visibility |
|
| 24 | + | import io.oxygenhub.android.ui.common.relativeTime |
|
| 25 | + | ||
| 26 | + | @Composable |
|
| 27 | + | fun RepositoryRow(repo: Repository, onClick: () -> Unit, modifier: Modifier = Modifier, selected: Boolean = false) { |
|
| 28 | + | ListItem( |
|
| 29 | + | modifier = modifier.clickable(onClick = onClick), |
|
| 30 | + | colors = if (selected) { |
|
| 31 | + | ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer) |
|
| 32 | + | } else { |
|
| 33 | + | ListItemDefaults.colors() |
|
| 34 | + | }, |
|
| 35 | + | leadingContent = { Icon(Icons.Outlined.Book, contentDescription = null) }, |
|
| 36 | + | headlineContent = { Text(repo.slug, maxLines = 1, overflow = TextOverflow.Ellipsis) }, |
|
| 37 | + | supportingContent = if (repo.description.isNotBlank()) { |
|
| 38 | + | { Text(repo.description, maxLines = 2, overflow = TextOverflow.Ellipsis) } |
|
| 39 | + | } else { |
|
| 40 | + | null |
|
| 41 | + | }, |
|
| 42 | + | trailingContent = { |
|
| 43 | + | Row(verticalAlignment = Alignment.CenterVertically) { |
|
| 44 | + | when (repo.visibility) { |
|
| 45 | + | Visibility.Private -> Icon(Icons.Outlined.Lock, contentDescription = "Private", modifier = Modifier.size(16.dp)) |
|
| 46 | + | Visibility.Internal -> Icon(Icons.Outlined.Domain, contentDescription = "Internal", modifier = Modifier.size(16.dp)) |
|
| 47 | + | Visibility.Public -> {} |
|
| 48 | + | } |
|
| 49 | + | if (repo.visibility != Visibility.Public) Spacer(Modifier.width(6.dp)) |
|
| 50 | + | Text(relativeTime(repo.updatedMs), style = MaterialTheme.typography.labelSmall) |
|
| 51 | + | } |
|
| 52 | + | }, |
|
| 53 | + | ) |
|
| 54 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/signin/SignInScreen.kt +179 −0
| 1 | + | package io.oxygenhub.android.ui.signin |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.Image |
|
| 4 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 5 | + | import androidx.compose.foundation.layout.Box |
|
| 6 | + | import androidx.compose.foundation.layout.Column |
|
| 7 | + | import androidx.compose.foundation.layout.Spacer |
|
| 8 | + | import androidx.compose.foundation.layout.fillMaxSize |
|
| 9 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 10 | + | import androidx.compose.foundation.layout.height |
|
| 11 | + | import androidx.compose.foundation.layout.imePadding |
|
| 12 | + | import androidx.compose.foundation.layout.padding |
|
| 13 | + | import androidx.compose.foundation.layout.safeDrawingPadding |
|
| 14 | + | import androidx.compose.foundation.layout.size |
|
| 15 | + | import androidx.compose.foundation.layout.widthIn |
|
| 16 | + | import androidx.compose.foundation.rememberScrollState |
|
| 17 | + | import androidx.compose.foundation.text.KeyboardActions |
|
| 18 | + | import androidx.compose.foundation.text.KeyboardOptions |
|
| 19 | + | import androidx.compose.foundation.verticalScroll |
|
| 20 | + | import androidx.compose.material.icons.Icons |
|
| 21 | + | import androidx.compose.material.icons.automirrored.outlined.OpenInNew |
|
| 22 | + | import androidx.compose.material.icons.outlined.Dns |
|
| 23 | + | import androidx.compose.material3.Button |
|
| 24 | + | import androidx.compose.material3.Card |
|
| 25 | + | import androidx.compose.material3.CardDefaults |
|
| 26 | + | import androidx.compose.material3.CircularProgressIndicator |
|
| 27 | + | import androidx.compose.material3.Icon |
|
| 28 | + | import androidx.compose.material3.LinearProgressIndicator |
|
| 29 | + | import androidx.compose.material3.MaterialTheme |
|
| 30 | + | import androidx.compose.material3.OutlinedTextField |
|
| 31 | + | import androidx.compose.material3.Surface |
|
| 32 | + | import androidx.compose.material3.Text |
|
| 33 | + | import androidx.compose.material3.TextButton |
|
| 34 | + | import androidx.compose.runtime.Composable |
|
| 35 | + | import androidx.compose.runtime.getValue |
|
| 36 | + | import androidx.compose.ui.Alignment |
|
| 37 | + | import androidx.compose.ui.Modifier |
|
| 38 | + | import androidx.compose.ui.platform.LocalUriHandler |
|
| 39 | + | import androidx.compose.ui.res.painterResource |
|
| 40 | + | import androidx.compose.ui.semantics.contentDescription |
|
| 41 | + | import androidx.compose.ui.semantics.semantics |
|
| 42 | + | import androidx.compose.ui.text.font.FontFamily |
|
| 43 | + | import androidx.compose.ui.text.font.FontWeight |
|
| 44 | + | import androidx.compose.ui.text.input.ImeAction |
|
| 45 | + | import androidx.compose.ui.text.input.KeyboardType |
|
| 46 | + | import androidx.compose.ui.text.style.TextAlign |
|
| 47 | + | import androidx.compose.ui.unit.dp |
|
| 48 | + | import androidx.compose.ui.unit.sp |
|
| 49 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 50 | + | import io.oxygenhub.android.R |
|
| 51 | + | import io.oxygenhub.android.api.DeviceCode |
|
| 52 | + | ||
| 53 | + | @Composable |
|
| 54 | + | fun SignInScreen(vm: SignInViewModel) { |
|
| 55 | + | val hub by vm.hub.collectAsStateWithLifecycle() |
|
| 56 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 57 | + | val ended by vm.ended.collectAsStateWithLifecycle() |
|
| 58 | + | SignInContent( |
|
| 59 | + | hub = hub, |
|
| 60 | + | state = state, |
|
| 61 | + | notice = ended, |
|
| 62 | + | onHubChange = vm::setHub, |
|
| 63 | + | onSignIn = vm::signIn, |
|
| 64 | + | onCancel = vm::cancel, |
|
| 65 | + | ) |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | @Composable |
|
| 69 | + | fun SignInContent( |
|
| 70 | + | hub: String, |
|
| 71 | + | state: SignInState, |
|
| 72 | + | notice: String?, |
|
| 73 | + | onHubChange: (String) -> Unit, |
|
| 74 | + | onSignIn: () -> Unit, |
|
| 75 | + | onCancel: () -> Unit, |
|
| 76 | + | ) { |
|
| 77 | + | Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { |
|
| 78 | + | Box(Modifier.fillMaxSize().safeDrawingPadding().imePadding(), contentAlignment = Alignment.Center) { |
|
| 79 | + | Column( |
|
| 80 | + | Modifier |
|
| 81 | + | .widthIn(max = 480.dp) |
|
| 82 | + | .fillMaxWidth() |
|
| 83 | + | .verticalScroll(rememberScrollState()) |
|
| 84 | + | .padding(24.dp), |
|
| 85 | + | horizontalAlignment = Alignment.CenterHorizontally, |
|
| 86 | + | ) { |
|
| 87 | + | Image(painterResource(R.drawable.mark), contentDescription = null, modifier = Modifier.size(width = 96.dp, height = 48.dp)) |
|
| 88 | + | Spacer(Modifier.height(16.dp)) |
|
| 89 | + | Text("OxygenHub", fontSize = 36.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) |
|
| 90 | + | Spacer(Modifier.height(32.dp)) |
|
| 91 | + | when (state) { |
|
| 92 | + | is SignInState.Idle -> Start(hub, state.error ?: notice, onHubChange, onSignIn) |
|
| 93 | + | SignInState.Requesting -> Waiting("Asking $hub for a code…") |
|
| 94 | + | is SignInState.Approving -> Approve(state.code, onCancel) |
|
| 95 | + | SignInState.Finishing -> Waiting("Approved. Signing in…") |
|
| 96 | + | } |
|
| 97 | + | } |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | @Composable |
|
| 103 | + | private fun Start(hub: String, error: String?, onHubChange: (String) -> Unit, onSignIn: () -> Unit) { |
|
| 104 | + | Text( |
|
| 105 | + | "Sign in with your browser: the app shows a code, you approve it on the hub's web page, " + |
|
| 106 | + | "signed in there with your password and second factor. The app never sees either.", |
|
| 107 | + | style = MaterialTheme.typography.bodyLarge, |
|
| 108 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 109 | + | textAlign = TextAlign.Center, |
|
| 110 | + | ) |
|
| 111 | + | Spacer(Modifier.height(24.dp)) |
|
| 112 | + | OutlinedTextField( |
|
| 113 | + | value = hub, |
|
| 114 | + | onValueChange = onHubChange, |
|
| 115 | + | label = { Text("Hub") }, |
|
| 116 | + | leadingIcon = { Icon(Icons.Outlined.Dns, contentDescription = null) }, |
|
| 117 | + | singleLine = true, |
|
| 118 | + | isError = error != null, |
|
| 119 | + | supportingText = { Text(error ?: "The hub's address; oxygenhub.io unless yours is your own.") }, |
|
| 120 | + | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, autoCorrectEnabled = false), |
|
| 121 | + | keyboardActions = KeyboardActions(onGo = { onSignIn() }), |
|
| 122 | + | modifier = Modifier.fillMaxWidth(), |
|
| 123 | + | ) |
|
| 124 | + | Spacer(Modifier.height(16.dp)) |
|
| 125 | + | Button(onClick = onSignIn, enabled = hub.isNotBlank(), modifier = Modifier.fillMaxWidth().height(50.dp)) { |
|
| 126 | + | Text("Sign in", fontSize = 16.sp) |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | @Composable |
|
| 131 | + | private fun Waiting(what: String) { |
|
| 132 | + | CircularProgressIndicator() |
|
| 133 | + | Spacer(Modifier.height(16.dp)) |
|
| 134 | + | Text(what, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center) |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | @Composable |
|
| 138 | + | private fun Approve(code: DeviceCode, onCancel: () -> Unit) { |
|
| 139 | + | val uri = LocalUriHandler.current |
|
| 140 | + | Text("Approve this code on the hub", style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center) |
|
| 141 | + | Spacer(Modifier.height(16.dp)) |
|
| 142 | + | Card( |
|
| 143 | + | colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), |
|
| 144 | + | modifier = Modifier.fillMaxWidth(), |
|
| 145 | + | ) { |
|
| 146 | + | Text( |
|
| 147 | + | code.userCode, |
|
| 148 | + | fontFamily = FontFamily.Monospace, |
|
| 149 | + | fontSize = 34.sp, |
|
| 150 | + | fontWeight = FontWeight.Bold, |
|
| 151 | + | letterSpacing = 4.sp, |
|
| 152 | + | textAlign = TextAlign.Center, |
|
| 153 | + | modifier = Modifier |
|
| 154 | + | .fillMaxWidth() |
|
| 155 | + | .padding(vertical = 24.dp) |
|
| 156 | + | .semantics { contentDescription = "Code ${code.userCode.toCharArray().joinToString(" ")}" }, |
|
| 157 | + | ) |
|
| 158 | + | } |
|
| 159 | + | Spacer(Modifier.height(16.dp)) |
|
| 160 | + | Button(onClick = { uri.openUri(code.pageToOpen) }, modifier = Modifier.fillMaxWidth().height(50.dp)) { |
|
| 161 | + | Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null) |
|
| 162 | + | Spacer(Modifier.size(8.dp)) |
|
| 163 | + | Text("Open the hub to approve", fontSize = 16.sp) |
|
| 164 | + | } |
|
| 165 | + | Spacer(Modifier.height(12.dp)) |
|
| 166 | + | Text( |
|
| 167 | + | "Or on a computer, go to ${code.verificationUri} and enter the code.", |
|
| 168 | + | style = MaterialTheme.typography.bodyMedium, |
|
| 169 | + | color = MaterialTheme.colorScheme.onSurfaceVariant, |
|
| 170 | + | textAlign = TextAlign.Center, |
|
| 171 | + | ) |
|
| 172 | + | Spacer(Modifier.height(24.dp)) |
|
| 173 | + | LinearProgressIndicator(Modifier.fillMaxWidth()) |
|
| 174 | + | Spacer(Modifier.height(8.dp)) |
|
| 175 | + | Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { |
|
| 176 | + | Text("Waiting for approval…", style = MaterialTheme.typography.bodyMedium) |
|
| 177 | + | TextButton(onClick = onCancel) { Text("Cancel") } |
|
| 178 | + | } |
|
| 179 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/signin/SignInViewModel.kt +98 −0
| 1 | + | package io.oxygenhub.android.ui.signin |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import androidx.lifecycle.viewModelScope |
|
| 5 | + | import io.oxygenhub.android.api.DeviceAuth |
|
| 6 | + | import io.oxygenhub.android.api.DeviceCode |
|
| 7 | + | import io.oxygenhub.android.api.DeviceFlowException |
|
| 8 | + | import io.oxygenhub.android.api.HubException |
|
| 9 | + | import io.oxygenhub.android.api.normalizeHubUrl |
|
| 10 | + | import io.oxygenhub.android.api.userMessage |
|
| 11 | + | import io.oxygenhub.android.auth.Session |
|
| 12 | + | import io.oxygenhub.android.auth.SessionManager |
|
| 13 | + | import kotlinx.coroutines.CancellationException |
|
| 14 | + | import kotlinx.coroutines.Job |
|
| 15 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 16 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 17 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 18 | + | import kotlinx.coroutines.launch |
|
| 19 | + | ||
| 20 | + | sealed interface SignInState { |
|
| 21 | + | /** Ready to start; [error] says why the last try ended. */ |
|
| 22 | + | data class Idle(val error: String? = null) : SignInState |
|
| 23 | + | ||
| 24 | + | /** Asking the hub for a code. */ |
|
| 25 | + | data object Requesting : SignInState |
|
| 26 | + | ||
| 27 | + | /** Showing the code while the user approves it on the web. */ |
|
| 28 | + | data class Approving(val code: DeviceCode) : SignInState |
|
| 29 | + | ||
| 30 | + | /** Approved: checking the token and saving it. */ |
|
| 31 | + | data object Finishing : SignInState |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | class SignInViewModel( |
|
| 35 | + | private val sessions: SessionManager, |
|
| 36 | + | private val authFor: (hub: String) -> DeviceAuth, |
|
| 37 | + | private val clientName: String, |
|
| 38 | + | defaultHub: String, |
|
| 39 | + | ) : ViewModel() { |
|
| 40 | + | private val _hub = MutableStateFlow(sessions.lastHub ?: defaultHub) |
|
| 41 | + | val hub: StateFlow<String> = _hub.asStateFlow() |
|
| 42 | + | ||
| 43 | + | private val _state = MutableStateFlow<SignInState>(SignInState.Idle()) |
|
| 44 | + | val state: StateFlow<SignInState> = _state.asStateFlow() |
|
| 45 | + | ||
| 46 | + | /** Why the last session ended, if the hub ended it. */ |
|
| 47 | + | val ended: StateFlow<String?> = sessions.ended |
|
| 48 | + | ||
| 49 | + | private var job: Job? = null |
|
| 50 | + | ||
| 51 | + | fun setHub(value: String) { |
|
| 52 | + | _hub.value = value |
|
| 53 | + | if (_state.value is SignInState.Idle) _state.value = SignInState.Idle() |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | fun signIn() { |
|
| 57 | + | if (job?.isActive == true) return |
|
| 58 | + | val hub = normalizeHubUrl(_hub.value) |
|
| 59 | + | if (hub == null) { |
|
| 60 | + | _state.value = SignInState.Idle("Enter the hub's HTTPS address, such as https://oxygenhub.io.") |
|
| 61 | + | return |
|
| 62 | + | } |
|
| 63 | + | _hub.value = hub |
|
| 64 | + | sessions.rememberHub(hub) |
|
| 65 | + | sessions.dismissEnded() |
|
| 66 | + | job = viewModelScope.launch { |
|
| 67 | + | _state.value = SignInState.Requesting |
|
| 68 | + | try { |
|
| 69 | + | val auth = authFor(hub) |
|
| 70 | + | val code = auth.requestCode(clientName) |
|
| 71 | + | _state.value = SignInState.Approving(code) |
|
| 72 | + | val grant = auth.awaitToken(code) |
|
| 73 | + | _state.value = SignInState.Finishing |
|
| 74 | + | val username = try { |
|
| 75 | + | auth.whoAmI(grant.accessToken).username |
|
| 76 | + | } catch (e: HubException.Http) { |
|
| 77 | + | // Approved without user:read: the token's answer names the account too. |
|
| 78 | + | if (e.status == 403 && grant.username.isNotBlank()) grant.username else throw e |
|
| 79 | + | } |
|
| 80 | + | sessions.signedIn(Session(hub, username, grant.accessToken, grant.scopes)) |
|
| 81 | + | _state.value = SignInState.Idle() |
|
| 82 | + | } catch (e: CancellationException) { |
|
| 83 | + | throw e |
|
| 84 | + | } catch (e: DeviceFlowException) { |
|
| 85 | + | _state.value = SignInState.Idle(e.message) |
|
| 86 | + | } catch (e: Exception) { |
|
| 87 | + | _state.value = SignInState.Idle(e.userMessage()) |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | /** Stops waiting. The code is left to expire on the hub (it has no call to withdraw one). */ |
|
| 93 | + | fun cancel() { |
|
| 94 | + | job?.cancel() |
|
| 95 | + | job = null |
|
| 96 | + | _state.value = SignInState.Idle() |
|
| 97 | + | } |
|
| 98 | + | } |
added app/src/test/java/io/oxygenhub/android/MainDispatcherRule.kt +17 −0
| 1 | + | package io.oxygenhub.android |
|
| 2 | + | ||
| 3 | + | import kotlinx.coroutines.Dispatchers |
|
| 4 | + | import kotlinx.coroutines.ExperimentalCoroutinesApi |
|
| 5 | + | import kotlinx.coroutines.test.TestDispatcher |
|
| 6 | + | import kotlinx.coroutines.test.UnconfinedTestDispatcher |
|
| 7 | + | import kotlinx.coroutines.test.resetMain |
|
| 8 | + | import kotlinx.coroutines.test.setMain |
|
| 9 | + | import org.junit.rules.TestWatcher |
|
| 10 | + | import org.junit.runner.Description |
|
| 11 | + | ||
| 12 | + | /** viewModelScope runs on Main: a test dispatcher stands in for it. */ |
|
| 13 | + | @OptIn(ExperimentalCoroutinesApi::class) |
|
| 14 | + | class MainDispatcherRule(val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() { |
|
| 15 | + | override fun starting(description: Description) = Dispatchers.setMain(dispatcher) |
|
| 16 | + | override fun finished(description: Description) = Dispatchers.resetMain() |
|
| 17 | + | } |
added app/src/test/java/io/oxygenhub/android/ui/DashboardViewModelTest.kt +71 −0
| 1 | + | package io.oxygenhub.android.ui |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.FakeHubApi |
|
| 4 | + | import io.oxygenhub.android.Fixtures |
|
| 5 | + | import io.oxygenhub.android.MainDispatcherRule |
|
| 6 | + | import io.oxygenhub.android.api.HubException |
|
| 7 | + | import io.oxygenhub.android.auth.Session |
|
| 8 | + | import io.oxygenhub.android.ui.common.Load |
|
| 9 | + | import io.oxygenhub.android.ui.dashboard.Dashboard |
|
| 10 | + | import io.oxygenhub.android.ui.dashboard.DashboardViewModel |
|
| 11 | + | import org.junit.Assert.assertEquals |
|
| 12 | + | import org.junit.Assert.assertFalse |
|
| 13 | + | import org.junit.Assert.assertNull |
|
| 14 | + | import org.junit.Assert.assertTrue |
|
| 15 | + | import org.junit.Rule |
|
| 16 | + | import org.junit.Test |
|
| 17 | + | ||
| 18 | + | class DashboardViewModelTest { |
|
| 19 | + | @get:Rule val main = MainDispatcherRule() |
|
| 20 | + | ||
| 21 | + | private val api = FakeHubApi() |
|
| 22 | + | private val session = Session("https://oxygenhub.io", "andymccall", "t") |
|
| 23 | + | ||
| 24 | + | private fun ready(vm: DashboardViewModel): Dashboard = (vm.state.value as Load.Ready).value |
|
| 25 | + | ||
| 26 | + | @Test |
|
| 27 | + | fun greetsAndListsTheUsersRecentRepositories() { |
|
| 28 | + | val d = ready(DashboardViewModel(api, session) { 9 }) |
|
| 29 | + | assertEquals("Good morning, Andy McCall", d.greeting) |
|
| 30 | + | assertTrue(d.recentAreYours) |
|
| 31 | + | assertEquals(listOf("andymccall/oxygenflow", "andymccall/secret-plans"), d.recent.map { it.slug }) |
|
| 32 | + | assertEquals(2, d.repositoryCount) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | @Test |
|
| 36 | + | fun withNoRepositoriesOfTheirOwnShowsTheHubs() { |
|
| 37 | + | val d = ready(DashboardViewModel(api, session.copy(username = "newcomer")) { 14 }.also { |
|
| 38 | + | api.user = { Fixtures.user.copy(username = "newcomer", displayName = "") } |
|
| 39 | + | it.refresh() |
|
| 40 | + | }) |
|
| 41 | + | assertEquals("Good afternoon, newcomer", d.greeting) |
|
| 42 | + | assertFalse(d.recentAreYours) |
|
| 43 | + | assertEquals("doomsdayone/tetris", d.recent.first().slug) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | @Test |
|
| 47 | + | fun withoutUserReadGreetsByTheSessionsName() { |
|
| 48 | + | api.user = { throw HubException.Http(403, "this token lacks the user:read scope") } |
|
| 49 | + | val d = ready(DashboardViewModel(api, session) { 22 }) |
|
| 50 | + | assertEquals("Good evening, andymccall", d.greeting) |
|
| 51 | + | assertNull(d.user) |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | @Test |
|
| 55 | + | fun aFailureAndARetry() { |
|
| 56 | + | api.repositories = { throw HubException.Http(503, "down") } |
|
| 57 | + | val vm = DashboardViewModel(api, session) { 9 } |
|
| 58 | + | assertTrue(vm.state.value is Load.Failed) |
|
| 59 | + | api.repositories = { Fixtures.repos } |
|
| 60 | + | vm.refresh() |
|
| 61 | + | assertTrue(vm.state.value is Load.Ready) |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | @Test |
|
| 65 | + | fun greetings() { |
|
| 66 | + | assertEquals("Good evening, a", DashboardViewModel.greeting(4, "a")) |
|
| 67 | + | assertEquals("Good morning, a", DashboardViewModel.greeting(5, "a")) |
|
| 68 | + | assertEquals("Good afternoon, a", DashboardViewModel.greeting(12, "a")) |
|
| 69 | + | assertEquals("Good evening, a", DashboardViewModel.greeting(18, "a")) |
|
| 70 | + | } |
|
| 71 | + | } |
added app/src/test/java/io/oxygenhub/android/ui/ListViewModelsTest.kt +106 −0
| 1 | + | package io.oxygenhub.android.ui |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.FakeHubApi |
|
| 4 | + | import io.oxygenhub.android.Fixtures |
|
| 5 | + | import io.oxygenhub.android.MainDispatcherRule |
|
| 6 | + | import io.oxygenhub.android.api.HubException |
|
| 7 | + | import io.oxygenhub.android.api.IssueState |
|
| 8 | + | import io.oxygenhub.android.ui.common.Load |
|
| 9 | + | import io.oxygenhub.android.ui.inbox.InboxViewModel |
|
| 10 | + | import io.oxygenhub.android.ui.issues.IssuesViewModel |
|
| 11 | + | import io.oxygenhub.android.ui.repos.RepoFilter |
|
| 12 | + | import io.oxygenhub.android.ui.repos.RepositoriesViewModel |
|
| 13 | + | import org.junit.Assert.assertEquals |
|
| 14 | + | import org.junit.Assert.assertNotNull |
|
| 15 | + | import org.junit.Assert.assertNull |
|
| 16 | + | import org.junit.Assert.assertTrue |
|
| 17 | + | import org.junit.Rule |
|
| 18 | + | import org.junit.Test |
|
| 19 | + | ||
| 20 | + | class ListViewModelsTest { |
|
| 21 | + | @get:Rule val main = MainDispatcherRule() |
|
| 22 | + | ||
| 23 | + | private val api = FakeHubApi() |
|
| 24 | + | ||
| 25 | + | @Test |
|
| 26 | + | fun repositoriesNewestFirstFilteredByOwnerAndText() { |
|
| 27 | + | val vm = RepositoriesViewModel(api, "andymccall") |
|
| 28 | + | val all = (vm.state.value as Load.Ready).value |
|
| 29 | + | assertEquals(listOf("doomsdayone/tetris", "andymccall/oxygenflow", "andymccall/secret-plans"), all.map { it.slug }) |
|
| 30 | + | assertEquals(listOf("andymccall/oxygenflow", "andymccall/secret-plans"), vm.shown(all, vm.query.value).map { it.slug }) |
|
| 31 | + | vm.setFilter(RepoFilter.All) |
|
| 32 | + | assertEquals(3, vm.shown(all, vm.query.value).size) |
|
| 33 | + | vm.setText("BLOCKS") |
|
| 34 | + | assertEquals(listOf("doomsdayone/tetris"), vm.shown(all, vm.query.value).map { it.slug }) |
|
| 35 | + | vm.setText("andymccall/secret") |
|
| 36 | + | assertEquals(listOf("andymccall/secret-plans"), vm.shown(all, vm.query.value).map { it.slug }) |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | @Test |
|
| 40 | + | fun aFailedRepositoryList() { |
|
| 41 | + | api.repositories = { throw HubException.Http(500, "boom") } |
|
| 42 | + | val vm = RepositoriesViewModel(api, "andymccall") |
|
| 43 | + | assertEquals(Load.Failed("The hub had a problem (500). Try again in a moment."), vm.state.value) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | @Test |
|
| 47 | + | fun issuesPinnedFirstThenNewest() { |
|
| 48 | + | val vm = IssuesViewModel(api, "andymccall", "oxygenhub-android") |
|
| 49 | + | assertEquals(listOf(2L, 3L), (vm.state.value as Load.Ready).value.map { it.number }) |
|
| 50 | + | vm.setFilter(IssueState.Closed) |
|
| 51 | + | assertEquals(listOf(1L), (vm.state.value as Load.Ready).value.map { it.number }) |
|
| 52 | + | vm.setFilter(IssueState.All) |
|
| 53 | + | assertEquals(listOf(2L, 3L, 1L), (vm.state.value as Load.Ready).value.map { it.number }) |
|
| 54 | + | assertEquals( |
|
| 55 | + | listOf("issues andymccall/oxygenhub-android open", "issues andymccall/oxygenhub-android closed", "issues andymccall/oxygenhub-android all"), |
|
| 56 | + | api.calls, |
|
| 57 | + | ) |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | @Test |
|
| 61 | + | fun noIssuesIsReadyAndEmpty() { |
|
| 62 | + | api.issues = { emptyList() } |
|
| 63 | + | val vm = IssuesViewModel(api, "a", "b") |
|
| 64 | + | assertEquals(Load.Ready(emptyList<Any>()), vm.state.value) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | @Test |
|
| 68 | + | fun markingOneRead() { |
|
| 69 | + | val vm = InboxViewModel(api) |
|
| 70 | + | val n = Fixtures.inbox.items[0] |
|
| 71 | + | vm.markRead(n) |
|
| 72 | + | val inbox = (vm.state.value as Load.Ready).value |
|
| 73 | + | assertEquals(1, inbox.unread) |
|
| 74 | + | assertTrue(inbox.items[0].read) |
|
| 75 | + | assertTrue("read 41" in api.calls) |
|
| 76 | + | // An entry already read is not marked again. |
|
| 77 | + | vm.markRead(inbox.items[2]) |
|
| 78 | + | assertEquals(1, api.calls.count { it.startsWith("read ") }) |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | @Test |
|
| 82 | + | fun aFailedMarkIsPutBack() { |
|
| 83 | + | api.markRead = { throw HubException.Http(403, "this token lacks the user:read scope") } |
|
| 84 | + | val vm = InboxViewModel(api) |
|
| 85 | + | vm.markRead(Fixtures.inbox.items[0]) |
|
| 86 | + | val inbox = (vm.state.value as Load.Ready).value |
|
| 87 | + | assertEquals(2, inbox.unread) |
|
| 88 | + | assertTrue(!inbox.items[0].read) |
|
| 89 | + | assertNotNull(vm.message.value) |
|
| 90 | + | vm.messageShown() |
|
| 91 | + | assertNull(vm.message.value) |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | @Test |
|
| 95 | + | fun markingAllRead() { |
|
| 96 | + | val vm = InboxViewModel(api) |
|
| 97 | + | vm.markAllRead() |
|
| 98 | + | val inbox = (vm.state.value as Load.Ready).value |
|
| 99 | + | assertEquals(0, inbox.unread) |
|
| 100 | + | assertTrue(inbox.items.all { it.read }) |
|
| 101 | + | assertTrue("read all" in api.calls) |
|
| 102 | + | // Nothing unread: nothing to ask. |
|
| 103 | + | vm.markAllRead() |
|
| 104 | + | assertEquals(1, api.calls.count { it == "read all" }) |
|
| 105 | + | } |
|
| 106 | + | } |
added app/src/test/java/io/oxygenhub/android/ui/SignInViewModelTest.kt +118 −0
| 1 | + | package io.oxygenhub.android.ui |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.FakeHubApi |
|
| 4 | + | import io.oxygenhub.android.MainDispatcherRule |
|
| 5 | + | import io.oxygenhub.android.api.DeviceAuth |
|
| 6 | + | import io.oxygenhub.android.api.DeviceCode |
|
| 7 | + | import io.oxygenhub.android.api.DeviceFlowException |
|
| 8 | + | import io.oxygenhub.android.api.HubException |
|
| 9 | + | import io.oxygenhub.android.api.TokenGrant |
|
| 10 | + | import io.oxygenhub.android.api.User |
|
| 11 | + | import io.oxygenhub.android.auth.MemorySessionStore |
|
| 12 | + | import io.oxygenhub.android.auth.Session |
|
| 13 | + | import io.oxygenhub.android.auth.SessionManager |
|
| 14 | + | import io.oxygenhub.android.ui.signin.SignInState |
|
| 15 | + | import io.oxygenhub.android.ui.signin.SignInViewModel |
|
| 16 | + | import kotlinx.coroutines.CompletableDeferred |
|
| 17 | + | import org.junit.Assert.assertEquals |
|
| 18 | + | import org.junit.Assert.assertNull |
|
| 19 | + | import org.junit.Assert.assertTrue |
|
| 20 | + | import org.junit.Rule |
|
| 21 | + | import org.junit.Test |
|
| 22 | + | ||
| 23 | + | class SignInViewModelTest { |
|
| 24 | + | @get:Rule val main = MainDispatcherRule() |
|
| 25 | + | ||
| 26 | + | private val code = DeviceCode("oxydev_x", "BCDF-GHJK", "https://hub.example/device", "https://hub.example/device?code=BCDF-GHJK") |
|
| 27 | + | private val grant = TokenGrant("oxy_new", scope = "repo:read user:read", username = "andymccall") |
|
| 28 | + | ||
| 29 | + | /** A device flow whose token arrives when the test says. */ |
|
| 30 | + | private inner class FakeAuth : DeviceAuth { |
|
| 31 | + | val token = CompletableDeferred<TokenGrant>() |
|
| 32 | + | var me: () -> User = { User("andymccall") } |
|
| 33 | + | var asked: String? = null |
|
| 34 | + | override suspend fun requestCode(client: String, scopes: List<String>): DeviceCode { |
|
| 35 | + | asked = client |
|
| 36 | + | return code |
|
| 37 | + | } |
|
| 38 | + | override suspend fun awaitToken(code: DeviceCode): TokenGrant = token.await() |
|
| 39 | + | override suspend fun whoAmI(token: String): User = me() |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | private val store = MemorySessionStore() |
|
| 43 | + | private val sessions = SessionManager(store) { _, _ -> FakeHubApi() } |
|
| 44 | + | private val auth = FakeAuth() |
|
| 45 | + | private val hubs = mutableListOf<String>() |
|
| 46 | + | ||
| 47 | + | private fun vm() = SignInViewModel(sessions, { hubs += it; auth }, "OxygenHub for Android on Test", "https://oxygenhub.io") |
|
| 48 | + | ||
| 49 | + | @Test |
|
| 50 | + | fun theDefaultHubOrTheLastOne() { |
|
| 51 | + | assertEquals("https://oxygenhub.io", vm().hub.value) |
|
| 52 | + | store.lastHub = "https://hub.example" |
|
| 53 | + | assertEquals("https://hub.example", vm().hub.value) |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | @Test |
|
| 57 | + | fun signsInOnceApproved() { |
|
| 58 | + | val vm = vm() |
|
| 59 | + | vm.setHub("hub.example/") |
|
| 60 | + | vm.signIn() |
|
| 61 | + | assertEquals(listOf("https://hub.example"), hubs) |
|
| 62 | + | assertEquals("https://hub.example", vm.hub.value) |
|
| 63 | + | assertEquals("OxygenHub for Android on Test", auth.asked) |
|
| 64 | + | assertEquals(SignInState.Approving(code), vm.state.value) |
|
| 65 | + | assertNull(sessions.session.value) |
|
| 66 | + | ||
| 67 | + | auth.token.complete(grant) |
|
| 68 | + | assertEquals(Session("https://hub.example", "andymccall", "oxy_new", listOf("repo:read", "user:read")), sessions.session.value) |
|
| 69 | + | assertEquals(store.load(), sessions.session.value) |
|
| 70 | + | assertEquals(SignInState.Idle(), vm.state.value) |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | @Test |
|
| 74 | + | fun aTokenWithoutUserReadUsesTheNameTheGrantGives() { |
|
| 75 | + | auth.me = { throw HubException.Http(403, "this token lacks the user:read scope") } |
|
| 76 | + | val vm = vm() |
|
| 77 | + | vm.signIn() |
|
| 78 | + | auth.token.complete(grant.copy(scope = "repo:read")) |
|
| 79 | + | assertEquals("andymccall", sessions.session.value?.username) |
|
| 80 | + | assertEquals(listOf("repo:read"), sessions.session.value?.scopes) |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | @Test |
|
| 84 | + | fun aBadAddressIsRefusedHere() { |
|
| 85 | + | val vm = vm() |
|
| 86 | + | vm.setHub("http://insecure.example") |
|
| 87 | + | vm.signIn() |
|
| 88 | + | assertTrue(hubs.isEmpty()) |
|
| 89 | + | assertTrue((vm.state.value as SignInState.Idle).error!!.contains("HTTPS")) |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | @Test |
|
| 93 | + | fun aDenialIsSaid() { |
|
| 94 | + | val vm = vm() |
|
| 95 | + | vm.signIn() |
|
| 96 | + | auth.token.completeExceptionally(DeviceFlowException.Denied()) |
|
| 97 | + | assertEquals(SignInState.Idle("The sign-in was denied on the web."), vm.state.value) |
|
| 98 | + | assertNull(sessions.session.value) |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | @Test |
|
| 102 | + | fun anUnreachableHubIsSaid() { |
|
| 103 | + | val vm = vm() |
|
| 104 | + | vm.signIn() |
|
| 105 | + | auth.token.completeExceptionally(HubException.Network(java.io.IOException("no route"))) |
|
| 106 | + | assertEquals(SignInState.Idle("Can't reach the hub. Check your connection and try again."), vm.state.value) |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | @Test |
|
| 110 | + | fun cancelling() { |
|
| 111 | + | val vm = vm() |
|
| 112 | + | vm.signIn() |
|
| 113 | + | vm.cancel() |
|
| 114 | + | assertEquals(SignInState.Idle(), vm.state.value) |
|
| 115 | + | auth.token.complete(grant) |
|
| 116 | + | assertNull(sessions.session.value) |
|
| 117 | + | } |
|
| 118 | + | } |
removed app/src/main/java/io/oxygenhub/android/ui/LoginScreen.kt +0 −0
| … | 133 unchanged lines |
removed app/src/main/java/io/oxygenhub/android/ui/MainScreen.kt +0 −0
| … | 121 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/MainActivity.kt +25 −27
| … | 3 unchanged lines |
||
| 4 | 4 | import androidx.activity.ComponentActivity |
|
| 5 | 5 | import androidx.activity.compose.setContent |
|
| 6 | 6 | import androidx.activity.enableEdgeToEdge |
|
| 7 | - | import androidx.compose.foundation.layout.fillMaxSize |
|
| 8 | - | import androidx.compose.foundation.layout.padding |
|
| 9 | - | import androidx.compose.material3.Scaffold |
|
| 10 | - | import androidx.compose.material3.Text |
|
| 11 | - | import androidx.compose.runtime.Composable |
|
| 12 | - | import androidx.compose.ui.Modifier |
|
| 13 | - | import androidx.compose.ui.tooling.preview.Preview |
|
| 7 | + | import androidx.compose.runtime.getValue |
|
| 8 | + | import androidx.compose.runtime.key |
|
| 9 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 10 | + | import io.oxygenhub.android.ui.nav.SignedInApp |
|
| 11 | + | import io.oxygenhub.android.ui.nav.rememberViewModel |
|
| 12 | + | import io.oxygenhub.android.ui.signin.SignInScreen |
|
| 13 | + | import io.oxygenhub.android.ui.signin.SignInViewModel |
|
| 14 | 14 | import io.oxygenhub.android.ui.theme.OxygenHubTheme |
|
| 15 | - | ||
| 16 | 15 | import uniffi.rust_core.oxygenGreeting |
|
| 17 | - | ||
| 18 | - | import io.oxygenhub.android.ui.LoginScreen |
|
| 19 | - | import io.oxygenhub.android.ui.MainScreen |
|
| 20 | - | import androidx.compose.runtime.* |
|
| 21 | 16 | ||
| 22 | 17 | class MainActivity : ComponentActivity() { |
|
| 23 | 18 | override fun onCreate(savedInstanceState: Bundle?) { |
|
| 24 | 19 | super.onCreate(savedInstanceState) |
|
| 25 | 20 | enableEdgeToEdge() |
|
| 21 | + | val container = (application as OxygenHubApp).container |
|
| 22 | + | val sessions = container.sessions |
|
| 26 | 23 | setContent { |
|
| 27 | 24 | OxygenHubTheme { |
|
| 28 | - | var isLoggedIn by remember { mutableStateOf(false) } |
|
| 29 | - | ||
| 30 | - | if (isLoggedIn) { |
|
| 31 | - | MainScreen() |
|
| 25 | + | val session by sessions.session.collectAsStateWithLifecycle() |
|
| 26 | + | val signedIn = session |
|
| 27 | + | val api = signedIn?.let { sessions.api() } |
|
| 28 | + | if (signedIn == null || api == null) { |
|
| 29 | + | SignInScreen( |
|
| 30 | + | rememberViewModel { SignInViewModel(sessions, container::deviceAuth, container.clientName, DEFAULT_HUB) }, |
|
| 31 | + | ) |
|
| 32 | 32 | } else { |
|
| 33 | - | LoginScreen( |
|
| 34 | - | onLoginSuccess = { isLoggedIn = true } |
|
| 35 | - | ) |
|
| 33 | + | // A new session starts the signed-in app afresh: nothing of the last account's is kept. |
|
| 34 | + | key(signedIn) { |
|
| 35 | + | SignedInApp( |
|
| 36 | + | session = signedIn, |
|
| 37 | + | api = api, |
|
| 38 | + | nativeCore = { oxygenGreeting(signedIn.username) }, |
|
| 39 | + | onSignOut = { sessions.signOut() }, |
|
| 40 | + | ) |
|
| 41 | + | } |
|
| 36 | 42 | } |
|
| 37 | 43 | } |
|
| 38 | 44 | } |
|
| 39 | 45 | } |
|
| 40 | 46 | } |
|
| 41 | - | ||
| 42 | - | @Preview(showBackground = true) |
|
| 43 | - | @Composable |
|
| 44 | - | fun MainScreenPreview() { |
|
| 45 | - | OxygenHubTheme { |
|
| 46 | - | MainScreen() |
|
| 47 | - | } |
|
| 48 | - | } |