OXYGENHUB
# 1b7703ec4b02 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / signin / SignInScreen.kt
8.1 KiB · last changed Panels you can see: the hub's palette by default, clear surface steps and a 1 dp edge on every panel (closes #24) 7 days ago source blame history raw
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.CardDefaults
25
import androidx.compose.material3.CircularProgressIndicator
26
import androidx.compose.material3.Icon
27
import androidx.compose.material3.LinearProgressIndicator
28
import androidx.compose.material3.MaterialTheme
29
import androidx.compose.material3.OutlinedTextField
30
import androidx.compose.material3.Surface
31
import androidx.compose.material3.Text
32
import androidx.compose.material3.TextButton
33
import androidx.compose.runtime.Composable
34
import androidx.compose.runtime.getValue
35
import androidx.compose.ui.Alignment
36
import androidx.compose.ui.Modifier
37
import androidx.compose.ui.graphics.Color
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
import io.oxygenhub.android.ui.backdrop.Backdrop
53
import io.oxygenhub.android.ui.common.Panel
54
import uniffi.rust_core.Backdrop as Scene
55
56
@Composable
57
fun SignInScreen(vm: SignInViewModel) {
58
    val hub by vm.hub.collectAsStateWithLifecycle()
59
    val state by vm.state.collectAsStateWithLifecycle()
60
    val ended by vm.ended.collectAsStateWithLifecycle()
61
    // The OxygenHub mark behind it, as behind the hub's sign-in page (#23): it answers a finger.
62
    Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
63
        Backdrop(key = Unit, make = { still -> Scene.mark(still) }, modifier = Modifier.fillMaxSize()) {
64
            SignInContent(
65
                hub = hub,
66
                state = state,
67
                notice = ended,
68
                onHubChange = vm::setHub,
69
                onSignIn = vm::signIn,
70
                onCancel = vm::cancel,
71
                background = Color.Transparent,
72
            )
73
        }
74
    }
75
}
76
77
@Composable
78
fun SignInContent(
79
    hub: String,
80
    state: SignInState,
81
    notice: String?,
82
    onHubChange: (String) -> Unit,
83
    onSignIn: () -> Unit,
84
    onCancel: () -> Unit,
85
    background: Color = MaterialTheme.colorScheme.background,
86
) {
87
    Surface(Modifier.fillMaxSize(), color = background) {
88
        Box(Modifier.fillMaxSize().safeDrawingPadding().imePadding(), contentAlignment = Alignment.Center) {
89
            Column(
90
                Modifier
91
                    .widthIn(max = 480.dp)
92
                    .fillMaxWidth()
93
                    .verticalScroll(rememberScrollState())
94
                    .padding(24.dp),
95
                horizontalAlignment = Alignment.CenterHorizontally,
96
            ) {
97
                Image(painterResource(R.drawable.mark), contentDescription = null, modifier = Modifier.size(width = 96.dp, height = 48.dp))
98
                Spacer(Modifier.height(16.dp))
99
                Text("OxygenHub", fontSize = 36.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
100
                Spacer(Modifier.height(32.dp))
101
                when (state) {
102
                    is SignInState.Idle -> Start(hub, state.error ?: notice, onHubChange, onSignIn)
103
                    SignInState.Requesting -> Waiting("Asking $hub for a code…")
104
                    is SignInState.Approving -> Approve(state.code, onCancel)
105
                    SignInState.Finishing -> Waiting("Approved. Signing in…")
106
                }
107
            }
108
        }
109
    }
110
}
111
112
@Composable
113
private fun Start(hub: String, error: String?, onHubChange: (String) -> Unit, onSignIn: () -> Unit) {
114
    Text(
115
        "Sign in with your browser: the app shows a code, you approve it on the hub's web page, " +
116
            "signed in there with your password and second factor. The app never sees either.",
117
        style = MaterialTheme.typography.bodyLarge,
118
        color = MaterialTheme.colorScheme.onSurfaceVariant,
119
        textAlign = TextAlign.Center,
120
    )
121
    Spacer(Modifier.height(24.dp))
122
    OutlinedTextField(
123
        value = hub,
124
        onValueChange = onHubChange,
125
        label = { Text("Hub") },
126
        leadingIcon = { Icon(Icons.Outlined.Dns, contentDescription = null) },
127
        singleLine = true,
128
        isError = error != null,
129
        supportingText = { Text(error ?: "The hub's address; oxygenhub.io unless yours is your own.") },
130
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, autoCorrectEnabled = false),
131
        keyboardActions = KeyboardActions(onGo = { onSignIn() }),
132
        modifier = Modifier.fillMaxWidth(),
133
    )
134
    Spacer(Modifier.height(16.dp))
135
    Button(onClick = onSignIn, enabled = hub.isNotBlank(), modifier = Modifier.fillMaxWidth().height(50.dp)) {
136
        Text("Sign in", fontSize = 16.sp)
137
    }
138
}
139
140
@Composable
141
private fun Waiting(what: String) {
142
    CircularProgressIndicator()
143
    Spacer(Modifier.height(16.dp))
144
    Text(what, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
145
}
146
147
@Composable
148
private fun Approve(code: DeviceCode, onCancel: () -> Unit) {
149
    val uri = LocalUriHandler.current
150
    Text("Approve this code on the hub", style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center)
151
    Spacer(Modifier.height(16.dp))
152
    Panel(
153
        colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
154
        modifier = Modifier.fillMaxWidth(),
155
    ) {
156
        Text(
157
            code.userCode,
158
            color = MaterialTheme.colorScheme.onSurface,
159
            fontFamily = FontFamily.Monospace,
160
            fontSize = 34.sp,
161
            fontWeight = FontWeight.Bold,
162
            letterSpacing = 4.sp,
163
            textAlign = TextAlign.Center,
164
            modifier = Modifier
165
                .fillMaxWidth()
166
                .padding(vertical = 24.dp)
167
                .semantics { contentDescription = "Code ${code.userCode.toCharArray().joinToString(" ")}" },
168
        )
169
    }
170
    Spacer(Modifier.height(16.dp))
171
    Button(onClick = { uri.openUri(code.pageToOpen) }, modifier = Modifier.fillMaxWidth().height(50.dp)) {
172
        Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null)
173
        Spacer(Modifier.size(8.dp))
174
        Text("Open the hub to approve", fontSize = 16.sp)
175
    }
176
    Spacer(Modifier.height(12.dp))
177
    Text(
178
        "Or on a computer, go to ${code.verificationUri} and enter the code.",
179
        style = MaterialTheme.typography.bodyMedium,
180
        color = MaterialTheme.colorScheme.onSurfaceVariant,
181
        textAlign = TextAlign.Center,
182
    )
183
    Spacer(Modifier.height(24.dp))
184
    LinearProgressIndicator(Modifier.fillMaxWidth())
185
    Spacer(Modifier.height(8.dp))
186
    Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
187
        Text("Waiting for approval…", style = MaterialTheme.typography.bodyMedium)
188
        TextButton(onClick = onCancel) { Text("Cancel") }
189
    }
190
}