OXYGENHUB

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