diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index e24ca02f..8175efae 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/chat/revolt/api/routes/onboard/Onboarding.kt b/app/src/main/java/chat/revolt/api/routes/onboard/Onboarding.kt new file mode 100644 index 00000000..998edd0e --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/onboard/Onboarding.kt @@ -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 { + 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) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/sync/SettingsSync.kt b/app/src/main/java/chat/revolt/api/routes/sync/SettingsSync.kt index 10740c66..1866627e 100644 --- a/app/src/main/java/chat/revolt/api/routes/sync/SettingsSync.kt +++ b/app/src/main/java/chat/revolt/api/routes/sync/SettingsSync.kt @@ -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 diff --git a/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt b/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt index ceaa1712..5b070e63 100644 --- a/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt +++ b/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt @@ -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() } } diff --git a/app/src/main/java/chat/revolt/screens/SplashScreen.kt b/app/src/main/java/chat/revolt/screens/SplashScreen.kt index 470dc07b..001e0a40 100644 --- a/app/src/main/java/chat/revolt/screens/SplashScreen.kt +++ b/app/src/main/java/chat/revolt/screens/SplashScreen.kt @@ -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) diff --git a/app/src/main/java/chat/revolt/screens/login/LoginScreen.kt b/app/src/main/java/chat/revolt/screens/login/LoginScreen.kt index 35a4e76e..13f286bc 100644 --- a/app/src/main/java/chat/revolt/screens/login/LoginScreen.kt +++ b/app/src/main/java/chat/revolt/screens/login/LoginScreen.kt @@ -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() diff --git a/app/src/main/java/chat/revolt/screens/register/OnboardingScreen.kt b/app/src/main/java/chat/revolt/screens/register/OnboardingScreen.kt new file mode 100644 index 00000000..2777667a --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/register/OnboardingScreen.kt @@ -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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8cea7dcc..17414546 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,10 +46,11 @@ Verify your email, and then we\'ll get on with choosing your username. Open mail app - Welcome! - It\'s time to choose a username! - Others will find, recognise and mention you with this name. - But if you change your mind, you can change your username at any time in your User Settings. + Welcome! + It\'s time to choose a username! + Others will find, recognise and mention you with this name. + But if you change your mind, you can change your username at any time in your User Settings. + Username You have been logged out. Please log in again.