frontend update, big refactor
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
package dev.zxq5.chatapp.android
|
||||
|
||||
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() {
|
||||
|
||||
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() {
|
||||
super.onCreate()
|
||||
ApiClient.init(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,36 +11,66 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import dev.zxq5.chatapp.android.model.ChatViewModel
|
||||
import dev.zxq5.chatapp.android.model.LoginState
|
||||
import dev.zxq5.chatapp.android.model.MainScreen
|
||||
import dev.zxq5.chatapp.android.ui.components.AuthScreen
|
||||
import dev.zxq5.chatapp.android.ui.components.ChatScreen
|
||||
import dev.zxq5.chatapp.android.ui.components.SettingsScreen
|
||||
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||
import dev.zxq5.chatapp.android.data.repository.AuthRepository
|
||||
import dev.zxq5.chatapp.android.data.repository.AuthState
|
||||
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.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
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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()
|
||||
setContent {
|
||||
ChatappTheme {
|
||||
val viewModel: ChatViewModel = viewModel()
|
||||
val loginState by viewModel.loginState.collectAsState()
|
||||
val currentScreen by viewModel.currentScreen.collectAsState()
|
||||
val authViewModel: AuthViewModel = viewModel(factory = ViewModelFactory(authRepository))
|
||||
val chatViewModel: ChatViewModel = viewModel(factory = ViewModelFactory(chatRepository))
|
||||
val settingsViewModel: SettingsViewModel = viewModel(factory = ViewModelFactory(settingsRepository))
|
||||
|
||||
val authState by authViewModel.authState.collectAsState()
|
||||
val currentScreen by chatViewModel.currentScreen.collectAsState()
|
||||
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) {
|
||||
if (loginState is LoginState.Success) {
|
||||
when (currentScreen) {
|
||||
MainScreen.CHAT -> ChatScreen(viewModel = viewModel)
|
||||
MainScreen.SETTINGS -> SettingsScreen(viewModel = viewModel)
|
||||
when (authState) {
|
||||
AuthState.Authenticated -> {
|
||||
when (currentScreen) {
|
||||
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
-3
@@ -1,4 +1,4 @@
|
||||
package dev.zxq5.chatapp.android.model
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -7,5 +7,3 @@ data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.zxq5.chatapp.android.model
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.zxq5.chatapp.android.model
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package dev.zxq5.chatapp.android.model
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.zxq5.chatapp.android.model
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -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 org.json.JSONObject
|
||||
|
||||
// In your ApiClient or a dedicated TokenStore
|
||||
object TokenStore {
|
||||
private const val KEY = "auth_token"
|
||||
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(
|
||||
context,
|
||||
"secure_prefs",
|
||||
@@ -25,54 +27,54 @@ object TokenStore {
|
||||
)
|
||||
}
|
||||
|
||||
fun save(context: Context, token: String) =
|
||||
prefs(context).edit { putString(KEY, token) }
|
||||
fun save(token: String) =
|
||||
prefs().edit { putString(KEY, token) }
|
||||
|
||||
fun get(context: Context): String? =
|
||||
prefs(context).getString(KEY, null)
|
||||
fun get(): String? =
|
||||
prefs().getString(KEY, null)
|
||||
|
||||
fun save2faEnabled(context: Context, enabled: Boolean) =
|
||||
prefs(context).edit { putBoolean(TWOFA_KEY, enabled) }
|
||||
fun save2faEnabled( enabled: Boolean) =
|
||||
prefs().edit { putBoolean(TWOFA_KEY, enabled) }
|
||||
|
||||
fun is2faEnabled(context: Context): Boolean =
|
||||
prefs(context).getBoolean(TWOFA_KEY, false)
|
||||
fun is2faEnabled(): Boolean =
|
||||
prefs().getBoolean(TWOFA_KEY, false)
|
||||
|
||||
fun clear(context: Context) =
|
||||
prefs(context).edit { remove(KEY).remove(TWOFA_KEY) }
|
||||
fun clear() =
|
||||
prefs().edit { remove(KEY).remove(TWOFA_KEY) }
|
||||
|
||||
fun getUserId(context: Context): Int? {
|
||||
val token = get(context) ?: return null
|
||||
fun getUserId(): Int? {
|
||||
val token = get() ?: return null
|
||||
return getUserIdFromToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserIdFromToken(token: String): Int? {
|
||||
return try {
|
||||
val payload = token.split(".")[1]
|
||||
// base64url needs padding restored
|
||||
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
||||
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
||||
val json = JSONObject(jsonString)
|
||||
fun getUserIdFromToken(token: String): Int? {
|
||||
return try {
|
||||
val payload = token.split(".")[1]
|
||||
// base64url needs padding restored
|
||||
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
||||
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
||||
val json = JSONObject(jsonString)
|
||||
|
||||
// Handle both standard 'sub' and custom 'user_id'
|
||||
when {
|
||||
json.has("sub") -> json.getInt("sub")
|
||||
json.has("user_id") -> json.getInt("user_id")
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getScopeFromToken(token: String): String? {
|
||||
return try {
|
||||
val payload = token.split(".")[1]
|
||||
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
||||
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
||||
val json = JSONObject(jsonString)
|
||||
if (json.has("scope")) json.getString("scope") else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
// Handle both standard 'sub' and custom 'user_id'
|
||||
when {
|
||||
json.has("sub") -> json.getInt("sub")
|
||||
json.has("user_id") -> json.getInt("user_id")
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getScopeFromToken(token: String): String? {
|
||||
return try {
|
||||
val payload = token.split(".")[1]
|
||||
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
||||
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
||||
val json = JSONObject(jsonString)
|
||||
if (json.has("scope")) json.getString("scope") else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
+66
-18
@@ -1,31 +1,79 @@
|
||||
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.getScopeFromToken
|
||||
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||
//
|
||||
//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);
|
||||
//// }
|
||||
//// }
|
||||
// }
|
||||
//}
|
||||
import dev.zxq5.chatapp.android.feature.auth.TokenScope
|
||||
|
||||
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 {
|
||||
object Success : LoginResult()
|
||||
object TotpRequired : LoginResult() // step 1 outcome → go to totp screen
|
||||
data class TotpError(val message: String) : LoginResult() // step 2 failure → stay on totp screen, show error
|
||||
data class Error(val message: String) : LoginResult() // general failure → show on login form
|
||||
object TotpRequired : LoginResult()
|
||||
data class TotpError(val message: String) : LoginResult()
|
||||
data class Error(val message: String) : LoginResult()
|
||||
}
|
||||
|
||||
sealed class 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()
|
||||
}
|
||||
}
|
||||
+67
@@ -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"
|
||||
}
|
||||
+138
@@ -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
|
||||
}
|
||||
+28
-21
@@ -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.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -26,12 +27,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.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.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -45,6 +41,9 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.LaunchedEffect
|
||||
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.SolidColor
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.zxq5.chatapp.android.model.ChatViewModel
|
||||
import dev.zxq5.chatapp.android.model.MainScreen
|
||||
import dev.zxq5.chatapp.android.model.Message
|
||||
import dev.zxq5.chatapp.android.api.model.Message
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@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()
|
||||
|
||||
if (selectedChannelId == null) {
|
||||
ChannelListScreen(
|
||||
viewModel = viewModel,
|
||||
onChannelSelect = { viewModel.switchChannel(it) }
|
||||
onChannelSelect = { viewModel.switchChannel(it) },
|
||||
onNavigateToSettings = onNavigateToSettings
|
||||
)
|
||||
} else {
|
||||
MessageScreen(
|
||||
@@ -85,7 +88,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) {
|
||||
fun ChannelListScreen(
|
||||
viewModel: ChatViewModel,
|
||||
onChannelSelect: (Int) -> Unit,
|
||||
onNavigateToSettings: () -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
@@ -153,7 +160,7 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit)
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = { BottomDock(viewModel) }
|
||||
bottomBar = { BottomDock(viewModel, onNavigateToSettings) }
|
||||
) { padding ->
|
||||
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
items(10) { i ->
|
||||
@@ -170,7 +177,7 @@ fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomDock(viewModel: ChatViewModel) {
|
||||
fun BottomDock(viewModel: ChatViewModel, onNavigateToSettings: () -> Unit) {
|
||||
val currentScreen by viewModel.currentScreen.collectAsState()
|
||||
|
||||
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))
|
||||
) {
|
||||
NavigationBarItem(
|
||||
selected = currentScreen == MainScreen.CHAT,
|
||||
onClick = { viewModel.navigateTo(MainScreen.CHAT) },
|
||||
selected = currentScreen == Screen.CHAT,
|
||||
onClick = { viewModel.navigateTo(Screen.CHAT) },
|
||||
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
|
||||
label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
@@ -190,8 +197,8 @@ fun BottomDock(viewModel: ChatViewModel) {
|
||||
)
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = currentScreen == MainScreen.SETTINGS,
|
||||
onClick = { viewModel.navigateTo(MainScreen.SETTINGS) },
|
||||
selected = currentScreen == Screen.SETTINGS,
|
||||
onClick = onNavigateToSettings,
|
||||
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
|
||||
label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
@@ -409,7 +416,7 @@ fun MessageBubble(message: Message, currentUserId: Int?) {
|
||||
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
|
||||
if (!isMe) {
|
||||
Text(
|
||||
message.display_name.lowercase(),
|
||||
message.display_name?.lowercase() ?: "unknown",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
|
||||
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) =
|
||||
androidx.compose.foundation.BorderStroke(width, color)
|
||||
private fun border(width: Dp, color: Color) =
|
||||
BorderStroke(width, color)
|
||||
+157
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user