frontend update, big refactor

This commit is contained in:
2026-04-02 03:09:33 +01:00
parent 3dfaab4865
commit ad0cf85b34
40 changed files with 2131 additions and 1485 deletions
@@ -1,11 +1,19 @@
package dev.zxq5.chatapp.android package dev.zxq5.chatapp.android
import android.app.Application import android.app.Application
import dev.zxq5.chatapp.android.api.ApiClient import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
class ChatApplication : Application() { class ChatApplication : Application() {
val tokenStore by lazy { TokenStore(this) }
val authRepository by lazy { AuthRepository(tokenStore) }
val chatRepository by lazy { ChatRepository(tokenStore) }
val settingsRepository by lazy { SettingsRepository(tokenStore) }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ApiClient.init(this)
} }
} }
@@ -11,36 +11,66 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import dev.zxq5.chatapp.android.model.ChatViewModel import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.model.LoginState import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.model.MainScreen import dev.zxq5.chatapp.android.data.repository.AuthState
import dev.zxq5.chatapp.android.ui.components.AuthScreen import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.ui.components.ChatScreen import dev.zxq5.chatapp.android.data.repository.SettingsRepository
import dev.zxq5.chatapp.android.ui.components.SettingsScreen import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
import dev.zxq5.chatapp.android.feature.chat.Screen
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
import dev.zxq5.chatapp.android.feature.auth.AuthScreen
import dev.zxq5.chatapp.android.feature.chat.ChatScreen
import dev.zxq5.chatapp.android.feature.settings.SettingsScreen
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val app = application as ChatApplication
val tokenStore = app.tokenStore
val authRepository = app.authRepository
val chatRepository = app.chatRepository
val settingsRepository = app.settingsRepository
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
ChatappTheme { ChatappTheme {
val viewModel: ChatViewModel = viewModel() val authViewModel: AuthViewModel = viewModel(factory = ViewModelFactory(authRepository))
val loginState by viewModel.loginState.collectAsState() val chatViewModel: ChatViewModel = viewModel(factory = ViewModelFactory(chatRepository))
val currentScreen by viewModel.currentScreen.collectAsState() val settingsViewModel: SettingsViewModel = viewModel(factory = ViewModelFactory(settingsRepository))
val authState by authViewModel.authState.collectAsState()
val currentScreen by chatViewModel.currentScreen.collectAsState()
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) { androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) {
if (loginState is LoginState.Success) { when (authState) {
when (currentScreen) { AuthState.Authenticated -> {
MainScreen.CHAT -> ChatScreen(viewModel = viewModel) when (currentScreen) {
MainScreen.SETTINGS -> SettingsScreen(viewModel = viewModel) Screen.CHAT -> ChatScreen(
viewModel = chatViewModel,
onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) },
onLogout = {
authViewModel.logout()
chatViewModel.clearChat()
}
)
Screen.SETTINGS -> SettingsScreen(
viewModel = settingsViewModel,
onBack = { chatViewModel.navigateTo(Screen.CHAT) },
onLogout = {
authViewModel.logout()
chatViewModel.clearChat()
}
)
}
}
AuthState.AwaitingTotp, AuthState.Unauthenticated -> {
AuthScreen(viewModel = authViewModel)
} }
} else {
AuthScreen(
viewModel = viewModel,
onSuccess = { }
)
} }
} }
} }
@@ -0,0 +1,27 @@
package dev.zxq5.chatapp.android
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
class ViewModelFactory(private val repository: Any) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(AuthViewModel::class.java) -> {
AuthViewModel(repository as AuthRepository) as T
}
modelClass.isAssignableFrom(ChatViewModel::class.java) -> {
ChatViewModel(repository as ChatRepository) as T
}
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> {
SettingsViewModel(repository as SettingsRepository) as T
}
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
@@ -1,361 +0,0 @@
package dev.zxq5.chatapp.android.api
import android.content.Context
import android.util.Log
import dev.zxq5.chatapp.android.core.data.TokenStore.getScopeFromToken
import dev.zxq5.chatapp.android.model.LoginRequest
import dev.zxq5.chatapp.android.model.LoginResponse
import dev.zxq5.chatapp.android.model.Message
import dev.zxq5.chatapp.android.model.SendMessage
import dev.zxq5.chatapp.android.model.SignupRequest
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.delete
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.http.encodedPath
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.data.TokenStore.getUserIdFromToken
import dev.zxq5.chatapp.android.core.error.ApiResult
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable
data class QrResponse(val qr_code: String)
@Serializable
data class TOTPSixDigitCode(val code: String)
@Serializable(with = TotpStatusSerializer::class)
enum class TotpStatus {
ENABLED, DISABLED;
val isEnabled: Boolean get() = this == ENABLED
}
object TotpStatusSerializer : KSerializer<TotpStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TotpStatus", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: TotpStatus) = encoder.encodeString(value.name.lowercase())
override fun deserialize(decoder: Decoder): TotpStatus =
TotpStatus.valueOf(decoder.decodeString().uppercase())
}
@Serializable
data class TotpStatusResponse(val status: TotpStatus)
@Serializable
data class PasswordChangeRequest(val old_password: String, val new_password: String)
@Serializable
data class DisplayNameRequest(val display_name: String?)
object ApiClient {
private lateinit var appContext: Context
fun init(context: Context) {
appContext = context.applicationContext
}
fun hasToken(): Boolean {
return TokenStore.get(appContext) != null
}
fun getTokenScope(): String? {
val token = TokenStore.get(appContext) ?: return null
val scope = getScopeFromToken(token)
Log.d("Chat", "Current token scope: $scope")
return scope
}
fun getStoredUserId(): Int? {
return TokenStore.getUserId(appContext)
}
fun is2faEnabledLocal(): Boolean {
return TokenStore.is2faEnabled(appContext)
}
fun set2faEnabledLocal(enabled: Boolean) {
TokenStore.save2faEnabled(appContext, enabled)
}
private var _http: HttpClient? = null
val http: HttpClient
get() = synchronized(this) {
_http ?: createClient().also { _http = it }
}
private fun createClient(): HttpClient {
return HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens {
val token = TokenStore.get(appContext) ?: return@loadTokens null
Log.d("Chat", "Auth plugin loading token: ${getScopeFromToken(token)}")
BearerTokens(token, "")
}
sendWithoutRequest { request ->
val path = request.url.encodedPath
!path.endsWith("/login") && !path.endsWith("/signup")
}
}
}
}
}
private fun resetClient() {
Log.d("Chat", "Resetting HttpClient to refresh tokens")
_http?.close()
_http = null
}
suspend fun login(username: String, password: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(username, password))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
Log.d("Chat", "Login token scope: ${getScopeFromToken(body.token)}")
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid username or password"
403 -> "Account suspended"
429 -> "Too many attempts, please wait"
else -> "Login failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Login network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun signup(username: String, email: String, password: String, token: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/signup") {
contentType(ContentType.Application.Json)
setBody(SignupRequest(username = username, email = email, password = password, access_token = token))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid access token"
else -> "Signup failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
fun logout() {
TokenStore.clear(appContext)
resetClient()
}
suspend fun sendMessage(channelId: Int, text: String) {
val userId = TokenStore.getUserId(appContext) ?: return
http.post("${BASE_URL}/api/chat/$channelId") {
contentType(ContentType.Application.Json)
setBody(SendMessage(
user_id = userId,
text = text,
timestamp = System.currentTimeMillis()
))
}
}
fun messageStream(channelId: Int): Flow<Message> = flow {
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line(256) ?: break
if (line.startsWith("data:")) {
val json = line.removePrefix("data:").trim()
runCatching { Json.decodeFromString<Message>(json) }
.onSuccess { emit(it) }
}
}
}
}
suspend fun getTotpQr(): QrResponse? {
return try {
http.get("${BASE_URL}/api/totp.jpg").body<QrResponse>()
} catch (e: Exception) {
Log.e("Chat", "Error fetching TOTP QR", e)
null
}
}
suspend fun confirmTotp(code: String): Boolean {
return try {
val response = http.post("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
// If confirming TOTP returns a new token (e.g. from partial to full), we should save it
// Assuming confirm might return a LoginResponse if it upgrades the session
runCatching {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
resetClient()
}
set2faEnabledLocal(true)
true
} else {
false
}
} catch (e: Exception) {
Log.e("Chat", "Error confirming TOTP", e)
false
}
}
suspend fun verifyTotpLogin(code: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp/verify") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
val tok = body.token;
Log.d("Chat", "UID ${getUserIdFromToken(tok)}");
Log.d("Chat", "Token ${getScopeFromToken(tok)}");
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
val errorText = try { response.body<String>() } catch (e: Exception) { "Unknown error" }
Log.e("Chat", "TOTP verify failed: ${response.status.value} - $errorText")
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Incorrect code, please try again"
403 -> "Session expired, please log in again"
429 -> "Too many attempts, please wait"
else -> "Verification failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "TOTP verify network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun getTotpStatus(): Boolean {
return try {
val response = http.get("${BASE_URL}/api/totp/status")
if (response.status.isSuccess()) {
val status = response.body<TotpStatus>()
val enabled = status.isEnabled
set2faEnabledLocal(enabled)
enabled
} else {
is2faEnabledLocal()
}
} catch (e: Exception) {
Log.e("Chat", "Error getting TOTP status", e)
is2faEnabledLocal()
}
}
suspend fun disableTotp(): ApiResult<LoginResponse> {
return try {
val response = http.delete("${BASE_URL}/api/totp")
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
set2faEnabledLocal(false)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(response.status.value, "Failed to disable TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error disabling TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/settings/password") {
contentType(ContentType.Application.Json)
setBody(PasswordChangeRequest(old, new))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(
response.status.value,
if (response.status.value == 401) "Old password is wrong" else "Password change failed"
)
}
} catch (e: Exception) {
Log.e("Chat", "Error changing password", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun updateDisplayName(name: String?): Boolean {
return try {
val response = http.post("${BASE_URL}/api/settings/display_name") {
contentType(ContentType.Application.Json)
setBody(DisplayNameRequest(name))
}
response.status.isSuccess()
} catch (e: Exception) {
Log.e("Chat", "Error updating display name", e)
false
}
}
}
@@ -0,0 +1,116 @@
package dev.zxq5.chatapp.android.api
import android.util.Log
import dev.zxq5.chatapp.android.api.model.LoginRequest
import dev.zxq5.chatapp.android.api.model.LoginResponse
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.api.model.SignupRequest
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Client for unauthenticated and pre-authenticated (2FA) requests.
*/
object AuthClient {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
suspend fun login(username: String, password: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(username, password))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<LoginResponse>())
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid username or password"
403 -> "Account suspended"
429 -> "Too many attempts, please wait"
else -> "Login failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Login network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun signup(username: String, email: String, password: String, token: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/signup") {
contentType(ContentType.Application.Json)
setBody(
SignupRequest(
username = username,
email = email,
password = password,
access_token = token
)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<LoginResponse>())
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid access token"
else -> "Signup failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun verifyTotpLogin(partialToken: String, code: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp/verify") {
header(HttpHeaders.Authorization, "Bearer $partialToken")
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<LoginResponse>())
} else {
val errorText = try { response.body<String>() } catch (e: Exception) { "Unknown error" }
Log.e("Chat", "TOTP verify failed: ${response.status.value} - $errorText")
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Incorrect code, please try again"
403 -> "Session expired, please log in again"
429 -> "Too many attempts, please wait"
else -> "Verification failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "TOTP verify network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
}
@@ -0,0 +1,57 @@
package dev.zxq5.chatapp.android.api
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.SendMessage
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json
class ChatClient(private val token: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens { BearerTokens(token, "") }
}
}
}
suspend fun sendMessage(channelId: Int, userId: Int, text: String) {
http.post("${BASE_URL}/api/chat/$channelId") {
contentType(ContentType.Application.Json)
setBody(SendMessage(user_id = userId, text = text, timestamp = System.currentTimeMillis()))
}
}
fun messageStream(channelId: Int): Flow<Message> = flow {
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line(256) ?: break
if (line.startsWith("data:")) {
val json = line.removePrefix("data:").trim()
runCatching { Json.decodeFromString<Message>(json) }
.onSuccess { emit(it) }
}
}
}
}
}
@@ -0,0 +1,171 @@
package dev.zxq5.chatapp.android.api
import android.util.Log
import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest
import dev.zxq5.chatapp.android.api.model.DisplayNameRequest
import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
import dev.zxq5.chatapp.android.api.model.TotpStatus
import dev.zxq5.chatapp.android.api.model.UsernameRequest
import dev.zxq5.chatapp.android.api.model.TotpDeleteRequest
import dev.zxq5.chatapp.android.api.model.PasswordRequest
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.error.ApiResult
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Client for account settings and TOTP management.
*/
class SettingsClient(private val token: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens { BearerTokens(token, "") }
}
}
}
suspend fun getTotpQr(password: String): ApiResult<QrResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp.jpg") {
contentType(ContentType.Application.Json)
setBody(PasswordRequest(password))
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<QrResponse>())
} else {
ApiResult.HttpError(response.status.value, "Failed to get QR code")
}
} catch (e: Exception) {
Log.e("Chat", "Error fetching TOTP QR", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun confirmTotp(code: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(response.status.value, "Failed to confirm TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error confirming TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun getTotpStatus(): ApiResult<TotpStatus> {
return try {
val response = http.get("${BASE_URL}/api/totp/status")
if (response.status.isSuccess()) {
ApiResult.Success(response.body<TotpStatus>())
} else {
ApiResult.HttpError(response.status.value, "Failed to get TOTP status")
}
} catch (e: Exception) {
Log.e("Chat", "Error getting TOTP status", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun disableTotp(password: String, totpCode: String): ApiResult<Unit> {
return try {
val response = http.delete("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TotpDeleteRequest(password, totpCode))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(response.status.value, "Failed to disable TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error disabling TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/settings/password") {
contentType(ContentType.Application.Json)
setBody(PasswordChangeRequest(old, new))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(
response.status.value,
if (response.status.value == 401) "Old password is wrong" else "Password change failed"
)
}
} catch (e: Exception) {
Log.e("Chat", "Error changing password", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun updateDisplayName(name: String?): Boolean {
return try {
val response = http.patch("${BASE_URL}/api/settings/display_name") {
contentType(ContentType.Application.Json)
setBody(DisplayNameRequest(name))
}
response.status.isSuccess()
} catch (e: Exception) {
Log.e("Chat", "Error updating display name", e)
false
}
}
suspend fun updateUsername(username: String): ApiResult<Unit> {
return try {
val response = http.patch("${BASE_URL}/api/settings/username") {
contentType(ContentType.Application.Json)
setBody(UsernameRequest(username))
}
if (response.status.isSuccess()) ApiResult.Success(Unit)
else ApiResult.HttpError(response.status.value, "Failed to update username")
} catch (e: Exception) {
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun deleteAccount(password: String, totpCode: String?): ApiResult<Unit> {
return try {
val response = http.delete("${BASE_URL}/api/settings") {
contentType(ContentType.Application.Json)
setBody(AccountDeleteRequest(password, totpCode))
}
if (response.status.isSuccess()) ApiResult.Success(Unit)
else ApiResult.HttpError(response.status.value, "Failed to delete account")
} catch (e: Exception) {
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
}
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class AccountDeleteRequest(val password: String, val totp_code: String? = null)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class DisplayNameRequest(val display_name: String?)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -6,6 +6,4 @@ import kotlinx.serialization.Serializable
data class LoginRequest( data class LoginRequest(
val username: String, val username: String,
val password: String val password: String
) )
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -8,4 +8,4 @@ data class Message(
val display_name: String, val display_name: String,
val text: String, val text: String,
val timestamp: Long val timestamp: Long
) )
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class PasswordChangeRequest(val old_password: String, val new_password: String)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class PasswordRequest(val password: String)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class QrResponse(val qr_code: String)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -7,4 +7,4 @@ data class SendMessage(
val user_id: Int, val user_id: Int,
val text: String, val text: String,
val timestamp: Long val timestamp: Long
) )
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -11,4 +11,4 @@ data class SignupRequest(
@SerialName("access_token") @SerialName("access_token")
val access_token: String val access_token: String
) )
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class TOTPSixDigitCode(val code: String)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class TotpDeleteRequest(val password: String, val totp_code: String)
@@ -0,0 +1,24 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable(with = TotpStatus.TotpStatusSerializer::class)
enum class TotpStatus {
ENABLED, DISABLED;
val isEnabled: Boolean get() = this == ENABLED
companion object TotpStatusSerializer : KSerializer<TotpStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TotpStatus", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: TotpStatus) = encoder.encodeString(value.name.lowercase())
override fun deserialize(decoder: Decoder): TotpStatus =
TotpStatus.valueOf(decoder.decodeString().uppercase())
}
}
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
@Serializable
data class UsernameRequest(val username: String)
@@ -8,12 +8,14 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import org.json.JSONObject import org.json.JSONObject
// In your ApiClient or a dedicated TokenStore private const val KEY = "auth_token"
object TokenStore { private const val TWOFA_KEY = "twofa_enabled"
private const val KEY = "auth_token"
private const val TWOFA_KEY = "twofa_enabled"
private fun prefs(context: Context): SharedPreferences { // In your ChatClient.kt or a dedicated TokenStore
class TokenStore(appContext: Context) {
private val context = appContext.applicationContext;
private fun prefs(): SharedPreferences {
return EncryptedSharedPreferences.create( return EncryptedSharedPreferences.create(
context, context,
"secure_prefs", "secure_prefs",
@@ -25,54 +27,54 @@ object TokenStore {
) )
} }
fun save(context: Context, token: String) = fun save(token: String) =
prefs(context).edit { putString(KEY, token) } prefs().edit { putString(KEY, token) }
fun get(context: Context): String? = fun get(): String? =
prefs(context).getString(KEY, null) prefs().getString(KEY, null)
fun save2faEnabled(context: Context, enabled: Boolean) = fun save2faEnabled( enabled: Boolean) =
prefs(context).edit { putBoolean(TWOFA_KEY, enabled) } prefs().edit { putBoolean(TWOFA_KEY, enabled) }
fun is2faEnabled(context: Context): Boolean = fun is2faEnabled(): Boolean =
prefs(context).getBoolean(TWOFA_KEY, false) prefs().getBoolean(TWOFA_KEY, false)
fun clear(context: Context) = fun clear() =
prefs(context).edit { remove(KEY).remove(TWOFA_KEY) } prefs().edit { remove(KEY).remove(TWOFA_KEY) }
fun getUserId(context: Context): Int? { fun getUserId(): Int? {
val token = get(context) ?: return null val token = get() ?: return null
return getUserIdFromToken(token) return getUserIdFromToken(token)
} }
}
fun getUserIdFromToken(token: String): Int? { fun getUserIdFromToken(token: String): Int? {
return try { return try {
val payload = token.split(".")[1] val payload = token.split(".")[1]
// base64url needs padding restored // base64url needs padding restored
val padded = payload + "==".take((4 - payload.length % 4) % 4) val padded = payload + "==".take((4 - payload.length % 4) % 4)
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE)) val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
val json = JSONObject(jsonString) val json = JSONObject(jsonString)
// Handle both standard 'sub' and custom 'user_id' // Handle both standard 'sub' and custom 'user_id'
when { when {
json.has("sub") -> json.getInt("sub") json.has("sub") -> json.getInt("sub")
json.has("user_id") -> json.getInt("user_id") json.has("user_id") -> json.getInt("user_id")
else -> null else -> null
}
} catch (e: Exception) {
null
} }
} catch (e: Exception) {
null
} }
}
fun getScopeFromToken(token: String): String? { fun getScopeFromToken(token: String): String? {
return try { return try {
val payload = token.split(".")[1] val payload = token.split(".")[1]
val padded = payload + "==".take((4 - payload.length % 4) % 4) val padded = payload + "==".take((4 - payload.length % 4) % 4)
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE)) val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
val json = JSONObject(jsonString) val json = JSONObject(jsonString)
if (json.has("scope")) json.getString("scope") else null if (json.has("scope")) json.getString("scope") else null
} catch (e: Exception) { } catch (e: Exception) {
null null
}
} }
} }
@@ -1,35 +1,83 @@
package dev.zxq5.chatapp.android.data.repository package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ApiClient import dev.zxq5.chatapp.android.api.AuthClient
import dev.zxq5.chatapp.android.core.data.TokenStore import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.data.getScopeFromToken
import dev.zxq5.chatapp.android.core.error.ApiResult import dev.zxq5.chatapp.android.core.error.ApiResult
// import dev.zxq5.chatapp.android.feature.auth.TokenScope
//class AuthRepository(
// private val apiClient: ApiClient,
// private val tokenStore: TokenStore,
//) {
//
// suspend fun login(username: String, password: String): LoginResult {
//// return when(val result = apiClient.login(username, password)) {
//// is ApiResult.Success -> {
//// tokenStore.save(context = context, result.data.token);
//// }
//// }
// }
//}
class AuthRepository(
private val tokenStore: TokenStore,
) {
suspend fun signup(username: String, email: String, password: String, accessToken: String): SignupResult {
return when(val result = AuthClient.signup(username, email, password, accessToken)) {
is ApiResult.HttpError -> SignupResult.Error(result.message)
is ApiResult.NetworkError -> SignupResult.Error("Network error: ${result.message}")
is ApiResult.Success -> {
tokenStore.save(result.data.token)
SignupResult.Success
}
}
}
suspend fun verifyTotpLogin(code: String): LoginResult {
val partialToken = tokenStore.get() ?: return LoginResult.Error("Session expired")
return when(val result = AuthClient.verifyTotpLogin(partialToken, code)) {
is ApiResult.HttpError -> LoginResult.TotpError(result.message)
is ApiResult.NetworkError -> LoginResult.TotpError("Network error: ${result.message}")
is ApiResult.Success -> {
tokenStore.save(result.data.token)
LoginResult.Success
}
}
}
suspend fun login(username: String, password: String): LoginResult {
return when(val result = AuthClient.login(username, password)) {
is ApiResult.HttpError -> LoginResult.Error(result.message)
is ApiResult.NetworkError -> LoginResult.Error("Network error: ${result.message}")
is ApiResult.Success -> {
tokenStore.save(result.data.token)
when (val scope = getScopeFromToken(result.data.token)) {
TokenScope.TOTP_PENDING -> LoginResult.TotpRequired
TokenScope.FULL -> LoginResult.Success
else -> LoginResult.Error("Unexpected token scope: $scope")
}
}
}
}
fun logout() {
tokenStore.clear()
}
fun getUserId() = tokenStore.getUserId()
fun getAuthState(): AuthState {
val token = tokenStore.get() ?: return AuthState.Unauthenticated
return when (getScopeFromToken(token)) {
TokenScope.FULL -> AuthState.Authenticated
TokenScope.TOTP_PENDING -> AuthState.AwaitingTotp
else -> AuthState.Unauthenticated
}
}
}
sealed class SignupResult {
object Success : SignupResult()
data class Error(val message: String) : SignupResult()
}
sealed class LoginResult { sealed class LoginResult {
object Success : LoginResult() object Success : LoginResult()
object TotpRequired : LoginResult() // step 1 outcome → go to totp screen object TotpRequired : LoginResult()
data class TotpError(val message: String) : LoginResult() // step 2 failure → stay on totp screen, show error data class TotpError(val message: String) : LoginResult()
data class Error(val message: String) : LoginResult() // general failure → show on login form data class Error(val message: String) : LoginResult()
} }
sealed class AuthState { sealed class AuthState {
object Authenticated : AuthState() object Authenticated : AuthState()
object AwaitingTotp : AuthState() object AwaitingTotp : AuthState()
object Unauthenticated : AuthState() object Unauthenticated : AuthState()
} }
@@ -0,0 +1,38 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ChatClient
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.api.model.Message
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
class ChatRepository(private val tokenStore: TokenStore) {
private var _chatClient: ChatClient? = null
private var _lastToken: String? = null
private fun getChatClient(): ChatClient? {
val token = tokenStore.get() ?: return null
if (_chatClient == null || token != _lastToken) {
_chatClient = ChatClient(token)
_lastToken = token
}
return _chatClient
}
fun resetClient() {
_chatClient = null
_lastToken = null
}
fun getUserId() = tokenStore.getUserId()
suspend fun sendMessage(channelId: Int, text: String) {
val userId = tokenStore.getUserId() ?: return
getChatClient()?.sendMessage(channelId, userId, text)
}
fun messageStream(channelId: Int): Flow<Message> {
return getChatClient()?.messageStream(channelId) ?: emptyFlow()
}
}
@@ -0,0 +1,67 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.SettingsClient
import dev.zxq5.chatapp.android.api.model.TotpStatus
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.error.ApiResult
class SettingsRepository(private val tokenStore: TokenStore) {
private var _settingsClient: SettingsClient? = null
private var _lastToken: String? = null
private fun getSettingsClient(): SettingsClient? {
val token = tokenStore.get() ?: return null
if (_settingsClient == null || token != _lastToken) {
_settingsClient = SettingsClient(token)
_lastToken = token
}
return _settingsClient
}
fun resetClient() {
_settingsClient = null
_lastToken = null
}
suspend fun getTotpQr(password: String): ApiResult<QrResponse?> {
val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return settingsClient.getTotpQr(password)
}
suspend fun confirmTotp(code: String): ApiResult<Unit> {
val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return client.confirmTotp(code)
}
suspend fun getTotpStatus(): ApiResult<TotpStatus> {
return getSettingsClient()?.getTotpStatus() ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun disableTotp(password: String, totpCode: String): ApiResult<Unit> {
val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return client.disableTotp(password, totpCode)
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return getSettingsClient()?.changePassword(old, new) ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun updateDisplayName(name: String?): Boolean {
return getSettingsClient()?.updateDisplayName(name) ?: false
}
suspend fun updateUsername(username: String): ApiResult<Unit> {
return getSettingsClient()?.updateUsername(username) ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun deleteAccount(password: String, totpCode: String?): ApiResult<Unit> {
return getSettingsClient()?.deleteAccount(password, totpCode) ?: ApiResult.NetworkError("Not authenticated")
}
fun logout() {
tokenStore.clear()
resetClient()
}
}
@@ -0,0 +1,5 @@
package dev.zxq5.chatapp.android.feature.auth
enum class AuthMode {
LOGIN, SIGNUP
}
@@ -0,0 +1,39 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.zxq5.chatapp.android.model.LoginState
@Composable
fun AuthScreen(viewModel: AuthViewModel) {
val loginState by viewModel.loginState.collectAsState()
val authMode by viewModel.authMode.collectAsState()
val totpError by viewModel.totpError.collectAsState()
if (loginState is LoginState.TwoFactorRequired ||
(loginState is LoginState.Loading && totpError != null)) {
TwoFactorLoginScreen(
onVerify = { viewModel.verifyTotpLogin(it) },
onBack = {
viewModel.clearTotpError()
viewModel.setAuthMode(AuthMode.LOGIN)
},
isLoading = loginState is LoginState.Loading,
error = totpError
)
return
}
if (authMode == AuthMode.SIGNUP) {
SignupScreen(
viewModel = viewModel,
onSwitchToLogin = { viewModel.setAuthMode(AuthMode.LOGIN) }
)
} else {
LoginScreen(
viewModel = viewModel,
onSwitchToSignup = { viewModel.setAuthMode(AuthMode.SIGNUP) }
)
}
}
@@ -0,0 +1,107 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.LoginResult
import dev.zxq5.chatapp.android.data.repository.SignupResult
import dev.zxq5.chatapp.android.data.repository.AuthState
import dev.zxq5.chatapp.android.model.LoginState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AuthViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _loginState = MutableStateFlow<LoginState>(LoginState.Idle)
val loginState: StateFlow<LoginState> = _loginState
private val _authMode = MutableStateFlow(AuthMode.LOGIN)
val authMode: StateFlow<AuthMode> = _authMode
private val _authState = MutableStateFlow(authRepository.getAuthState())
val authState: StateFlow<AuthState> = _authState
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
fun setAuthMode(mode: AuthMode) {
_authMode.value = mode
if (_loginState.value is LoginState.Error) {
_loginState.value = LoginState.Idle
}
}
fun signup(username: String, email: String, password: String, accessToken: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
when (val result = authRepository.signup(username, email, password, accessToken)) {
is SignupResult.Success -> {
updateAuthState()
_loginState.value = LoginState.Success
}
is SignupResult.Error -> {
_loginState.value = LoginState.Error(result.message)
}
}
}
}
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
when (val result = authRepository.login(username, password)) {
is LoginResult.Success -> {
updateAuthState()
_loginState.value = LoginState.Success
}
is LoginResult.TotpRequired -> {
updateAuthState()
_loginState.value = LoginState.TwoFactorRequired
}
is LoginResult.Error -> {
_loginState.value = LoginState.Error(result.message)
}
is LoginResult.TotpError -> {
_loginState.value = LoginState.Error(result.message)
}
}
}
}
fun verifyTotpLogin(code: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
when (val result = authRepository.verifyTotpLogin(code)) {
is LoginResult.Success -> {
updateAuthState()
_loginState.value = LoginState.Success
}
is LoginResult.TotpError -> {
_totpError.value = result.message
_loginState.value = LoginState.TwoFactorRequired
}
is LoginResult.Error -> {
_loginState.value = LoginState.Error(result.message)
}
is LoginResult.TotpRequired -> {
_loginState.value = LoginState.TwoFactorRequired
}
}
}
}
fun logout() {
authRepository.logout()
updateAuthState()
_loginState.value = LoginState.Idle
}
private fun updateAuthState() {
_authState.value = authRepository.getAuthState()
}
fun clearTotpError() {
_totpError.value = null
}
}
@@ -0,0 +1,133 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.foundation.background
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.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onSwitchToSignup: () -> Unit
) {
val loginState by viewModel.loginState.collectAsState()
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "welcome back",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (username.isBlank() || password.isBlank()) {
localError = "fill all fields"
return@Button
}
viewModel.login(username, password)
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("login", style = MaterialTheme.typography.bodyLarge)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onSwitchToSignup) {
Text(
"no account? sign up",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,160 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.foundation.background
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.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField
@Composable
fun SignupScreen(
viewModel: AuthViewModel,
onSwitchToLogin: () -> Unit
) {
val loginState by viewModel.loginState.collectAsState()
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var accessToken by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "create account",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
TextField(
value = email,
onValueChange = { email = it },
label = "email"
)
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
TextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "confirm password",
isPassword = true
)
TextField(
value = accessToken,
onValueChange = { accessToken = it },
label = "access token"
)
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (username.isBlank() || email.isBlank() || password.isBlank() || accessToken.isBlank()) {
localError = "fill all fields"
return@Button
}
if (password != confirmPassword) {
localError = "passwords mismatch"
return@Button
}
viewModel.signup(username, email, password, accessToken)
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("sign up", style = MaterialTheme.typography.bodyLarge)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onSwitchToLogin) {
Text(
"have account? login",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.feature.auth
object TokenScope {
const val FULL = "full"
const val TOTP_PENDING = "totp_pending"
}
@@ -0,0 +1,138 @@
package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TwoFactorLoginScreen(
onVerify: (String) -> Unit,
onBack: () -> Unit,
isLoading: Boolean,
error: String?
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"security verification",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(80.dp))
Text(
"two-factor auth",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"enter the 6-digit code from your app",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp, bottom = 48.dp)
)
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(200.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = TextAlign.Center,
letterSpacing = 8.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f)
),
singleLine = true
)
if (error != null) {
Text(
text = error.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Spacer(Modifier.height(12.dp))
}
Spacer(Modifier.height(36.dp))
Button(
onClick = { if (code.length == 6) onVerify(code) },
enabled = code.length == 6 && !isLoading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("verify", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
@@ -0,0 +1,93 @@
package dev.zxq5.chatapp.android.feature.chat
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.api.model.Message
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
private val _channelId = MutableStateFlow<Int?>(null)
val channelId: StateFlow<Int?> = _channelId
private val _currentScreen = MutableStateFlow(Screen.CHAT)
val currentScreen: StateFlow<Screen> = _currentScreen
private val _currentUserId = MutableStateFlow<Int?>(null)
val currentUserId: StateFlow<Int?> = _currentUserId
private var streamJob: Job? = null
init {
_currentUserId.value = chatRepository.getUserId()
observeChannel()
}
private fun observeChannel() {
viewModelScope.launch {
_channelId.collect { id ->
streamJob?.cancel()
_messages.value = emptyList()
if (id != null) {
streamJob = launch {
chatRepository.messageStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
}
.collect { message ->
_messages.update { it + message }
}
}
}
}
}
}
fun navigateTo(screen: Screen) {
_currentScreen.value = screen
}
fun switchChannel(id: Int?) {
_channelId.value = id
if (id != null) {
// Refresh user ID just in case it wasn't available at init
_currentUserId.value = chatRepository.getUserId()
chatRepository.resetClient()
}
}
fun sendMessage(text: String) {
val currentId = _channelId.value ?: return
viewModelScope.launch {
runCatching {
chatRepository.sendMessage(
channelId = currentId,
text = text
)
}.onFailure { e ->
Log.e("Chat", "Send message error", e)
}
}
}
fun clearChat() {
_messages.value = emptyList()
_channelId.value = null
_currentUserId.value = null
streamJob?.cancel()
chatRepository.resetClient()
}
}
@@ -0,0 +1,5 @@
package dev.zxq5.chatapp.android.feature.chat
enum class Screen {
CHAT, SETTINGS
}
@@ -1,5 +1,6 @@
package dev.zxq5.chatapp.android.ui.components package dev.zxq5.chatapp.android.feature.chat
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -26,12 +27,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -45,6 +41,9 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -58,21 +57,25 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.ChatViewModel import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.model.MainScreen
import dev.zxq5.chatapp.android.model.Message
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@Composable @Composable
fun ChatScreen(viewModel: ChatViewModel) { fun ChatScreen(
viewModel: ChatViewModel,
onNavigateToSettings: () -> Unit,
onLogout: () -> Unit // Note: logout is now part of SettingsScreen in this UI, but we'll keep the param for now
) {
val selectedChannelId by viewModel.channelId.collectAsState() val selectedChannelId by viewModel.channelId.collectAsState()
if (selectedChannelId == null) { if (selectedChannelId == null) {
ChannelListScreen( ChannelListScreen(
viewModel = viewModel, viewModel = viewModel,
onChannelSelect = { viewModel.switchChannel(it) } onChannelSelect = { viewModel.switchChannel(it) },
onNavigateToSettings = onNavigateToSettings
) )
} else { } else {
MessageScreen( MessageScreen(
@@ -85,7 +88,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) { fun ChannelListScreen(
viewModel: ChatViewModel,
onChannelSelect: (Int) -> Unit,
onNavigateToSettings: () -> Unit
) {
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
topBar = { topBar = {
@@ -153,7 +160,7 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit)
} }
} }
}, },
bottomBar = { BottomDock(viewModel) } bottomBar = { BottomDock(viewModel, onNavigateToSettings) }
) { padding -> ) { padding ->
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) { LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
items(10) { i -> items(10) { i ->
@@ -170,7 +177,7 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit)
} }
@Composable @Composable
fun BottomDock(viewModel: ChatViewModel) { fun BottomDock(viewModel: ChatViewModel, onNavigateToSettings: () -> Unit) {
val currentScreen by viewModel.currentScreen.collectAsState() val currentScreen by viewModel.currentScreen.collectAsState()
NavigationBar( NavigationBar(
@@ -179,8 +186,8 @@ fun BottomDock(viewModel: ChatViewModel) {
modifier = Modifier.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp)) modifier = Modifier.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp))
) { ) {
NavigationBarItem( NavigationBarItem(
selected = currentScreen == MainScreen.CHAT, selected = currentScreen == Screen.CHAT,
onClick = { viewModel.navigateTo(MainScreen.CHAT) }, onClick = { viewModel.navigateTo(Screen.CHAT) },
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") }, icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
label = { Text("chat", style = MaterialTheme.typography.labelSmall) }, label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
@@ -190,8 +197,8 @@ fun BottomDock(viewModel: ChatViewModel) {
) )
) )
NavigationBarItem( NavigationBarItem(
selected = currentScreen == MainScreen.SETTINGS, selected = currentScreen == Screen.SETTINGS,
onClick = { viewModel.navigateTo(MainScreen.SETTINGS) }, onClick = onNavigateToSettings,
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") }, icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
label = { Text("settings", style = MaterialTheme.typography.labelSmall) }, label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
@@ -409,7 +416,7 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) { Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
if (!isMe) { if (!isMe) {
Text( Text(
message.display_name.lowercase(), message.display_name?.lowercase() ?: "unknown",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 2.dp) modifier = Modifier.padding(bottom = 2.dp)
@@ -431,5 +438,5 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
} }
} }
private fun border(width: androidx.compose.ui.unit.Dp, color: Color) = private fun border(width: Dp, color: Color) =
androidx.compose.foundation.BorderStroke(width, color) BorderStroke(width, color)
@@ -0,0 +1,157 @@
package dev.zxq5.chatapp.android.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class SettingsViewModel(private val settingsRepository: SettingsRepository) : ViewModel() {
private val _is2faEnabled = MutableStateFlow(false)
val is2faEnabled: StateFlow<Boolean> = _is2faEnabled
private val _totpQr = MutableStateFlow<QrResponse?>(null)
val totpQr: StateFlow<QrResponse?> = _totpQr
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
private val _settingsError = MutableStateFlow<String?>(null)
val settingsError: StateFlow<String?> = _settingsError
private val _isSuccessState = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val isSuccessState: StateFlow<Map<String, Boolean>> = _isSuccessState
fun clearMessages() {
_settingsError.value = null
_totpError.value = null
}
private fun triggerSuccess(key: String) {
viewModelScope.launch {
_isSuccessState.value = _isSuccessState.value + (key to true)
delay(5000)
_isSuccessState.value = _isSuccessState.value + (key to false)
}
}
fun fetchTotpStatus() {
viewModelScope.launch {
when (val result = settingsRepository.getTotpStatus()) {
is ApiResult.Success -> _is2faEnabled.value = result.data.isEnabled
else -> {}
}
}
}
fun fetchTotpQr(password: String) {
viewModelScope.launch {
_totpError.value = null
when (val result = settingsRepository.getTotpQr(password)) {
is ApiResult.Success -> {
_totpQr.value = result.data
}
is ApiResult.HttpError -> {
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
_totpError.value = "Network error: ${result.message}"
}
}
}
}
fun confirmTotp(code: String) {
viewModelScope.launch {
_totpError.value = null
when (val result = settingsRepository.confirmTotp(code)) {
is ApiResult.Success -> {
_is2faEnabled.value = true
_totpQr.value = null
triggerSuccess("2fa")
}
is ApiResult.HttpError -> {
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
_totpError.value = "Network error: ${result.message}"
}
}
}
}
fun disableTotp(password: String, totpCode: String) {
viewModelScope.launch {
_totpError.value = null
when (val result = settingsRepository.disableTotp(password, totpCode)) {
is ApiResult.Success -> {
_is2faEnabled.value = false
triggerSuccess("2fa")
}
is ApiResult.HttpError -> _totpError.value = result.message
is ApiResult.NetworkError -> _totpError.value = "Network error: ${result.message}"
}
}
}
fun changePassword(old: String, new: String) {
viewModelScope.launch {
clearMessages()
when (val result = settingsRepository.changePassword(old, new)) {
is ApiResult.Success -> {
triggerSuccess("password")
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
}
}
}
fun updateDisplayName(name: String?) {
viewModelScope.launch {
clearMessages()
if (settingsRepository.updateDisplayName(name)) {
triggerSuccess("display_name")
} else {
_settingsError.value = "Failed to update display name"
}
}
}
fun updateUsername(username: String) {
viewModelScope.launch {
clearMessages()
when (val result = settingsRepository.updateUsername(username)) {
is ApiResult.Success -> {
triggerSuccess("username")
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
}
}
}
fun deleteAccount(password: String, totpCode: String?, onLogout: () -> Unit) {
viewModelScope.launch {
clearMessages()
when (val result = settingsRepository.deleteAccount(password, totpCode)) {
is ApiResult.Success -> {
_isSuccessState.value = _isSuccessState.value + ("delete" to true)
delay(3000)
onLogout()
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
}
}
}
fun logout() {
settingsRepository.logout()
}
}
@@ -0,0 +1,534 @@
package dev.zxq5.chatapp.android.feature.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.util.Base64
import android.graphics.BitmapFactory
import androidx.compose.ui.text.style.TextAlign
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
onLogout: () -> Unit
) {
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
val totpQr by viewModel.totpQr.collectAsState()
val settingsError by viewModel.settingsError.collectAsState()
val isSuccessState by viewModel.isSuccessState.collectAsState()
val totpError by viewModel.totpError.collectAsState()
LaunchedEffect(Unit) {
viewModel.clearMessages()
viewModel.fetchTotpStatus()
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Text(
"settings",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
SettingsSection(title = "profile") {
var displayName by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
SettingsField(
label = "display name",
value = displayName,
onValueChange = { displayName = it },
buttonLabel = "update",
isSuccess = isSuccessState["display_name"] == true,
onClick = { viewModel.updateDisplayName(displayName.ifBlank { null }) }
)
Spacer(Modifier.height(16.dp))
SettingsField(
label = "username",
value = username,
onValueChange = { username = it },
buttonLabel = "update",
isSuccess = isSuccessState["username"] == true,
onClick = { if (username.isNotBlank()) viewModel.updateUsername(username) }
)
}
SettingsSection(title = "security") {
var oldPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
Text("change password", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = oldPassword,
onValueChange = { oldPassword = it },
label = { Text("old password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("new password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(12.dp))
SuccessButton(
onClick = {
viewModel.changePassword(oldPassword, newPassword)
oldPassword = ""
newPassword = ""
},
label = "update password",
isSuccess = isSuccessState["password"] == true,
enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty(),
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
var show2faSetup by remember { mutableStateOf(false) }
var setupPassword by remember { mutableStateOf("") }
var show2faDisable by remember { mutableStateOf(false) }
var disablePassword by remember { mutableStateOf("") }
var disableCode by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("two-factor authentication", style = MaterialTheme.typography.bodyLarge)
Text(
if (is2faEnabled) "enabled" else "disabled",
style = MaterialTheme.typography.labelSmall,
color = if (is2faEnabled) Color.Green.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
if (!is2faEnabled) {
Button(
onClick = {
show2faSetup = !show2faSetup
if (!show2faSetup) setupPassword = ""
},
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), contentColor = MaterialTheme.colorScheme.onSurface)
) {
Text(if (show2faSetup) "cancel" else "setup", style = MaterialTheme.typography.labelSmall)
}
} else {
SuccessButton(
onClick = {
show2faDisable = !show2faDisable
if (!show2faDisable) {
disablePassword = ""
disableCode = ""
}
},
label = if (show2faDisable) "cancel" else "disable",
isSuccess = isSuccessState["2fa"] == true,
baseColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
contentColor = Color.Red,
successColor = Color.Red
)
}
}
if (show2faSetup && !is2faEnabled) {
Spacer(Modifier.height(16.dp))
if (totpQr == null) {
OutlinedTextField(
value = setupPassword,
onValueChange = { setupPassword = it },
label = { Text("confirm password to setup") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { viewModel.fetchTotpQr(setupPassword) },
enabled = setupPassword.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text("get qr code")
}
} else {
TwoFactorSetup(
qrCodeBase64 = totpQr?.qr_code,
error = totpError,
onConfirm = { viewModel.confirmTotp(it) }
)
}
}
if (show2faDisable && is2faEnabled) {
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = disablePassword,
onValueChange = { disablePassword = it },
label = { Text("password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = disableCode,
onValueChange = { if (it.length <= 6) disableCode = it },
label = { Text("2fa code") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(Modifier.height(8.dp))
SuccessButton(
onClick = { viewModel.disableTotp(disablePassword, disableCode) },
label = "confirm disable",
isSuccess = isSuccessState["2fa"] == true,
baseColor = Color.Red,
enabled = disablePassword.isNotBlank() && disableCode.length == 6,
modifier = Modifier.fillMaxWidth()
)
}
if (totpError != null && !show2faSetup && !show2faDisable) {
Text(totpError!!.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp))
}
}
SettingsSection(title = "danger zone", color = Color.Red.copy(alpha = 0.7f)) {
var deletePassword by remember { mutableStateOf("") }
var deleteTotp by remember { mutableStateOf("") }
var showDeleteConfirm by remember { mutableStateOf(false) }
if (!showDeleteConfirm) {
Button(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f), contentColor = Color.Red)
) {
Text("delete account")
}
} else {
Text("confirm account deletion", color = Color.Red, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = deletePassword,
onValueChange = { deletePassword = it },
label = { Text("password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
if (is2faEnabled) {
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = deleteTotp,
onValueChange = { if (it.length <= 6) deleteTotp = it },
label = { Text("2fa code") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { showDeleteConfirm = false },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text("cancel")
}
SuccessButton(
onClick = { viewModel.deleteAccount(
deletePassword, deleteTotp.ifBlank { null },
onLogout
) },
label = "delete forever",
isSuccess = isSuccessState["delete"] == true,
baseColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red,
successColor = Color.Red,
modifier = Modifier.weight(1f),
enabled = deletePassword.isNotEmpty() && (!is2faEnabled || deleteTotp.length == 6)
)
}
}
}
SettingsSection(title = "session") {
Spacer(Modifier.height(16.dp))
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
) {
Text("logout")
}
}
if (settingsError != null) {
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp))
}
}
}
}
@Composable
fun SettingsSection(
title: String,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
content: @Composable () -> Unit
) {
var expanded by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(title.lowercase(), style = MaterialTheme.typography.labelSmall, color = color)
Icon(
if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = color,
modifier = Modifier.size(16.dp)
)
}
AnimatedVisibility(visible = expanded) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).padding(bottom = 8.dp)) {
content()
}
}
}
}
@Composable
fun SettingsField(
label: String,
value: String,
onValueChange: (String) -> Unit,
buttonLabel: String,
isSuccess: Boolean,
onClick: () -> Unit
) {
Column {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isSuccess) Color.Green else MaterialTheme.colorScheme.primary
)
)
Spacer(Modifier.height(8.dp))
SuccessButton(
onClick = onClick,
label = buttonLabel,
isSuccess = isSuccess,
enabled = value.isNotBlank(),
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
fun SuccessButton(
onClick: () -> Unit,
label: String,
isSuccess: Boolean,
modifier: Modifier = Modifier,
enabled: Boolean = true,
baseColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
successColor: Color = Color.Green.copy(alpha = 0.8f)
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSuccess) successColor else baseColor,
animationSpec = tween(500)
)
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = backgroundColor,
contentColor = if (isSuccess) Color.White else contentColor
)
) {
Text(if (isSuccess) "success" else label)
}
}
@Composable
fun TwoFactorSetup(
qrCodeBase64: String?,
error: String?,
onConfirm: (String) -> Unit
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (qrCodeBase64 != null) {
val bitmap = remember(qrCodeBase64) {
val base64Data = qrCodeBase64.substringAfter("base64,")
val decodedString = Base64.decode(base64Data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.size(180.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.padding(8.dp)
)
}
} else {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
}
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000") },
modifier = Modifier.width(150.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(textAlign = TextAlign.Center),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp)
)
if (error != null) {
Text(error.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp))
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { if (code.length == 6) onConfirm(code) },
enabled = code.length == 6,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Text("confirm code")
}
}
}
@@ -1,325 +0,0 @@
package dev.zxq5.chatapp.android.model
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.ApiClient
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.api.QrResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
enum class AuthMode {
LOGIN, SIGNUP
}
enum class MainScreen {
CHAT, SETTINGS
}
class ChatViewModel : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
private val _channelId = MutableStateFlow<Int?>(null)
val channelId: StateFlow<Int?> = _channelId
private val _currentUserId = MutableStateFlow<Int?>(null)
val currentUserId: StateFlow<Int?> = _currentUserId
private val _currentScreen = MutableStateFlow(MainScreen.CHAT)
val currentScreen: StateFlow<MainScreen> = _currentScreen
val loginState = MutableStateFlow<LoginState>(LoginState.Idle)
// Tracks whether the user is viewing the Login or Signup screen
val authMode = MutableStateFlow(AuthMode.LOGIN)
// 2FA state
private val _totpQr = MutableStateFlow<QrResponse?>(null)
val totpQr: StateFlow<QrResponse?> = _totpQr
private val _is2faEnabled = MutableStateFlow(false)
val is2faEnabled: StateFlow<Boolean> = _is2faEnabled
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
// Settings state
private val _settingsError = MutableStateFlow<String?>(null)
val settingsError: StateFlow<String?> = _settingsError
private val _settingsSuccess = MutableStateFlow<String?>(null)
val settingsSuccess: StateFlow<String?> = _settingsSuccess
fun clearSettingsMessages() {
_settingsError.value = null
_settingsSuccess.value = null
}
fun clearTotpError() {
_totpError.value = null
}
private var streamJob: Job? = null
init {
initAuth(ApiClient.hasToken())
}
fun initAuth(hasToken: Boolean) {
if (hasToken) {
val scope = ApiClient.getTokenScope()
if (scope == TokenScope.TOTP_PENDING) {
loginState.value = LoginState.TwoFactorRequired
} else if (scope == TokenScope.FULL) {
loginState.value = LoginState.Success
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
fetchTotpStatus()
observeChannel()
} else {
loginState.value = LoginState.Error("Unknown token scope: $scope")
}
} else {
loginState.value = LoginState.Idle
}
}
private fun observeChannel() {
// restart stream whenever channel changes
viewModelScope.launch {
_channelId.filterNotNull().collect { id ->
streamJob?.cancel()
_messages.value = emptyList()
streamJob = launch {
ApiClient.messageStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
}
.collect { message ->
_messages.update { it + message }
}
}
}
}
}
fun navigateTo(screen: MainScreen) {
_currentScreen.value = screen
if (screen == MainScreen.SETTINGS) {
fetchTotpStatus()
}
}
fun switchChannel(id: Int?) {
_channelId.value = id
}
fun setAuthMode(mode: AuthMode) {
authMode.value = mode
// Clear errors when switching modes
if (loginState.value is LoginState.Error || loginState.value is LoginState.TwoFactorRequired) {
loginState.value = LoginState.Idle
}
}
fun sendMessage(text: String) {
val currentId = _channelId.value ?: return
viewModelScope.launch {
runCatching {
ApiClient.sendMessage(
channelId = currentId,
text = text
)
}.onFailure { e ->
Log.e("Chat", "Send message error", e)
}
}
}
fun login(username: String, password: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
when (val result = ApiClient.login(username, password)) {
is ApiResult.Success -> {
when (val scope = ApiClient.getTokenScope()) {
TokenScope.FULL -> {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
fetchTotpStatus()
observeChannel()
}
TokenScope.TOTP_PENDING -> {
loginState.value = LoginState.TwoFactorRequired
}
else -> {
loginState.value = LoginState.Error("Unknown token scope: $scope")
}
}
}
is ApiResult.HttpError -> {
loginState.value = LoginState.Error(result.message)
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
}
}
}
}
fun verifyLogin2fa(code: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
when (val result = ApiClient.verifyTotpLogin(code)) {
is ApiResult.Success -> {
val scope = ApiClient.getTokenScope()
if (scope == TokenScope.FULL) {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
fetchTotpStatus()
observeChannel()
} else {
// token came back but scope is wrong — shouldn't happen
loginState.value = LoginState.Error("Unexpected token scope after verification")
}
}
is ApiResult.HttpError -> {
// stay on TOTP screen but show the error
loginState.value = LoginState.TwoFactorRequired
// use a separate error signal so we don't lose the TOTP state
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.TwoFactorRequired
_totpError.value = "Could not reach server: ${result.message}"
}
}
}
}
fun signup(username: String, email: String, password: String, accessToken: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
try {
when (val result = ApiClient.signup(username, email, password, accessToken)) {
is ApiResult.Success -> {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
observeChannel()
}
is ApiResult.HttpError -> {
loginState.value = LoginState.Error(result.message)
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
}
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
loginState.value = LoginState.Error("Signup failed: ${e.localizedMessage}")
}
}
}
fun logout() {
viewModelScope.launch {
ApiClient.logout()
_currentUserId.value = null
_is2faEnabled.value = false
loginState.value = LoginState.Idle
_messages.value = emptyList()
_channelId.value = null
_currentScreen.value = MainScreen.CHAT
streamJob?.cancel()
}
}
fun fetchTotpQr() {
viewModelScope.launch {
_totpQr.value = ApiClient.getTotpQr()
}
}
fun confirmTotp(code: String) {
viewModelScope.launch {
val success = ApiClient.confirmTotp(code)
if (success) {
_is2faEnabled.value = true
ApiClient.set2faEnabledLocal(true)
_totpQr.value = null
} else {
_totpError.value = "Invalid verification code"
}
}
}
fun fetchTotpStatus() {
viewModelScope.launch {
_is2faEnabled.value = ApiClient.getTotpStatus()
}
}
fun disableTotp() {
viewModelScope.launch {
when (val result = ApiClient.disableTotp()) {
is ApiResult.Success -> {
_is2faEnabled.value = false
_settingsSuccess.value = "2FA disabled successfully"
}
is ApiResult.HttpError -> {
_settingsError.value = result.message
}
is ApiResult.NetworkError -> {
_settingsError.value = "Network error: ${result.message}"
}
}
}
}
fun changePassword(old: String, new: String) {
viewModelScope.launch {
_settingsError.value = null
_settingsSuccess.value = null
when (val result = ApiClient.changePassword(old, new)) {
is ApiResult.Success -> {
_settingsSuccess.value = "Password updated"
}
is ApiResult.HttpError -> {
_settingsError.value = result.message
}
is ApiResult.NetworkError -> {
_settingsError.value = "Network error: ${result.message}"
}
}
}
}
fun updateDisplayName(name: String?) {
viewModelScope.launch {
_settingsError.value = null
_settingsSuccess.value = null
val success = ApiClient.updateDisplayName(name)
if (success) {
_settingsSuccess.value = "Display name updated"
} else {
_settingsError.value = "Failed to update display name"
}
}
}
}
object TokenScope {
const val FULL = "full";
const val TOTP_PENDING = "totp_pending";
}
@@ -1,318 +0,0 @@
package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.zxq5.chatapp.android.model.AuthMode
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.LoginState
@Composable
fun AuthScreen(viewModel: ChatViewModel, onSuccess: () -> Unit) {
val loginState by viewModel.loginState.collectAsState()
val authMode by viewModel.authMode.collectAsState()
val totpError by viewModel.totpError.collectAsState()
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var accessToken by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(loginState) {
if (loginState is LoginState.Success) onSuccess()
}
LaunchedEffect(authMode) {
localError = null
}
if (loginState is LoginState.TwoFactorRequired ||
(loginState is LoginState.Loading && totpError != null)) {
TwoFactorLoginScreen(
onVerify = { code -> viewModel.verifyLogin2fa(code) },
onBack = {
viewModel.clearTotpError()
viewModel.setAuthMode(AuthMode.LOGIN)
},
isLoading = loginState is LoginState.Loading,
error = totpError
)
return
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = if (authMode == AuthMode.LOGIN) "welcome back" else "create account",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
if (authMode == AuthMode.SIGNUP) {
TextField(
value = email,
onValueChange = { email = it },
label = "email"
)
}
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
if (authMode == AuthMode.SIGNUP) {
TextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "confirm password",
isPassword = true
)
TextField(
value = accessToken,
onValueChange = { accessToken = it },
label = "access token"
)
}
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (authMode == AuthMode.LOGIN) {
if (username.isBlank() || password.isBlank()) {
localError = "fill all fields"
return@Button
}
viewModel.login(username, password)
} else {
if (username.isBlank() || email.isBlank() || password.isBlank() || accessToken.isBlank()) {
localError = "fill all fields"
return@Button
}
if (password != confirmPassword) {
localError = "passwords mismatch"
return@Button
}
viewModel.signup(username, email, password, accessToken)
}
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text(
if (authMode == AuthMode.LOGIN) "login" else "sign up",
style = MaterialTheme.typography.bodyLarge
)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(
onClick = {
viewModel.setAuthMode(if (authMode == AuthMode.LOGIN) AuthMode.SIGNUP else AuthMode.LOGIN)
}
) {
Text(
if (authMode == AuthMode.LOGIN) "no account? sign up"
else "have account? login",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
fun TwoFactorLoginScreen(
onVerify: (String) -> Unit,
onBack: () -> Unit,
isLoading: Boolean,
error: String?
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"security verification",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(80.dp))
Text(
"two-factor auth",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"enter the 6-digit code from your app",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp, bottom = 48.dp)
)
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(200.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = TextAlign.Center,
letterSpacing = 8.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f)
),
singleLine = true
)
if (error != null) {
Text(
text = error.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Spacer(Modifier.height(12.dp)) // keep layout stable when no error
}
Spacer(Modifier.height(36.dp))
Button(
onClick = { if (code.length == 6) onVerify(code) },
enabled = code.length == 6 && !isLoading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("verify", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
@@ -1,368 +0,0 @@
package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.MainScreen
import android.util.Base64
import android.graphics.BitmapFactory
import androidx.compose.runtime.LaunchedEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: ChatViewModel) {
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
val totpQr by viewModel.totpQr.collectAsState()
val settingsError by viewModel.settingsError.collectAsState()
val settingsSuccess by viewModel.settingsSuccess.collectAsState()
var show2faSetup by remember { mutableStateOf(false) }
var displayName by remember { mutableStateOf("") }
var oldPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.clearSettingsMessages()
viewModel.fetchTotpStatus()
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Text(
"settings",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
navigationIcon = {
IconButton(onClick = { viewModel.navigateTo(MainScreen.CHAT) }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
if (settingsError != null) {
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall)
}
if (settingsSuccess != null) {
Text(settingsSuccess!!, color = Color.Green, style = MaterialTheme.typography.bodySmall)
}
// Profile Section
Column {
Text(
"profile",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("display name") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
trailingIcon = {
IconButton(onClick = { viewModel.updateDisplayName(displayName.ifBlank { null }) }) {
Icon(Icons.Default.Check, "Save")
}
}
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
// Security Section
Column {
Text(
"account security",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"two-factor authentication",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
if (is2faEnabled) "enabled" else "disabled",
style = MaterialTheme.typography.labelSmall,
color = if (is2faEnabled) Color.Green.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
if (!is2faEnabled) {
Button(
onClick = {
show2faSetup = true
viewModel.fetchTotpQr()
},
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.height(32.dp)
) {
Text("setup", style = MaterialTheme.typography.labelSmall)
}
} else {
Button(
onClick = { viewModel.disableTotp() },
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red
),
modifier = Modifier.height(32.dp)
) {
Text("disable", style = MaterialTheme.typography.labelSmall)
}
}
}
if (show2faSetup && !is2faEnabled) {
Spacer(Modifier.height(16.dp))
TwoFactorSetup(
qrCodeBase64 = totpQr?.qr_code,
onConfirm = { code ->
viewModel.confirmTotp(code)
},
onCancel = { show2faSetup = false }
)
}
Spacer(Modifier.height(16.dp))
Text("change password", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = oldPassword,
onValueChange = { oldPassword = it },
label = { Text("old password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("new password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(12.dp))
Button(
onClick = {
viewModel.changePassword(oldPassword, newPassword)
oldPassword = ""
newPassword = ""
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty()
) {
Text("update password")
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
// Application Section
Column {
Text(
"application",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
Button(
onClick = { viewModel.logout() },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red
)
) {
Text("logout", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
@Composable
fun TwoFactorSetup(
qrCodeBase64: String?,
onConfirm: (String) -> Unit,
onCancel: () -> Unit
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"scan qr code",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
if (qrCodeBase64 != null) {
val bitmap = remember(qrCodeBase64) {
val base64Data = qrCodeBase64.substringAfter("base64,")
val decodedString = Base64.decode(base64Data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.size(180.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White) // QR codes usually need white background
.padding(8.dp)
)
}
} else {
Box(
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(150.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
letterSpacing = 4.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant
)
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onCancel,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
) {
Text("cancel", style = MaterialTheme.typography.labelSmall)
}
Button(
onClick = { if (code.length == 6) onConfirm(code) },
enabled = code.length == 6,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("confirm", style = MaterialTheme.typography.labelSmall)
}
}
}
}