frontend v0.4.1-2

- added invite section to UI and some general bug fixes
This commit is contained in:
2026-04-11 00:09:47 +01:00
parent 529d09aabc
commit d1208f7e39
18 changed files with 301 additions and 96 deletions
-4
View File
@@ -4,9 +4,6 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<application <application
android:name=".ChatApplication" android:name=".ChatApplication"
@@ -22,7 +19,6 @@
<service <service
android:name=".core.service.MessageStreamService" android:name=".core.service.MessageStreamService"
android:foregroundServiceType="dataSync"
android:exported="false"/> android:exported="false"/>
<activity <activity
@@ -64,12 +64,22 @@ class MainActivity : ComponentActivity() {
LaunchedEffect(authState) { LaunchedEffect(authState) {
when (authState) { when (authState) {
AuthState.Authenticated -> MessageStreamService.start(this@MainActivity) AuthState.Authenticated -> {
MessageStreamService.start(this@MainActivity)
chatViewModel.loadAccessibleChannels()
}
AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity) AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity)
AuthState.AwaitingTotp -> {} AuthState.AwaitingTotp -> {}
} }
} }
LaunchedEffect(Unit) {
chatViewModel.onUnauthorized = {
authViewModel.logout()
chatViewModel.clearChat()
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let { intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let {
chatViewModel.switchChannel(it.toLong()) chatViewModel.switchChannel(it.toLong())
@@ -4,6 +4,7 @@ import android.util.Log
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest
import dev.zxq5.chatapp.android.api.model.DisplayNameRequest import dev.zxq5.chatapp.android.api.model.DisplayNameRequest
import dev.zxq5.chatapp.android.api.model.InviteRequest
import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest
import dev.zxq5.chatapp.android.api.model.QrResponse import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
@@ -45,6 +46,22 @@ class SettingsClient(private val token: String) {
} }
} }
suspend fun createInvite(request: InviteRequest): ApiResult<String> {
return try {
val response = http.post("${BASE_URL}/api/invite") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body<String>())
} else {
ApiResult.HttpError(response.status.value, "Failed to create invite")
}
} catch (e: Exception) {
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun getTotpQr(password: String): ApiResult<QrResponse> { suspend fun getTotpQr(password: String): ApiResult<QrResponse> {
return try { return try {
val response = http.post("${BASE_URL}/api/totp.jpg") { val response = http.post("${BASE_URL}/api/totp.jpg") {
@@ -0,0 +1,13 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable
data class InviteRequest @OptIn(ExperimentalTime::class) constructor(
val name: String,
val max_uses: Int,
val expiry_date: Instant,
val start_date: Instant
)
@@ -1,4 +1,4 @@
package dev.zxq5.chatapp.android.model package dev.zxq5.chatapp.android.api.model
sealed class LoginState { sealed class LoginState {
object Idle : LoginState() object Idle : LoginState()
@@ -3,10 +3,12 @@ package dev.zxq5.chatapp.android.core.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Base64 import android.util.Base64
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import org.json.JSONObject import org.json.JSONObject
import java.time.Instant
private const val KEY = "auth_token" private const val KEY = "auth_token"
private const val TWOFA_KEY = "twofa_enabled" private const val TWOFA_KEY = "twofa_enabled"
@@ -27,11 +29,37 @@ class TokenStore(appContext: Context) {
) )
} }
fun save(token: String) = fun save(token: String) {
prefs().edit { putString(KEY, token) } Log.d("TokenStore", "Saving token: $token")
prefs().edit { putString(KEY, token) }
}
fun get(): String? {
val ret = prefs().getString(KEY, null)
Log.d("TokenStore", "Retrieved token: $ret")
return ret
}
fun isExpired(): Boolean {
val token = get() ?: return true
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("exp")) {
val exp = json.getLong("exp")
val now = Instant.now().epochSecond
now >= exp
} else {
false // If no exp claim, assume not expired or handle differently
}
} catch (e: Exception) {
true // If we can't parse it, treat it as expired
}
}
fun get(): String? =
prefs().getString(KEY, null)
fun save2faEnabled( enabled: Boolean) = fun save2faEnabled( enabled: Boolean) =
prefs().edit { putBoolean(TWOFA_KEY, enabled) } prefs().edit { putBoolean(TWOFA_KEY, enabled) }
@@ -3,7 +3,6 @@ package dev.zxq5.chatapp.android.core.service
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import dev.zxq5.chatapp.android.ChatApplication import dev.zxq5.chatapp.android.ChatApplication
@@ -15,21 +14,17 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// core/service/MessageStreamService.kt
class MessageStreamService : Service() { class MessageStreamService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var notificationService: NotificationService private lateinit var notificationService: NotificationService
private lateinit var chatRepository: ChatRepository private lateinit var chatRepository: ChatRepository
// which channel the user is currently looking at
// set by the ViewModel when the user opens/closes a channel
var activeChannelId: Long? = null var activeChannelId: Long? = null
set(value) { set(value) {
field = value field = value
Log.d("Service", "activeChannelId set to $value") Log.d("Service", "activeChannelId set to $value")
if (value != null) { if (value != null) {
// restart stream with new channel
currentStreamJob?.cancel() currentStreamJob?.cancel()
observeMessages() observeMessages()
} }
@@ -42,11 +37,9 @@ class MessageStreamService : Service() {
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, MessageStreamService::class.java) val intent = Intent(context, MessageStreamService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Use startService to avoid the requirement for a persistent notification.
context.startForegroundService(intent) // This also prevents ForegroundServiceDidNotStartInTimeException.
} else { context.startService(intent)
context.startService(intent)
}
} }
fun stop(context: Context) { fun stop(context: Context) {
@@ -62,32 +55,25 @@ class MessageStreamService : Service() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(
NotificationService.FOREGROUND_NOTIFICATION_ID,
notificationService.buildForegroundNotification()
)
observeMessages() observeMessages()
return START_STICKY // restart if killed return START_STICKY
} }
private fun observeMessages() { private fun observeMessages() {
val channelId = activeChannelId ?: chatRepository.getLastActiveChannel() val channelId = activeChannelId ?: chatRepository.getLastActiveChannel()
Log.d("Service", "observeMessages called, channelId=$channelId") if (channelId == null) return
if (channelId == null) {
Log.d("Service", "No channel to observe, waiting for switchChannel")
return
}
Log.d("Service", "Starting stream for channel $channelId")
currentStreamJob = serviceScope.launch { currentStreamJob = serviceScope.launch {
chatRepository.messageStream(channelId) chatRepository.messageStream(channelId)
.catch { e -> Log.e("Service", "Stream error", e) } .catch { e -> Log.e("Service", "Stream error", e) }
.collect { message -> .collect { message ->
if (!ChatApplication.AppState.isInForeground) { // no channel focused, always notify // Only show notification when an event (new message) is received
// and the app is not in the foreground on this channel.
if (!ChatApplication.AppState.isInForeground || activeChannelId != channelId) {
notificationService.showMessageNotification( notificationService.showMessageNotification(
conversationId = activeChannelId.toString(), conversationId = channelId.toString(),
senderName = message.display_name, senderName = message.display_name,
messagePreview = message.text.take(80) messagePreview = message.text
) )
} }
} }
@@ -101,4 +87,4 @@ class MessageStreamService : Service() {
instance = null instance = null
serviceScope.cancel() serviceScope.cancel()
} }
} }
@@ -15,51 +15,41 @@ class NotificationService(private val context: Context) {
companion object { companion object {
const val CHANNEL_ID = "messages" const val CHANNEL_ID = "messages"
const val FOREGROUND_NOTIFICATION_ID = 1 // ← this needs to exist const val SERVICE_CHANNEL_ID = "service"
const val FOREGROUND_NOTIFICATION_ID = 1
} }
private val manager = context.getSystemService(NotificationManager::class.java) private val manager = context.getSystemService(NotificationManager::class.java)
fun createChannels() { fun createForegroundNotification(): Notification {
// channel for new message notifications val intent = Intent(context, MainActivity::class.java).apply {
val messageChannel = NotificationChannel( flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
CHANNEL_ID,
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
enableVibration(true)
} }
// channel for the persistent foreground service notification val pendingIntent = PendingIntent.getActivity(
// low importance so it doesn't make noise context,
val serviceChannel = NotificationChannel( 0,
"service", intent,
"Background connection", PendingIntent.FLAG_IMMUTABLE
NotificationManager.IMPORTANCE_LOW
) )
val mgr = context.getSystemService(NotificationManager::class.java) return NotificationCompat.Builder(context, SERVICE_CHANNEL_ID)
mgr.createNotificationChannel(messageChannel)
mgr.createNotificationChannel(serviceChannel)
}
fun buildForegroundNotification(): Notification {
return NotificationCompat.Builder(context, "service")
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle("chatapp") .setContentTitle("Chat App")
.setContentText("Connected") .setContentText("Connecting to message stream...")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(pendingIntent)
.setOngoing(true) .setOngoing(true)
.setSilent(true)
.build() .build()
} }
fun showMessageNotification( fun showMessageNotification(
conversationId: String, conversationId: String,
senderName: String, senderName: String,
messagePreview: String, // for E2E this would be "New message" — no plaintext messagePreview: String,
notificationId: Int = conversationId.hashCode() notificationId: Int = conversationId.hashCode()
) { ) {
// intent that opens the app to the right conversation when tapped
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("conversation_id", conversationId) putExtra("conversation_id", conversationId)
@@ -72,13 +62,13 @@ class NotificationService(private val context: Context) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
val notification = NotificationCompat.Builder(context, "messages") val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName) .setContentTitle(senderName)
.setContentText(messagePreview) .setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setAutoCancel(true) // dismiss on tap .setAutoCancel(true)
.build() .build()
manager.notify(notificationId, notification) manager.notify(notificationId, notification)
@@ -91,4 +81,4 @@ class NotificationService(private val context: Context) {
fun dismissAll() { fun dismissAll() {
manager.cancelAll() manager.cancelAll()
} }
} }
@@ -56,6 +56,10 @@ class AuthRepository(
fun getAuthState(): AuthState { fun getAuthState(): AuthState {
val token = tokenStore.get() ?: return AuthState.Unauthenticated val token = tokenStore.get() ?: return AuthState.Unauthenticated
if (tokenStore.isExpired()) {
tokenStore.clear()
return AuthState.Unauthenticated
}
return when (getScopeFromToken(token)) { return when (getScopeFromToken(token)) {
TokenScope.FULL -> AuthState.Authenticated TokenScope.FULL -> AuthState.Authenticated
TokenScope.TOTP_PENDING -> AuthState.AwaitingTotp TokenScope.TOTP_PENDING -> AuthState.AwaitingTotp
@@ -2,6 +2,7 @@ package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.model.QrResponse import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.api.SettingsClient import dev.zxq5.chatapp.android.api.SettingsClient
import dev.zxq5.chatapp.android.api.model.InviteRequest
import dev.zxq5.chatapp.android.api.model.TotpStatus import dev.zxq5.chatapp.android.api.model.TotpStatus
import dev.zxq5.chatapp.android.core.data.TokenStore import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.error.ApiResult import dev.zxq5.chatapp.android.core.error.ApiResult
@@ -25,6 +26,10 @@ class SettingsRepository(private val tokenStore: TokenStore) {
_lastToken = null _lastToken = null
} }
suspend fun createInvite(request: InviteRequest): ApiResult<String> {
return getSettingsClient()?.createInvite(request) ?: ApiResult.NetworkError("Not authenticated")
}
suspend fun getTotpQr(password: String): ApiResult<QrResponse?> { suspend fun getTotpQr(password: String): ApiResult<QrResponse?> {
val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated") val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
return settingsClient.getTotpQr(password) return settingsClient.getTotpQr(password)
@@ -3,7 +3,7 @@ package dev.zxq5.chatapp.android.feature.auth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import dev.zxq5.chatapp.android.model.LoginState import dev.zxq5.chatapp.android.api.model.LoginState
@Composable @Composable
fun AuthScreen(viewModel: AuthViewModel) { fun AuthScreen(viewModel: AuthViewModel) {
@@ -7,7 +7,7 @@ import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.LoginResult import dev.zxq5.chatapp.android.data.repository.LoginResult
import dev.zxq5.chatapp.android.data.repository.SignupResult import dev.zxq5.chatapp.android.data.repository.SignupResult
import dev.zxq5.chatapp.android.data.repository.AuthState import dev.zxq5.chatapp.android.data.repository.AuthState
import dev.zxq5.chatapp.android.model.LoginState import dev.zxq5.chatapp.android.api.model.LoginState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -28,7 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.LoginState import dev.zxq5.chatapp.android.api.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField import dev.zxq5.chatapp.android.ui.components.TextField
@Composable @Composable
@@ -28,7 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.LoginState import dev.zxq5.chatapp.android.api.model.LoginState
import dev.zxq5.chatapp.android.ui.components.TextField import dev.zxq5.chatapp.android.ui.components.TextField
@Composable @Composable
@@ -9,6 +9,8 @@ import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.Space import dev.zxq5.chatapp.android.api.model.Space
import dev.zxq5.chatapp.android.api.model.SpaceDto import dev.zxq5.chatapp.android.api.model.SpaceDto
import dev.zxq5.chatapp.android.core.service.MessageStreamService import dev.zxq5.chatapp.android.core.service.MessageStreamService
import io.ktor.client.plugins.ResponseException
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -40,6 +42,8 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
val channelError: StateFlow<String?> = _channelError val channelError: StateFlow<String?> = _channelError
private var streamJob: Job? = null private var streamJob: Job? = null
var onUnauthorized: (() -> Unit)? = null
init { init {
_currentUserId.value = chatRepository.getUserId() _currentUserId.value = chatRepository.getUserId()
@@ -49,6 +53,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
fun loadAccessibleChannels() { fun loadAccessibleChannels() {
_error.value = null _error.value = null
_currentUserId.value = chatRepository.getUserId()
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
chatRepository.getAccessibleChannels() chatRepository.getAccessibleChannels()
@@ -56,7 +61,11 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
_spaces.value = data _spaces.value = data
}.onFailure { e -> }.onFailure { e ->
Log.e("Chat", "Failed to load spaces", e) Log.e("Chat", "Failed to load spaces", e)
_error.value = "Failed to load channels: ${e.message}" if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
onUnauthorized?.invoke()
} else {
_error.value = "Failed to load channels: ${e.message}"
}
} }
} }
} }
@@ -72,7 +81,11 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
chatRepository.messageStream(id) chatRepository.messageStream(id)
.catch { e -> .catch { e ->
Log.e("Chat", "Stream error", e) Log.e("Chat", "Stream error", e)
_channelError.value = "Connection lost: ${e.message}" if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
onUnauthorized?.invoke()
} else {
_channelError.value = "Connection lost: ${e.message}"
}
} }
.collect { message -> .collect { message ->
_messages.update { it + message } _messages.update { it + message }
@@ -108,7 +121,11 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
) )
}.onFailure { e -> }.onFailure { e ->
Log.e("Chat", "Send message error", e) Log.e("Chat", "Send message error", e)
_channelError.value = "Failed to send message" if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
onUnauthorized?.invoke()
} else {
_channelError.value = "Failed to send message"
}
} }
} }
} }
@@ -117,6 +134,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
_messages.value = emptyList() _messages.value = emptyList()
_channelId.value = null _channelId.value = null
_currentUserId.value = null _currentUserId.value = null
_spaces.value = emptyList()
_error.value = null _error.value = null
_channelError.value = null _channelError.value = null
streamJob?.cancel() streamJob?.cancel()
@@ -2,6 +2,7 @@ package dev.zxq5.chatapp.android.feature.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.model.InviteRequest
import dev.zxq5.chatapp.android.api.model.QrResponse import dev.zxq5.chatapp.android.api.model.QrResponse
import dev.zxq5.chatapp.android.core.error.ApiResult import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.data.repository.SettingsRepository import dev.zxq5.chatapp.android.data.repository.SettingsRepository
@@ -27,6 +28,9 @@ class SettingsViewModel(private val settingsRepository: SettingsRepository) : Vi
private val _isSuccessState = MutableStateFlow<Map<String, Boolean>>(emptyMap()) private val _isSuccessState = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val isSuccessState: StateFlow<Map<String, Boolean>> = _isSuccessState val isSuccessState: StateFlow<Map<String, Boolean>> = _isSuccessState
private val _lastInviteCode = MutableStateFlow<String?>(null)
val lastInviteCode: StateFlow<String?> = _lastInviteCode
fun clearMessages() { fun clearMessages() {
_settingsError.value = null _settingsError.value = null
_totpError.value = null _totpError.value = null
@@ -40,6 +44,20 @@ class SettingsViewModel(private val settingsRepository: SettingsRepository) : Vi
} }
} }
fun createInvite(request: InviteRequest) {
viewModelScope.launch {
_settingsError.value = null
when (val result = settingsRepository.createInvite(request)) {
is ApiResult.Success -> {
_lastInviteCode.value = result.data
triggerSuccess("invite")
}
is ApiResult.HttpError -> _settingsError.value = result.message
is ApiResult.NetworkError -> _settingsError.value = result.message
}
}
}
fun fetchTotpStatus() { fun fetchTotpStatus() {
viewModelScope.launch { viewModelScope.launch {
when (val result = settingsRepository.getTotpStatus()) { when (val result = settingsRepository.getTotpStatus()) {
@@ -23,11 +23,14 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -37,8 +40,10 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -57,9 +62,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import android.util.Base64 import android.util.Base64
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import dev.zxq5.chatapp.android.api.model.InviteRequest
import kotlin.time.Duration.Companion.days
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
@@ -70,6 +81,7 @@ fun SettingsScreen(
val settingsError by viewModel.settingsError.collectAsState() val settingsError by viewModel.settingsError.collectAsState()
val isSuccessState by viewModel.isSuccessState.collectAsState() val isSuccessState by viewModel.isSuccessState.collectAsState()
val totpError by viewModel.totpError.collectAsState() val totpError by viewModel.totpError.collectAsState()
val lastInviteCode by viewModel.lastInviteCode.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.clearMessages() viewModel.clearMessages()
@@ -274,6 +286,120 @@ fun SettingsScreen(
} }
} }
SettingsSection(title = "invite") {
var inviteName by remember { mutableStateOf("") }
var maxUses by remember { mutableStateOf("1") }
val clipboardManager = LocalClipboardManager.current
var showDatePicker by remember { mutableStateOf(false) }
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = System.currentTimeMillis() + 7.days.inWholeMilliseconds
)
Text("create invite token", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = inviteName,
onValueChange = { inviteName = it },
label = { Text("name") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = maxUses,
onValueChange = { if (it.all { c -> c.isDigit() }) maxUses = it },
label = { Text("max uses") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = datePickerState.selectedDateMillis?.let { Instant.fromEpochMilliseconds(it).toString().substringBefore("T") } ?: "",
onValueChange = {},
label = { Text("expiry date") },
readOnly = true,
trailingIcon = {
IconButton(onClick = { showDatePicker = true }) {
Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Select Date")
}
},
modifier = Modifier.fillMaxWidth().clickable { showDatePicker = true },
shape = RoundedCornerShape(8.dp)
)
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = { showDatePicker = false }) {
Text("ok")
}
}
) {
DatePicker(state = datePickerState)
}
}
Spacer(Modifier.height(12.dp))
SuccessButton(
onClick = {
val nowMs = System.currentTimeMillis()
val expiryMs = datePickerState.selectedDateMillis ?: (nowMs + 7.days.inWholeMilliseconds)
viewModel.createInvite(
InviteRequest(
name = inviteName,
max_uses = maxUses.toIntOrNull() ?: 1,
start_date = Instant.fromEpochMilliseconds(nowMs),
expiry_date = Instant.fromEpochMilliseconds(expiryMs)
)
)
},
label = "generate invite",
isSuccess = isSuccessState["invite"] == true,
enabled = inviteName.isNotBlank(),
modifier = Modifier.fillMaxWidth()
)
if (lastInviteCode != null) {
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = lastInviteCode!!,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(lastInviteCode!!))
}) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(20.dp))
}
}
}
}
SettingsSection(title = "session") {
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
) {
Text("logout")
}
}
SettingsSection(title = "danger zone", color = Color.Red.copy(alpha = 0.7f)) { SettingsSection(title = "danger zone", color = Color.Red.copy(alpha = 0.7f)) {
var deletePassword by remember { mutableStateOf("") } var deletePassword by remember { mutableStateOf("") }
var deleteTotp by remember { mutableStateOf("") } var deleteTotp by remember { mutableStateOf("") }
@@ -337,18 +463,6 @@ fun SettingsScreen(
} }
} }
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) { if (settingsError != null) {
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp)) Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp))
} }
@@ -457,6 +571,7 @@ fun SuccessButton(
} }
} }
@OptIn(ExperimentalTime::class)
@Composable @Composable
fun TwoFactorSetup( fun TwoFactorSetup(
qrCodeBase64: String?, qrCodeBase64: String?,
@@ -511,15 +626,13 @@ fun TwoFactorSetup(
Text(error.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp)) Text(error.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp))
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(16.dp))
SuccessButton(
Button( onClick = { onConfirm(code) },
onClick = { if (code.length == 6) onConfirm(code) }, label = "verify and enable",
isSuccess = false, // Managed by parent
enabled = code.length == 6, enabled = code.length == 6,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth()
shape = RoundedCornerShape(8.dp) )
) {
Text("confirm code")
}
} }
} }
@@ -2,12 +2,14 @@ package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -31,6 +33,11 @@ fun TextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge, textStyle = MaterialTheme.typography.bodyLarge,
keyboardOptions = if (isPassword) {
KeyboardOptions(keyboardType = KeyboardType.Password)
} else {
KeyboardOptions.Default
},
visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
@@ -40,6 +47,6 @@ fun TextField(
unfocusedBorderColor = MaterialTheme.colorScheme.outline, unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedTextColor = MaterialTheme.colorScheme.onSurface, focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface unfocusedTextColor = MaterialTheme.colorScheme.onSurface
) ),
) )
} }