frontend v0.4.1-2
- added invite section to UI and some general bug fixes
This commit is contained in:
@@ -4,9 +4,6 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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
|
||||
android:name=".ChatApplication"
|
||||
@@ -22,7 +19,6 @@
|
||||
|
||||
<service
|
||||
android:name=".core.service.MessageStreamService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
|
||||
@@ -64,12 +64,22 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
LaunchedEffect(authState) {
|
||||
when (authState) {
|
||||
AuthState.Authenticated -> MessageStreamService.start(this@MainActivity)
|
||||
AuthState.Authenticated -> {
|
||||
MessageStreamService.start(this@MainActivity)
|
||||
chatViewModel.loadAccessibleChannels()
|
||||
}
|
||||
AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity)
|
||||
AuthState.AwaitingTotp -> {}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
chatViewModel.onUnauthorized = {
|
||||
authViewModel.logout()
|
||||
chatViewModel.clearChat()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let {
|
||||
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.api.model.AccountDeleteRequest
|
||||
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.QrResponse
|
||||
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> {
|
||||
return try {
|
||||
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
-1
@@ -1,4 +1,4 @@
|
||||
package dev.zxq5.chatapp.android.model
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
sealed class LoginState {
|
||||
object Idle : LoginState()
|
||||
@@ -3,10 +3,12 @@ package dev.zxq5.chatapp.android.core.data
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import org.json.JSONObject
|
||||
import java.time.Instant
|
||||
|
||||
private const val KEY = "auth_token"
|
||||
private const val TWOFA_KEY = "twofa_enabled"
|
||||
@@ -27,11 +29,37 @@ class TokenStore(appContext: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
fun save(token: String) =
|
||||
prefs().edit { putString(KEY, token) }
|
||||
fun save(token: String) {
|
||||
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) =
|
||||
prefs().edit { putBoolean(TWOFA_KEY, enabled) }
|
||||
|
||||
+9
-23
@@ -3,7 +3,6 @@ package dev.zxq5.chatapp.android.core.service
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import dev.zxq5.chatapp.android.ChatApplication
|
||||
@@ -15,21 +14,17 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// core/service/MessageStreamService.kt
|
||||
class MessageStreamService : Service() {
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private lateinit var notificationService: NotificationService
|
||||
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
|
||||
set(value) {
|
||||
field = value
|
||||
Log.d("Service", "activeChannelId set to $value")
|
||||
if (value != null) {
|
||||
// restart stream with new channel
|
||||
currentStreamJob?.cancel()
|
||||
observeMessages()
|
||||
}
|
||||
@@ -42,12 +37,10 @@ class MessageStreamService : Service() {
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, MessageStreamService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
// Use startService to avoid the requirement for a persistent notification.
|
||||
// This also prevents ForegroundServiceDidNotStartInTimeException.
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, MessageStreamService::class.java))
|
||||
@@ -62,32 +55,25 @@ class MessageStreamService : Service() {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(
|
||||
NotificationService.FOREGROUND_NOTIFICATION_ID,
|
||||
notificationService.buildForegroundNotification()
|
||||
)
|
||||
observeMessages()
|
||||
return START_STICKY // restart if killed
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun observeMessages() {
|
||||
val channelId = activeChannelId ?: chatRepository.getLastActiveChannel()
|
||||
Log.d("Service", "observeMessages called, channelId=$channelId")
|
||||
if (channelId == null) {
|
||||
Log.d("Service", "No channel to observe, waiting for switchChannel")
|
||||
return
|
||||
}
|
||||
if (channelId == null) return
|
||||
|
||||
Log.d("Service", "Starting stream for channel $channelId")
|
||||
currentStreamJob = serviceScope.launch {
|
||||
chatRepository.messageStream(channelId)
|
||||
.catch { e -> Log.e("Service", "Stream error", e) }
|
||||
.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(
|
||||
conversationId = activeChannelId.toString(),
|
||||
conversationId = channelId.toString(),
|
||||
senderName = message.display_name,
|
||||
messagePreview = message.text.take(80)
|
||||
messagePreview = message.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-29
@@ -15,51 +15,41 @@ class NotificationService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
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)
|
||||
|
||||
fun createChannels() {
|
||||
// channel for new message notifications
|
||||
val messageChannel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
enableVibration(true)
|
||||
fun createForegroundNotification(): Notification {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
|
||||
// channel for the persistent foreground service notification
|
||||
// low importance so it doesn't make noise
|
||||
val serviceChannel = NotificationChannel(
|
||||
"service",
|
||||
"Background connection",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val mgr = context.getSystemService(NotificationManager::class.java)
|
||||
mgr.createNotificationChannel(messageChannel)
|
||||
mgr.createNotificationChannel(serviceChannel)
|
||||
}
|
||||
|
||||
fun buildForegroundNotification(): Notification {
|
||||
return NotificationCompat.Builder(context, "service")
|
||||
return NotificationCompat.Builder(context, SERVICE_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("chatapp")
|
||||
.setContentText("Connected")
|
||||
.setContentTitle("Chat App")
|
||||
.setContentText("Connecting to message stream...")
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun showMessageNotification(
|
||||
conversationId: String,
|
||||
senderName: String,
|
||||
messagePreview: String, // for E2E this would be "New message" — no plaintext
|
||||
messagePreview: String,
|
||||
notificationId: Int = conversationId.hashCode()
|
||||
) {
|
||||
// intent that opens the app to the right conversation when tapped
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra("conversation_id", conversationId)
|
||||
@@ -72,13 +62,13 @@ class NotificationService(private val context: Context) {
|
||||
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)
|
||||
.setContentTitle(senderName)
|
||||
.setContentText(messagePreview)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true) // dismiss on tap
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
manager.notify(notificationId, notification)
|
||||
|
||||
@@ -56,6 +56,10 @@ class AuthRepository(
|
||||
|
||||
fun getAuthState(): AuthState {
|
||||
val token = tokenStore.get() ?: return AuthState.Unauthenticated
|
||||
if (tokenStore.isExpired()) {
|
||||
tokenStore.clear()
|
||||
return AuthState.Unauthenticated
|
||||
}
|
||||
return when (getScopeFromToken(token)) {
|
||||
TokenScope.FULL -> AuthState.Authenticated
|
||||
TokenScope.TOTP_PENDING -> AuthState.AwaitingTotp
|
||||
|
||||
+5
@@ -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.SettingsClient
|
||||
import dev.zxq5.chatapp.android.api.model.InviteRequest
|
||||
import dev.zxq5.chatapp.android.api.model.TotpStatus
|
||||
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||
@@ -25,6 +26,10 @@ class SettingsRepository(private val tokenStore: TokenStore) {
|
||||
_lastToken = null
|
||||
}
|
||||
|
||||
suspend fun createInvite(request: InviteRequest): ApiResult<String> {
|
||||
return getSettingsClient()?.createInvite(request) ?: ApiResult.NetworkError("Not authenticated")
|
||||
}
|
||||
|
||||
suspend fun getTotpQr(password: String): ApiResult<QrResponse?> {
|
||||
val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
|
||||
return settingsClient.getTotpQr(password)
|
||||
|
||||
@@ -3,7 +3,7 @@ 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
|
||||
import dev.zxq5.chatapp.android.api.model.LoginState
|
||||
|
||||
@Composable
|
||||
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.SignupResult
|
||||
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.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -28,7 +28,7 @@ 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.api.model.LoginState
|
||||
import dev.zxq5.chatapp.android.ui.components.TextField
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -28,7 +28,7 @@ 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.api.model.LoginState
|
||||
import dev.zxq5.chatapp.android.ui.components.TextField
|
||||
|
||||
@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.SpaceDto
|
||||
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.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -41,6 +43,8 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
|
||||
private var streamJob: Job? = null
|
||||
|
||||
var onUnauthorized: (() -> Unit)? = null
|
||||
|
||||
init {
|
||||
_currentUserId.value = chatRepository.getUserId()
|
||||
observeChannel()
|
||||
@@ -49,6 +53,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
|
||||
fun loadAccessibleChannels() {
|
||||
_error.value = null
|
||||
_currentUserId.value = chatRepository.getUserId()
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
chatRepository.getAccessibleChannels()
|
||||
@@ -56,10 +61,14 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
_spaces.value = data
|
||||
}.onFailure { e ->
|
||||
Log.e("Chat", "Failed to load spaces", e)
|
||||
if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
|
||||
onUnauthorized?.invoke()
|
||||
} else {
|
||||
_error.value = "Failed to load channels: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeChannel() {
|
||||
viewModelScope.launch {
|
||||
@@ -72,8 +81,12 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
chatRepository.messageStream(id)
|
||||
.catch { e ->
|
||||
Log.e("Chat", "Stream error", e)
|
||||
if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
|
||||
onUnauthorized?.invoke()
|
||||
} else {
|
||||
_channelError.value = "Connection lost: ${e.message}"
|
||||
}
|
||||
}
|
||||
.collect { message ->
|
||||
_messages.update { it + message }
|
||||
}
|
||||
@@ -108,15 +121,20 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
)
|
||||
}.onFailure { e ->
|
||||
Log.e("Chat", "Send message error", e)
|
||||
if (e is ResponseException && e.response.status == HttpStatusCode.Unauthorized) {
|
||||
onUnauthorized?.invoke()
|
||||
} else {
|
||||
_channelError.value = "Failed to send message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearChat() {
|
||||
_messages.value = emptyList()
|
||||
_channelId.value = null
|
||||
_currentUserId.value = null
|
||||
_spaces.value = emptyList()
|
||||
_error.value = null
|
||||
_channelError.value = null
|
||||
streamJob?.cancel()
|
||||
|
||||
+18
@@ -2,6 +2,7 @@ package dev.zxq5.chatapp.android.feature.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.core.error.ApiResult
|
||||
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())
|
||||
val isSuccessState: StateFlow<Map<String, Boolean>> = _isSuccessState
|
||||
|
||||
private val _lastInviteCode = MutableStateFlow<String?>(null)
|
||||
val lastInviteCode: StateFlow<String?> = _lastInviteCode
|
||||
|
||||
fun clearMessages() {
|
||||
_settingsError.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() {
|
||||
viewModelScope.launch {
|
||||
when (val result = settingsRepository.getTotpStatus()) {
|
||||
|
||||
@@ -23,11 +23,14 @@ 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.ContentCopy
|
||||
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.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -37,8 +40,10 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -57,9 +62,15 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import android.util.Base64
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
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
|
||||
fun SettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
@@ -70,6 +81,7 @@ fun SettingsScreen(
|
||||
val settingsError by viewModel.settingsError.collectAsState()
|
||||
val isSuccessState by viewModel.isSuccessState.collectAsState()
|
||||
val totpError by viewModel.totpError.collectAsState()
|
||||
val lastInviteCode by viewModel.lastInviteCode.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
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)) {
|
||||
var deletePassword 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) {
|
||||
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
|
||||
fun TwoFactorSetup(
|
||||
qrCodeBase64: String?,
|
||||
@@ -511,15 +626,13 @@ fun TwoFactorSetup(
|
||||
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) },
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SuccessButton(
|
||||
onClick = { onConfirm(code) },
|
||||
label = "verify and enable",
|
||||
isSuccess = false, // Managed by parent
|
||||
enabled = code.length == 6,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("confirm code")
|
||||
}
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package dev.zxq5.chatapp.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -31,6 +33,11 @@ fun TextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
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,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
@@ -40,6 +47,6 @@ fun TextField(
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user