OXYGENHUB
◆ v0.1.9 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / account / AccountScreen.kt
7.0 KiB · last changed This phone as the second factor: a Keystore key opened by fingerprint, face or PIN approves sign-ins and sensitive changes on the web by the number the browser shows (closes #32) 3 days ago source blame history raw
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.verticalScroll
14
import androidx.compose.material.icons.Icons
15
import androidx.compose.material.icons.automirrored.outlined.Logout
16
import androidx.compose.material.icons.outlined.Dns
17
import androidx.compose.material.icons.outlined.Key
18
import androidx.compose.material.icons.outlined.Memory
19
import androidx.compose.material.icons.outlined.Palette
20
import androidx.compose.material3.AlertDialog
21
import androidx.compose.material3.CircularProgressIndicator
22
import androidx.compose.material3.Icon
23
import androidx.compose.material3.ListItem
24
import androidx.compose.material3.MaterialTheme
25
import androidx.compose.material3.OutlinedButton
26
import androidx.compose.material3.Switch
27
import androidx.compose.material3.Text
28
import androidx.compose.material3.TextButton
29
import androidx.compose.runtime.Composable
30
import androidx.compose.runtime.getValue
31
import androidx.compose.runtime.mutableStateOf
32
import androidx.compose.runtime.remember
33
import androidx.compose.runtime.rememberCoroutineScope
34
import androidx.compose.runtime.setValue
35
import androidx.compose.ui.Alignment
36
import androidx.compose.ui.Modifier
37
import androidx.compose.ui.platform.LocalContext
38
import androidx.compose.ui.text.font.FontWeight
39
import androidx.compose.ui.unit.dp
40
import io.oxygenhub.android.alerts.LandAlertsCard
41
import io.oxygenhub.android.api.Avatar
42
import io.oxygenhub.android.api.HubApi
43
import io.oxygenhub.android.auth.Session
44
import io.oxygenhub.android.runner.RunnerCard
45
import io.oxygenhub.android.ui.common.AppScreen
46
import io.oxygenhub.android.ui.common.AvatarImage
47
import io.oxygenhub.android.secondfactor.SecondFactorCard
48
import io.oxygenhub.android.ui.common.Panel
49
import io.oxygenhub.android.ui.theme.Appearance
50
import io.oxygenhub.android.ui.theme.Tones
51
import io.oxygenhub.android.ui.theme.rememberWallpaperColours
52
import io.oxygenhub.android.update.UpdateCard
53
import kotlinx.coroutines.launch
54
55
/** Who is signed in, to which hub, with what; and signing out. */
56
@Composable
57
fun AccountScreen(session: Session, avatar: Avatar?, api: HubApi, nativeCore: () -> String, onSignOut: suspend () -> Unit) {
58
    var confirming by remember { mutableStateOf(false) }
59
    var signingOut by remember { mutableStateOf(false) }
60
    val scope = rememberCoroutineScope()
61
    val core = remember { runCatching(nativeCore).getOrElse { "unavailable: ${it.message}" } }
62
63
    AppScreen(title = "Account") {
64
        Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
65
            Column(
66
                Modifier.widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp),
67
                verticalArrangement = Arrangement.spacedBy(16.dp),
68
            ) {
69
                Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
70
                    AvatarImage(avatar, session.username, size = 72.dp)
71
                    Spacer(Modifier.height(8.dp))
72
                    Text(session.username, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
73
                }
74
                LandAlertsCard()
75
                SecondFactorCard(api)
76
                RunnerCard(api, session.hub, session.canWrite)
77
                UpdateCard(api, session.hub)
78
                AppearanceCard()
79
                Panel(Modifier.fillMaxWidth()) {
80
                    ListItem(
81
                        leadingContent = { Icon(Icons.Outlined.Dns, contentDescription = null, tint = Tones.info) },
82
                        headlineContent = { Text("Hub") },
83
                        supportingContent = { Text(session.hub) },
84
                    )
85
                    ListItem(
86
                        leadingContent = { Icon(Icons.Outlined.Key, contentDescription = null, tint = Tones.info) },
87
                        headlineContent = { Text("Allowed") },
88
                        supportingContent = { Text(session.scopes.ifEmpty { listOf("(the hub did not say)") }.joinToString(", ")) },
89
                    )
90
                    ListItem(
91
                        leadingContent = { Icon(Icons.Outlined.Memory, contentDescription = null, tint = Tones.info) },
92
                        headlineContent = { Text("Native core") },
93
                        supportingContent = { Text(core) },
94
                    )
95
                }
96
                Text(
97
                    "This phone's sign-in is a device token, kept encrypted with a key in the Android Keystore. " +
98
                        "It lasts while it is used, and ends after 90 days without use. Signing out revokes it on the hub.",
99
                    style = MaterialTheme.typography.bodySmall,
100
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
101
                )
102
                OutlinedButton(onClick = { confirming = true }, enabled = !signingOut, modifier = Modifier.fillMaxWidth()) {
103
                    if (signingOut) {
104
                        CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
105
                    } else {
106
                        Icon(Icons.AutoMirrored.Outlined.Logout, contentDescription = null)
107
                    }
108
                    Spacer(Modifier.size(8.dp))
109
                    Text("Sign out")
110
                }
111
            }
112
        }
113
    }
114
115
    if (confirming) {
116
        AlertDialog(
117
            onDismissRequest = { confirming = false },
118
            title = { Text("Sign out?") },
119
            text = { Text("The token this phone uses is revoked on ${session.hub}. You can sign in again at any time.") },
120
            confirmButton = {
121
                TextButton(onClick = {
122
                    confirming = false
123
                    signingOut = true
124
                    scope.launch { onSignOut() }
125
                }) { Text("Sign out") }
126
            },
127
            dismissButton = { TextButton(onClick = { confirming = false }) { Text("Cancel") } },
128
        )
129
    }
130
}
131
132
/** The app's colours (#24): the hub's palette, or the wallpaper's. */
133
@Composable
134
private fun AppearanceCard() {
135
    val context = LocalContext.current
136
    val wallpaper = rememberWallpaperColours()
137
    Panel(Modifier.fillMaxWidth()) {
138
        ListItem(
139
            leadingContent = { Icon(Icons.Outlined.Palette, contentDescription = null, tint = Tones.info) },
140
            headlineContent = { Text("Wallpaper colours") },
141
            supportingContent = { Text(if (wallpaper) "The app takes its colours from your wallpaper." else "The app uses OxygenHub's own colours, as the website does.") },
142
            trailingContent = { Switch(checked = wallpaper, onCheckedChange = { Appearance.setWallpaperColours(context, it) }) },
143
        )
144
    }
145
}