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.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
-1
@@ -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) }
|
||||||
|
|||||||
+11
-25
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-31
@@ -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
|
||||||
|
|||||||
+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.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()
|
||||||
|
|||||||
+18
@@ -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
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user