feat: onboarding flow
Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
parent
e316c0bf3a
commit
d42395b826
|
|
@ -26,6 +26,7 @@ import chat.revolt.screens.chat.dialogs.FeedbackDialog
|
||||||
import chat.revolt.screens.login.LoginGreetingScreen
|
import chat.revolt.screens.login.LoginGreetingScreen
|
||||||
import chat.revolt.screens.login.LoginScreen
|
import chat.revolt.screens.login.LoginScreen
|
||||||
import chat.revolt.screens.login.MfaScreen
|
import chat.revolt.screens.login.MfaScreen
|
||||||
|
import chat.revolt.screens.register.OnboardingScreen
|
||||||
import chat.revolt.screens.register.RegisterDetailsScreen
|
import chat.revolt.screens.register.RegisterDetailsScreen
|
||||||
import chat.revolt.screens.register.RegisterGreetingScreen
|
import chat.revolt.screens.register.RegisterGreetingScreen
|
||||||
import chat.revolt.screens.register.RegisterVerifyScreen
|
import chat.revolt.screens.register.RegisterVerifyScreen
|
||||||
|
|
@ -119,6 +120,7 @@ fun AppEntrypoint() {
|
||||||
|
|
||||||
RegisterVerifyScreen(navController, email)
|
RegisterVerifyScreen(navController, email)
|
||||||
}
|
}
|
||||||
|
composable("register/onboarding") { OnboardingScreen(navController) }
|
||||||
|
|
||||||
composable("chat") { ChatRouterScreen(navController) }
|
composable("chat") { ChatRouterScreen(navController) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
package chat.revolt.api.routes.onboard
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import chat.revolt.api.RevoltAPI
|
||||||
|
import chat.revolt.api.RevoltError
|
||||||
|
import chat.revolt.api.RevoltHttp
|
||||||
|
import chat.revolt.api.RevoltJson
|
||||||
|
import chat.revolt.api.schemas.RsResult
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OnboardingResponse(
|
||||||
|
val onboarding: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun needsOnboarding(
|
||||||
|
sessionToken: String = RevoltAPI.sessionToken,
|
||||||
|
): Boolean {
|
||||||
|
val response = RevoltHttp.get("/onboard/hello") {
|
||||||
|
header(RevoltAPI.TOKEN_HEADER_NAME, sessionToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
val responseContent = response.bodyAsText()
|
||||||
|
|
||||||
|
return RevoltJson.decodeFromString(OnboardingResponse.serializer(), responseContent).onboarding
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OnboardingCompletionBody(
|
||||||
|
val username: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun completeOnboarding(
|
||||||
|
body: OnboardingCompletionBody,
|
||||||
|
sessionToken: String = RevoltAPI.sessionToken,
|
||||||
|
): RsResult<Unit, RevoltError> {
|
||||||
|
val response = RevoltHttp.post("/onboard/complete") {
|
||||||
|
setBody(body)
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
header(RevoltAPI.TOKEN_HEADER_NAME, sessionToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("RevoltAPI", "completeOnboarding: ${response.status}")
|
||||||
|
|
||||||
|
if (response.status == HttpStatusCode.Conflict) {
|
||||||
|
return RsResult.err(RevoltError("UsernameTaken"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status == HttpStatusCode.BadRequest) {
|
||||||
|
return RsResult.err(RevoltError("InvalidUsername"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val responseContent = response.bodyAsText()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val error = RevoltJson.decodeFromString(RevoltError.serializer(), responseContent)
|
||||||
|
return RsResult.err(error)
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
// Not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
return RsResult.ok(Unit)
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,10 @@ package chat.revolt.api.routes.sync
|
||||||
import chat.revolt.api.RevoltAPI
|
import chat.revolt.api.RevoltAPI
|
||||||
import chat.revolt.api.RevoltHttp
|
import chat.revolt.api.RevoltHttp
|
||||||
import chat.revolt.api.RevoltJson
|
import chat.revolt.api.RevoltJson
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.parameter
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
import kotlinx.serialization.builtins.MapSerializer
|
import kotlinx.serialization.builtins.MapSerializer
|
||||||
|
|
|
||||||
|
|
@ -17,20 +17,24 @@ object SyncedSettings {
|
||||||
get() = _android.value
|
get() = _android.value
|
||||||
|
|
||||||
suspend fun fetch() {
|
suspend fun fetch() {
|
||||||
val settings = getKeys("ordering", "android")
|
try {
|
||||||
|
val settings = getKeys("ordering", "android")
|
||||||
|
|
||||||
settings["ordering"]?.let {
|
settings["ordering"]?.let {
|
||||||
_ordering.value = RevoltJson.decodeFromString(
|
_ordering.value = RevoltJson.decodeFromString(
|
||||||
OrderingSettings.serializer(),
|
OrderingSettings.serializer(),
|
||||||
it.value
|
it.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
settings["android"]?.let {
|
settings["android"]?.let {
|
||||||
_android.value = RevoltJson.decodeFromString(
|
_android.value = RevoltJson.decodeFromString(
|
||||||
AndroidSpecificSettings.serializer(),
|
AndroidSpecificSettings.serializer(),
|
||||||
it.value
|
it.value
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import chat.revolt.activities.WebChallengeActivity
|
||||||
import chat.revolt.api.RevoltAPI
|
import chat.revolt.api.RevoltAPI
|
||||||
import chat.revolt.api.RevoltHttp
|
import chat.revolt.api.RevoltHttp
|
||||||
import chat.revolt.api.internals.WebChallenge
|
import chat.revolt.api.internals.WebChallenge
|
||||||
|
import chat.revolt.api.routes.onboard.needsOnboarding
|
||||||
import chat.revolt.api.settings.GlobalState
|
import chat.revolt.api.settings.GlobalState
|
||||||
import chat.revolt.api.settings.SyncedSettings
|
import chat.revolt.api.settings.SyncedSettings
|
||||||
import chat.revolt.components.screens.splash.DisconnectedScreen
|
import chat.revolt.components.screens.splash.DisconnectedScreen
|
||||||
|
|
@ -112,6 +113,12 @@ class SplashScreenViewModel @Inject constructor(
|
||||||
kvStorage.remove("sessionToken")
|
kvStorage.remove("sessionToken")
|
||||||
setNavigateTo("login")
|
setNavigateTo("login")
|
||||||
} else {
|
} else {
|
||||||
|
val onboard = needsOnboarding()
|
||||||
|
if (onboard) {
|
||||||
|
setNavigateTo("onboarding")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
RevoltAPI.loginAs(token)
|
RevoltAPI.loginAs(token)
|
||||||
loadSettings()
|
loadSettings()
|
||||||
setNavigateTo("home")
|
setNavigateTo("home")
|
||||||
|
|
@ -183,6 +190,14 @@ fun SplashScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"onboarding" -> {
|
||||||
|
navController.navigate("register/onboarding") {
|
||||||
|
popUpTo("splash") {
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"webchallenge" -> {
|
"webchallenge" -> {
|
||||||
val intent = Intent(context, WebChallengeActivity::class.java)
|
val intent = Intent(context, WebChallengeActivity::class.java)
|
||||||
webChallengeActivityResult.launch(intent)
|
webChallengeActivityResult.launch(intent)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import chat.revolt.api.REVOLT_SUPPORT
|
||||||
import chat.revolt.api.RevoltAPI
|
import chat.revolt.api.RevoltAPI
|
||||||
import chat.revolt.api.routes.account.EmailPasswordAssessment
|
import chat.revolt.api.routes.account.EmailPasswordAssessment
|
||||||
import chat.revolt.api.routes.account.negotiateAuthentication
|
import chat.revolt.api.routes.account.negotiateAuthentication
|
||||||
|
import chat.revolt.api.routes.onboard.needsOnboarding
|
||||||
import chat.revolt.components.generic.AnyLink
|
import chat.revolt.components.generic.AnyLink
|
||||||
import chat.revolt.components.generic.FormTextField
|
import chat.revolt.components.generic.FormTextField
|
||||||
import chat.revolt.components.generic.Weblink
|
import chat.revolt.components.generic.Weblink
|
||||||
|
|
@ -89,8 +90,17 @@ class LoginViewModel @Inject constructor(
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
RevoltAPI.loginAs(response.firstUserHints!!.token)
|
val token = response.firstUserHints!!.token
|
||||||
kvStorage.set("sessionToken", response.firstUserHints.token)
|
|
||||||
|
kvStorage.set("sessionToken", token)
|
||||||
|
|
||||||
|
val onboard = needsOnboarding(token)
|
||||||
|
if (onboard) {
|
||||||
|
_navigateTo = "onboarding"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
RevoltAPI.loginAs(token)
|
||||||
|
|
||||||
_navigateTo = "home"
|
_navigateTo = "home"
|
||||||
} catch (e: Error) {
|
} catch (e: Error) {
|
||||||
|
|
@ -136,6 +146,12 @@ fun LoginScreen(
|
||||||
popUpTo("login/greeting") { inclusive = true }
|
popUpTo("login/greeting") { inclusive = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"onboarding" -> {
|
||||||
|
navController.navigate("register/onboarding") {
|
||||||
|
popUpTo("login/greeting") { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (viewModel.navigateTo != null) {
|
if (viewModel.navigateTo != null) {
|
||||||
viewModel.navigationComplete()
|
viewModel.navigationComplete()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
package chat.revolt.screens.register
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import chat.revolt.R
|
||||||
|
import chat.revolt.api.routes.onboard.OnboardingCompletionBody
|
||||||
|
import chat.revolt.api.routes.onboard.completeOnboarding
|
||||||
|
import chat.revolt.components.generic.FormTextField
|
||||||
|
import chat.revolt.persistence.KVStorage
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OnboardingScreen(navController: NavController) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val username = remember { mutableStateOf("") }
|
||||||
|
val error = remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
fun onboardingComplete() {
|
||||||
|
navController.navigate("splash") {
|
||||||
|
popUpTo("register/onboarding") { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun onboard() {
|
||||||
|
val body = OnboardingCompletionBody(
|
||||||
|
username = username.value
|
||||||
|
)
|
||||||
|
|
||||||
|
val sessionToken = KVStorage(context).get("sessionToken") ?: return
|
||||||
|
val result = completeOnboarding(body, sessionToken)
|
||||||
|
|
||||||
|
Log.d("OnboardingScreen", "onboard: $result")
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
onboardingComplete()
|
||||||
|
} else {
|
||||||
|
error.value = result.error?.type ?: "Unknown error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_welcome),
|
||||||
|
style = MaterialTheme.typography.displaySmall.copy(
|
||||||
|
fontSize = 30.sp,
|
||||||
|
fontWeight = FontWeight.Black,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_lead),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground.copy(
|
||||||
|
alpha = 0.5f
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_others),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground.copy(
|
||||||
|
alpha = 0.5f
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_changeable),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground.copy(
|
||||||
|
alpha = 0.5f
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
FormTextField(
|
||||||
|
value = username.value,
|
||||||
|
onChange = { username.value = it },
|
||||||
|
label = stringResource(R.string.onboarding_username),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error.value.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = error.value,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(horizontal = 40.dp, vertical = 10.dp),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
onboard()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = username.value.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.next))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,10 +46,11 @@
|
||||||
<string name="verify_then_choose_username">Verify your email, and then we\'ll get on with choosing your username.</string>
|
<string name="verify_then_choose_username">Verify your email, and then we\'ll get on with choosing your username.</string>
|
||||||
<string name="open_mail_app">Open mail app</string>
|
<string name="open_mail_app">Open mail app</string>
|
||||||
|
|
||||||
<string name="welcome">Welcome!</string>
|
<string name="onboarding_welcome">Welcome!</string>
|
||||||
<string name="username_choose_lead">It\'s time to choose a username!</string>
|
<string name="onboarding_lead">It\'s time to choose a username!</string>
|
||||||
<string name="username_choose_others">Others will find, recognise and mention you with this name.</string>
|
<string name="onboarding_others">Others will find, recognise and mention you with this name.</string>
|
||||||
<string name="username_choose_changeable">But if you change your mind, you can change your username at any time in your User Settings.</string>
|
<string name="onboarding_changeable">But if you change your mind, you can change your username at any time in your User Settings.</string>
|
||||||
|
<string name="onboarding_username">Username</string>
|
||||||
|
|
||||||
<string name="token_invalid_toast">You have been logged out. Please log in again.</string>
|
<string name="token_invalid_toast">You have been logged out. Please log in again.</string>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue