feat: onboarding flow

Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
Infi 2023-05-12 01:30:05 +02:00
parent e316c0bf3a
commit d42395b826
8 changed files with 307 additions and 20 deletions

View File

@ -26,6 +26,7 @@ import chat.revolt.screens.chat.dialogs.FeedbackDialog
import chat.revolt.screens.login.LoginGreetingScreen
import chat.revolt.screens.login.LoginScreen
import chat.revolt.screens.login.MfaScreen
import chat.revolt.screens.register.OnboardingScreen
import chat.revolt.screens.register.RegisterDetailsScreen
import chat.revolt.screens.register.RegisterGreetingScreen
import chat.revolt.screens.register.RegisterVerifyScreen
@ -119,6 +120,7 @@ fun AppEntrypoint() {
RegisterVerifyScreen(navController, email)
}
composable("register/onboarding") { OnboardingScreen(navController) }
composable("chat") { ChatRouterScreen(navController) }

View File

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

View File

@ -3,8 +3,10 @@ package chat.revolt.api.routes.sync
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.client.request.parameter
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.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer

View File

@ -17,20 +17,24 @@ object SyncedSettings {
get() = _android.value
suspend fun fetch() {
val settings = getKeys("ordering", "android")
try {
val settings = getKeys("ordering", "android")
settings["ordering"]?.let {
_ordering.value = RevoltJson.decodeFromString(
OrderingSettings.serializer(),
it.value
)
}
settings["ordering"]?.let {
_ordering.value = RevoltJson.decodeFromString(
OrderingSettings.serializer(),
it.value
)
}
settings["android"]?.let {
_android.value = RevoltJson.decodeFromString(
AndroidSpecificSettings.serializer(),
it.value
)
settings["android"]?.let {
_android.value = RevoltJson.decodeFromString(
AndroidSpecificSettings.serializer(),
it.value
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}

View File

@ -28,6 +28,7 @@ import chat.revolt.activities.WebChallengeActivity
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
import chat.revolt.api.internals.WebChallenge
import chat.revolt.api.routes.onboard.needsOnboarding
import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.components.screens.splash.DisconnectedScreen
@ -112,6 +113,12 @@ class SplashScreenViewModel @Inject constructor(
kvStorage.remove("sessionToken")
setNavigateTo("login")
} else {
val onboard = needsOnboarding()
if (onboard) {
setNavigateTo("onboarding")
return@launch
}
RevoltAPI.loginAs(token)
loadSettings()
setNavigateTo("home")
@ -183,6 +190,14 @@ fun SplashScreen(
}
}
"onboarding" -> {
navController.navigate("register/onboarding") {
popUpTo("splash") {
inclusive = true
}
}
}
"webchallenge" -> {
val intent = Intent(context, WebChallengeActivity::class.java)
webChallengeActivityResult.launch(intent)

View File

@ -37,6 +37,7 @@ import chat.revolt.api.REVOLT_SUPPORT
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.account.EmailPasswordAssessment
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.FormTextField
import chat.revolt.components.generic.Weblink
@ -89,8 +90,17 @@ class LoginViewModel @Inject constructor(
)
try {
RevoltAPI.loginAs(response.firstUserHints!!.token)
kvStorage.set("sessionToken", response.firstUserHints.token)
val token = response.firstUserHints!!.token
kvStorage.set("sessionToken", token)
val onboard = needsOnboarding(token)
if (onboard) {
_navigateTo = "onboarding"
return@launch
}
RevoltAPI.loginAs(token)
_navigateTo = "home"
} catch (e: Error) {
@ -136,6 +146,12 @@ fun LoginScreen(
popUpTo("login/greeting") { inclusive = true }
}
}
"onboarding" -> {
navController.navigate("register/onboarding") {
popUpTo("login/greeting") { inclusive = true }
}
}
}
if (viewModel.navigateTo != null) {
viewModel.navigationComplete()

View File

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

View File

@ -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="open_mail_app">Open mail app</string>
<string name="welcome">Welcome!</string>
<string name="username_choose_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="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_welcome">Welcome!</string>
<string name="onboarding_lead">It\'s time to choose a username!</string>
<string name="onboarding_others">Others will find, recognise and mention you with this name.</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>