works
@@ -0,0 +1,24 @@
|
||||
package com.example.fluffytrix
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.example.fluffytrix", appContext.packageName)
|
||||
}
|
||||
}
|
||||
28
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".FluffytrixApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Fluffytrix">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.example.fluffytrix
|
||||
|
||||
import android.app.Application
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import com.example.fluffytrix.data.repository.AuthRepository
|
||||
import com.example.fluffytrix.di.appModule
|
||||
import com.example.fluffytrix.di.dataModule
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
|
||||
class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
startKoin {
|
||||
androidContext(this@FluffytrixApplication)
|
||||
modules(appModule, dataModule)
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {
|
||||
val authRepository: AuthRepository by inject()
|
||||
val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
val token = authRepository.getAccessToken()
|
||||
if (token != null && request.url.encodedPath.contains("/_matrix/")) {
|
||||
chain.proceed(
|
||||
request.newBuilder()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
return ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("image_cache"))
|
||||
.maxSizeBytes(50L * 1024 * 1024) // 50 MB
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
20
app/src/main/java/com/example/fluffytrix/MainActivity.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.example.fluffytrix
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.example.fluffytrix.ui.navigation.FluffytrixNavigation
|
||||
import com.example.fluffytrix.ui.theme.FluffytrixTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
FluffytrixTheme {
|
||||
FluffytrixNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.fluffytrix.data
|
||||
|
||||
object MxcUrlHelper {
|
||||
fun mxcToDownloadUrl(
|
||||
baseUrl: String,
|
||||
mxcUri: String?,
|
||||
): String? {
|
||||
if (mxcUri == null || !mxcUri.startsWith("mxc://")) return null
|
||||
val parts = mxcUri.removePrefix("mxc://").split("/", limit = 2)
|
||||
if (parts.size != 2) return null
|
||||
val (serverName, mediaId) = parts
|
||||
val base = baseUrl.trimEnd('/')
|
||||
return "$base/_matrix/client/v1/media/download/$serverName/$mediaId"
|
||||
}
|
||||
|
||||
fun mxcToThumbnailUrl(
|
||||
baseUrl: String,
|
||||
mxcUri: String?,
|
||||
size: Int = 64,
|
||||
): String? {
|
||||
if (mxcUri == null || !mxcUri.startsWith("mxc://")) return null
|
||||
val parts = mxcUri.removePrefix("mxc://").split("/", limit = 2)
|
||||
if (parts.size != 2) return null
|
||||
val (serverName, mediaId) = parts
|
||||
val base = baseUrl.trimEnd('/')
|
||||
return "$base/_matrix/client/v1/media/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.example.fluffytrix.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "fluffytrix_prefs")
|
||||
|
||||
class PreferencesManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
|
||||
private val KEY_USER_ID = stringPreferencesKey("user_id")
|
||||
private val KEY_DEVICE_ID = stringPreferencesKey("device_id")
|
||||
private val KEY_HOMESERVER_URL = stringPreferencesKey("homeserver_url")
|
||||
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||
private val KEY_PASSWORD = stringPreferencesKey("password")
|
||||
private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
|
||||
private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order")
|
||||
}
|
||||
|
||||
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[KEY_IS_LOGGED_IN] == true
|
||||
}
|
||||
|
||||
val accessToken: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[KEY_ACCESS_TOKEN]
|
||||
}
|
||||
|
||||
val userId: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[KEY_USER_ID]
|
||||
}
|
||||
|
||||
val homeserverUrl: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[KEY_HOMESERVER_URL]
|
||||
}
|
||||
|
||||
val username: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[KEY_USERNAME]
|
||||
}
|
||||
|
||||
val password: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[KEY_PASSWORD]
|
||||
}
|
||||
|
||||
suspend fun saveSession(
|
||||
userId: String,
|
||||
deviceId: String,
|
||||
homeserverUrl: String,
|
||||
) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_USER_ID] = userId
|
||||
prefs[KEY_DEVICE_ID] = deviceId
|
||||
prefs[KEY_HOMESERVER_URL] = homeserverUrl
|
||||
prefs[KEY_IS_LOGGED_IN] = true
|
||||
}
|
||||
}
|
||||
|
||||
val channelOrder: Flow<Map<String, List<String>>> = context.dataStore.data.map { prefs ->
|
||||
val raw = prefs[KEY_CHANNEL_ORDER] ?: return@map emptyMap()
|
||||
try {
|
||||
Json.decodeFromString<Map<String, List<String>>>(raw)
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveChannelOrder(spaceId: String, roomIds: List<String>) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val existing = prefs[KEY_CHANNEL_ORDER]?.let {
|
||||
try { Json.decodeFromString<Map<String, List<String>>>(it) } catch (_: Exception) { emptyMap() }
|
||||
} ?: emptyMap()
|
||||
prefs[KEY_CHANNEL_ORDER] = Json.encodeToString(existing + (spaceId to roomIds))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearSession() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.fluffytrix.data.model
|
||||
|
||||
sealed class AuthState {
|
||||
data object Idle : AuthState()
|
||||
data object Loading : AuthState()
|
||||
data class Success(val userId: String) : AuthState()
|
||||
data class Error(val message: String) : AuthState()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.example.fluffytrix.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.example.fluffytrix.data.local.PreferencesManager
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import net.folivo.trixnity.client.MatrixClient
|
||||
import net.folivo.trixnity.client.fromStore
|
||||
import net.folivo.trixnity.client.loginWithPassword
|
||||
import net.folivo.trixnity.client.media.createInMemoryMediaStoreModule
|
||||
import net.folivo.trixnity.client.store.AccountStore
|
||||
import net.folivo.trixnity.client.store.repository.room.TrixnityRoomDatabase
|
||||
import net.folivo.trixnity.client.store.repository.room.createRoomRepositoriesModule
|
||||
import net.folivo.trixnity.clientserverapi.model.authentication.IdentifierType
|
||||
|
||||
class AuthRepository(
|
||||
private val preferencesManager: PreferencesManager,
|
||||
private val context: Context,
|
||||
) {
|
||||
private var matrixClient: MatrixClient? = null
|
||||
private var accessToken: String? = null
|
||||
|
||||
private fun createDatabaseBuilder() =
|
||||
Room.databaseBuilder(context, TrixnityRoomDatabase::class.java, "trixnity")
|
||||
.fallbackToDestructiveMigration(false)
|
||||
|
||||
suspend fun login(
|
||||
homeserverUrl: String,
|
||||
username: String,
|
||||
password: String,
|
||||
): Result<MatrixClient> {
|
||||
val normalizedUrl = homeserverUrl.let {
|
||||
if (!it.startsWith("http")) "https://$it" else it
|
||||
}
|
||||
val baseUrl = Url(normalizedUrl)
|
||||
|
||||
val result = MatrixClient.loginWithPassword(
|
||||
baseUrl = baseUrl,
|
||||
identifier = IdentifierType.User(username),
|
||||
password = password,
|
||||
initialDeviceDisplayName = "Fluffytrix Android",
|
||||
repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()),
|
||||
mediaStoreModule = createInMemoryMediaStoreModule(),
|
||||
)
|
||||
|
||||
result.onSuccess { client ->
|
||||
matrixClient = client
|
||||
try {
|
||||
val accountStore = client.di.get<AccountStore>()
|
||||
accessToken = accountStore.getAccount()?.accessToken
|
||||
} catch (_: Exception) { }
|
||||
preferencesManager.saveSession(
|
||||
userId = client.userId.full,
|
||||
deviceId = client.deviceId,
|
||||
homeserverUrl = homeserverUrl,
|
||||
)
|
||||
client.startSync()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreSession(): Boolean {
|
||||
if (matrixClient != null) return true
|
||||
|
||||
val isLoggedIn = preferencesManager.isLoggedIn.firstOrNull() ?: false
|
||||
if (!isLoggedIn) return false
|
||||
|
||||
return try {
|
||||
val client = MatrixClient.fromStore(
|
||||
repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()),
|
||||
mediaStoreModule = createInMemoryMediaStoreModule(),
|
||||
).getOrNull()
|
||||
|
||||
if (client != null) {
|
||||
matrixClient = client
|
||||
try {
|
||||
val accountStore = client.di.get<AccountStore>()
|
||||
accessToken = accountStore.getAccount()?.accessToken
|
||||
} catch (_: Exception) { }
|
||||
client.startSync()
|
||||
true
|
||||
} else {
|
||||
// Store was empty or corrupt — clear saved state
|
||||
preferencesManager.clearSession()
|
||||
false
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
preferencesManager.clearSession()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getClient(): MatrixClient? = matrixClient
|
||||
fun getAccessToken(): String? = accessToken
|
||||
fun getBaseUrl(): String? = matrixClient?.baseUrl?.toString()?.trimEnd('/')
|
||||
|
||||
suspend fun logout() {
|
||||
matrixClient?.logout()
|
||||
matrixClient?.close()
|
||||
matrixClient = null
|
||||
accessToken = null
|
||||
preferencesManager.clearSession()
|
||||
}
|
||||
}
|
||||
20
app/src/main/java/com/example/fluffytrix/di/AppModule.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.example.fluffytrix.di
|
||||
|
||||
import com.example.fluffytrix.data.local.PreferencesManager
|
||||
import com.example.fluffytrix.data.repository.AuthRepository
|
||||
import com.example.fluffytrix.ui.screens.login.LoginViewModel
|
||||
import com.example.fluffytrix.ui.screens.main.MainViewModel
|
||||
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
val appModule = module {
|
||||
viewModel { LoginViewModel(get()) }
|
||||
viewModel { VerificationViewModel(get()) }
|
||||
viewModel { MainViewModel(get(), get()) }
|
||||
}
|
||||
|
||||
val dataModule = module {
|
||||
single { PreferencesManager(get()) }
|
||||
single { AuthRepository(get(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.example.fluffytrix.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.example.fluffytrix.data.local.PreferencesManager
|
||||
import com.example.fluffytrix.data.repository.AuthRepository
|
||||
import com.example.fluffytrix.ui.screens.login.LoginScreen
|
||||
import com.example.fluffytrix.ui.screens.main.MainScreen
|
||||
import com.example.fluffytrix.ui.screens.verification.VerificationScreen
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun FluffytrixNavigation() {
|
||||
val navController = rememberNavController()
|
||||
val preferencesManager: PreferencesManager = koinInject()
|
||||
val authRepository: AuthRepository = koinInject()
|
||||
val isLoggedIn by preferencesManager.isLoggedIn.collectAsState(initial = false)
|
||||
|
||||
var isRestoring by remember { mutableStateOf(true) }
|
||||
var restoreSucceeded by remember { mutableStateOf(false) }
|
||||
var restoreFailed by remember { mutableStateOf(false) }
|
||||
var retryTrigger by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(isLoggedIn, retryTrigger) {
|
||||
if (isLoggedIn && authRepository.getClient() == null) {
|
||||
isRestoring = true
|
||||
restoreFailed = false
|
||||
restoreSucceeded = authRepository.restoreSession()
|
||||
if (!restoreSucceeded) {
|
||||
// Don't clear credentials — let user retry or go to login
|
||||
restoreFailed = true
|
||||
}
|
||||
} else {
|
||||
restoreSucceeded = authRepository.getClient() != null
|
||||
}
|
||||
isRestoring = false
|
||||
}
|
||||
|
||||
if (isRestoring && isLoggedIn) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Restore failed — show retry screen instead of wiping credentials
|
||||
if (restoreFailed && !restoreSucceeded) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Failed to reconnect",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Could not restore your session",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Button(onClick = { retryTrigger++ }) {
|
||||
Text("Retry")
|
||||
}
|
||||
TextButton(onClick = {
|
||||
restoreFailed = false
|
||||
restoreSucceeded = false
|
||||
}) {
|
||||
Text("Log in again")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val startDestination = if (restoreSucceeded || (isLoggedIn && authRepository.getClient() != null)) {
|
||||
Screen.Main.route
|
||||
} else {
|
||||
Screen.Login.route
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable(Screen.Login.route) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate(Screen.Verification.route) {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Screen.Verification.route) {
|
||||
VerificationScreen(
|
||||
onVerified = {
|
||||
navController.navigate(Screen.Main.route) {
|
||||
popUpTo(Screen.Verification.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onSkip = {
|
||||
navController.navigate(Screen.Main.route) {
|
||||
popUpTo(Screen.Verification.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.Main.route) {
|
||||
MainScreen(
|
||||
onLogout = {
|
||||
navController.navigate(Screen.Login.route) {
|
||||
popUpTo(Screen.Main.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.fluffytrix.ui.navigation
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
data object Login : Screen("login")
|
||||
data object Verification : Screen("verification")
|
||||
data object Main : Screen("main")
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.example.fluffytrix.ui.screens.login
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.fluffytrix.data.model.AuthState
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
viewModel: LoginViewModel = koinViewModel(),
|
||||
) {
|
||||
val homeserverUrl by viewModel.homeserverUrl.collectAsState()
|
||||
val username by viewModel.username.collectAsState()
|
||||
val password by viewModel.password.collectAsState()
|
||||
val authState by viewModel.authState.collectAsState()
|
||||
|
||||
LaunchedEffect(authState) {
|
||||
if (authState is AuthState.Success) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 400.dp)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Fluffytrix",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = homeserverUrl,
|
||||
onValueChange = { viewModel.homeserverUrl.value = it },
|
||||
label = { Text("Homeserver URL") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { viewModel.username.value = it },
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { viewModel.password.value = it },
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { viewModel.login() }),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.login() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = authState !is AuthState.Loading,
|
||||
) {
|
||||
if (authState is AuthState.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.height(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Text("Log In")
|
||||
}
|
||||
}
|
||||
|
||||
if (authState is AuthState.Error) {
|
||||
Text(
|
||||
text = (authState as AuthState.Error).message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.example.fluffytrix.ui.screens.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.fluffytrix.data.model.AuthState
|
||||
import com.example.fluffytrix.data.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LoginViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val homeserverUrl = MutableStateFlow("matrix.org")
|
||||
val username = MutableStateFlow("")
|
||||
val password = MutableStateFlow("")
|
||||
|
||||
private val _authState = MutableStateFlow<AuthState>(AuthState.Idle)
|
||||
val authState: StateFlow<AuthState> = _authState
|
||||
|
||||
fun login() {
|
||||
if (username.value.isBlank() || password.value.isBlank()) {
|
||||
_authState.value = AuthState.Error("Username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
_authState.value = AuthState.Loading
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.login(
|
||||
homeserverUrl = homeserverUrl.value,
|
||||
username = username.value,
|
||||
password = password.value,
|
||||
)
|
||||
_authState.value = result.fold(
|
||||
onSuccess = { AuthState.Success(it.userId.full) },
|
||||
onFailure = { AuthState.Error(it.message ?: "Login failed") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.example.fluffytrix.ui.screens.main
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.example.fluffytrix.ui.screens.main.components.ChannelList
|
||||
import com.example.fluffytrix.ui.screens.main.components.MemberList
|
||||
import com.example.fluffytrix.ui.screens.main.components.MessageTimeline
|
||||
import com.example.fluffytrix.ui.screens.main.components.SpaceList
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
onLogout: () -> Unit,
|
||||
viewModel: MainViewModel = koinViewModel(),
|
||||
) {
|
||||
val spaces by viewModel.spaces.collectAsState()
|
||||
val channels by viewModel.channels.collectAsState()
|
||||
val selectedSpace by viewModel.selectedSpace.collectAsState()
|
||||
val selectedChannel by viewModel.selectedChannel.collectAsState()
|
||||
val showChannelList by viewModel.showChannelList.collectAsState()
|
||||
val showMemberList by viewModel.showMemberList.collectAsState()
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val members by viewModel.members.collectAsState()
|
||||
val channelName by viewModel.channelName.collectAsState()
|
||||
val isReorderMode by viewModel.isReorderMode.collectAsState()
|
||||
|
||||
Scaffold { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Main content: SpaceList + chat + members
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
SpaceList(
|
||||
spaces = spaces,
|
||||
selectedSpace = selectedSpace,
|
||||
onSpaceClick = { viewModel.selectSpace(it) },
|
||||
onToggleChannelList = { viewModel.toggleChannelList() },
|
||||
contentPadding = padding,
|
||||
)
|
||||
|
||||
MessageTimeline(
|
||||
selectedChannel = selectedChannel,
|
||||
channelName = channelName,
|
||||
messages = messages,
|
||||
onToggleMemberList = { viewModel.toggleMemberList() },
|
||||
onSendMessage = { viewModel.sendMessage(it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = padding,
|
||||
)
|
||||
|
||||
AnimatedVisibility(visible = showMemberList) {
|
||||
MemberList(
|
||||
members = members,
|
||||
contentPadding = padding,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Channel list overlays on top, slides in from left
|
||||
AnimatedVisibility(
|
||||
visible = showChannelList,
|
||||
enter = slideInHorizontally { -it },
|
||||
exit = slideOutHorizontally { -it },
|
||||
modifier = Modifier.zIndex(1f),
|
||||
) {
|
||||
Row {
|
||||
Spacer(modifier = Modifier.width(64.dp))
|
||||
ChannelList(
|
||||
channels = channels,
|
||||
selectedChannel = selectedChannel,
|
||||
onChannelClick = {
|
||||
viewModel.selectChannel(it)
|
||||
viewModel.toggleChannelList()
|
||||
},
|
||||
onLogout = {
|
||||
viewModel.logout()
|
||||
onLogout()
|
||||
},
|
||||
contentPadding = padding,
|
||||
isReorderMode = isReorderMode,
|
||||
onToggleReorderMode = { viewModel.toggleReorderMode() },
|
||||
onMoveChannel = { from, to -> viewModel.moveChannel(from, to) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package com.example.fluffytrix.ui.screens.main
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.fluffytrix.data.MxcUrlHelper.mxcToDownloadUrl
|
||||
import com.example.fluffytrix.data.MxcUrlHelper.mxcToThumbnailUrl
|
||||
import com.example.fluffytrix.data.local.PreferencesManager
|
||||
import com.example.fluffytrix.data.repository.AuthRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.folivo.trixnity.client.room
|
||||
import net.folivo.trixnity.client.room.message.text
|
||||
import net.folivo.trixnity.client.store.Room
|
||||
import net.folivo.trixnity.client.store.isEncrypted
|
||||
import net.folivo.trixnity.client.user
|
||||
import net.folivo.trixnity.core.model.RoomId
|
||||
import net.folivo.trixnity.core.model.UserId
|
||||
import net.folivo.trixnity.core.model.events.m.room.CreateEventContent.RoomType
|
||||
import net.folivo.trixnity.core.model.events.m.room.Membership
|
||||
import net.folivo.trixnity.core.model.events.m.room.RoomMessageEventContent
|
||||
import net.folivo.trixnity.core.model.events.m.space.ChildEventContent
|
||||
|
||||
data class SpaceItem(
|
||||
val id: RoomId,
|
||||
val name: String,
|
||||
val avatarUrl: String?,
|
||||
)
|
||||
|
||||
data class ChannelItem(
|
||||
val id: RoomId,
|
||||
val name: String,
|
||||
val isEncrypted: Boolean,
|
||||
val avatarUrl: String? = null,
|
||||
)
|
||||
|
||||
sealed interface MessageContent {
|
||||
data class Text(val body: String, val urls: List<String> = emptyList()) : MessageContent
|
||||
data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent
|
||||
data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
|
||||
data class File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent
|
||||
}
|
||||
|
||||
private val urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""")
|
||||
|
||||
@Immutable
|
||||
data class MessageItem(
|
||||
val eventId: String,
|
||||
val senderId: String,
|
||||
val senderName: String,
|
||||
val senderAvatarUrl: String? = null,
|
||||
val content: MessageContent,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
data class MemberItem(
|
||||
val userId: UserId,
|
||||
val displayName: String,
|
||||
val avatarUrl: String? = null,
|
||||
)
|
||||
|
||||
class MainViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
private val preferencesManager: PreferencesManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList())
|
||||
val spaces: StateFlow<List<SpaceItem>> = _spaces
|
||||
|
||||
private val _channels = MutableStateFlow<List<ChannelItem>>(emptyList())
|
||||
val channels: StateFlow<List<ChannelItem>> = _channels
|
||||
|
||||
private val _selectedSpace = MutableStateFlow<RoomId?>(null)
|
||||
val selectedSpace: StateFlow<RoomId?> = _selectedSpace
|
||||
|
||||
private val _selectedChannel = MutableStateFlow<RoomId?>(null)
|
||||
val selectedChannel: StateFlow<RoomId?> = _selectedChannel
|
||||
|
||||
private val _showChannelList = MutableStateFlow(true)
|
||||
val showChannelList: StateFlow<Boolean> = _showChannelList
|
||||
|
||||
private val _showMemberList = MutableStateFlow(false)
|
||||
val showMemberList: StateFlow<Boolean> = _showMemberList
|
||||
|
||||
private val _messages = MutableStateFlow<List<MessageItem>>(emptyList())
|
||||
val messages: StateFlow<List<MessageItem>> = _messages
|
||||
|
||||
private val _members = MutableStateFlow<List<MemberItem>>(emptyList())
|
||||
val members: StateFlow<List<MemberItem>> = _members
|
||||
|
||||
private val _channelName = MutableStateFlow<String?>(null)
|
||||
val channelName: StateFlow<String?> = _channelName
|
||||
|
||||
private val _isReorderMode = MutableStateFlow(false)
|
||||
val isReorderMode: StateFlow<Boolean> = _isReorderMode
|
||||
|
||||
private val _channelOrderMap = MutableStateFlow<Map<String, List<String>>>(emptyMap())
|
||||
|
||||
private val _allChannelRooms = MutableStateFlow<List<ChannelItem>>(emptyList())
|
||||
private val _spaceChildren = MutableStateFlow<Set<RoomId>?>(null)
|
||||
|
||||
// Per-room caches
|
||||
private val messageCache = mutableMapOf<RoomId, MutableList<MessageItem>>()
|
||||
private val messageIds = mutableMapOf<RoomId, MutableSet<String>>()
|
||||
private val memberCache = mutableMapOf<RoomId, List<MemberItem>>()
|
||||
private val channelNameCache = mutableMapOf<RoomId, String>()
|
||||
private val senderAvatarCache = mutableMapOf<String, String?>()
|
||||
private val senderNameCache = mutableMapOf<String, String>()
|
||||
|
||||
// Room data cache — avoid re-resolving unchanged rooms
|
||||
private var cachedRoomData = mapOf<RoomId, Room>()
|
||||
|
||||
private var timelineJob: Job? = null
|
||||
private var membersJob: Job? = null
|
||||
private var spaceChildrenJob: Job? = null
|
||||
|
||||
init {
|
||||
loadRooms()
|
||||
observeSelectedChannel()
|
||||
observeSpaceFiltering()
|
||||
viewModelScope.launch {
|
||||
preferencesManager.channelOrder.collect { _channelOrderMap.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
private fun loadRooms() {
|
||||
val client = authRepository.getClient() ?: return
|
||||
val baseUrl = authRepository.getBaseUrl() ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
client.room.getAll()
|
||||
.debounce(1000)
|
||||
.collect { roomMap ->
|
||||
withContext(Dispatchers.Default) {
|
||||
// Only resolve rooms whose keys changed
|
||||
val currentKeys = roomMap.keys
|
||||
val newRoomIds = currentKeys - cachedRoomData.keys
|
||||
val removedRoomIds = cachedRoomData.keys - currentKeys
|
||||
|
||||
// Resolve only new/changed rooms
|
||||
val newlyResolved = if (newRoomIds.isNotEmpty()) {
|
||||
coroutineScope {
|
||||
newRoomIds.mapNotNull { roomId ->
|
||||
roomMap[roomId]?.let { flow ->
|
||||
async { roomId to flow.firstOrNull() }
|
||||
}
|
||||
}.awaitAll()
|
||||
.filter { it.second != null }
|
||||
.associate { it.first to it.second!! }
|
||||
}
|
||||
} else emptyMap()
|
||||
|
||||
// On first load, resolve everything; after that, only new ones
|
||||
val allResolved = if (cachedRoomData.isEmpty()) {
|
||||
coroutineScope {
|
||||
roomMap.entries.map { (roomId, flow) ->
|
||||
async { roomId to flow.firstOrNull() }
|
||||
}.awaitAll()
|
||||
.filter { it.second != null }
|
||||
.associate { it.first to it.second!! }
|
||||
}
|
||||
} else {
|
||||
(cachedRoomData - removedRoomIds) + newlyResolved
|
||||
}
|
||||
|
||||
cachedRoomData = allResolved
|
||||
val joinedRooms = allResolved.values.filter { it.membership == Membership.JOIN }
|
||||
|
||||
val allSpaces = joinedRooms
|
||||
.filter { it.createEventContent?.type is RoomType.Space }
|
||||
|
||||
// Collect child space IDs so we only show top-level spaces
|
||||
val childSpaceIds = mutableSetOf<RoomId>()
|
||||
val allSpaceIds = allSpaces.map { it.roomId }.toSet()
|
||||
coroutineScope {
|
||||
allSpaces.map { space ->
|
||||
async {
|
||||
try {
|
||||
val children = client.room.getAllState(space.roomId, ChildEventContent::class)
|
||||
.firstOrNull()?.keys?.map { RoomId(it) } ?: emptyList()
|
||||
children.filter { it in allSpaceIds }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
}.awaitAll().forEach { childSpaceIds.addAll(it) }
|
||||
}
|
||||
|
||||
_spaces.value = allSpaces
|
||||
.filter { it.roomId !in childSpaceIds }
|
||||
.map { room ->
|
||||
SpaceItem(
|
||||
id = room.roomId,
|
||||
name = room.name?.explicitName ?: room.roomId.full,
|
||||
avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 96),
|
||||
)
|
||||
}
|
||||
|
||||
_allChannelRooms.value = joinedRooms
|
||||
.filter { it.createEventContent?.type !is RoomType.Space }
|
||||
.map { room ->
|
||||
ChannelItem(
|
||||
id = room.roomId,
|
||||
name = room.name?.explicitName ?: room.roomId.full,
|
||||
isEncrypted = room.encrypted,
|
||||
avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 64),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSpaceFiltering() {
|
||||
viewModelScope.launch {
|
||||
combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap) { allChannels, children, spaceId, orderMap ->
|
||||
val filtered = if (children == null) allChannels
|
||||
else allChannels.filter { it.id in children }
|
||||
val savedOrder = spaceId?.let { orderMap[it.full] }
|
||||
if (savedOrder != null) {
|
||||
val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i }
|
||||
filtered.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE }
|
||||
} else filtered
|
||||
}.collect { _channels.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSelectedChannel() {
|
||||
viewModelScope.launch {
|
||||
_selectedChannel.collect { roomId ->
|
||||
timelineJob?.cancel()
|
||||
membersJob?.cancel()
|
||||
|
||||
if (roomId == null) {
|
||||
_messages.value = emptyList()
|
||||
_members.value = emptyList()
|
||||
_channelName.value = null
|
||||
return@collect
|
||||
}
|
||||
|
||||
// Restore from cache instantly
|
||||
_messages.value = messageCache[roomId]?.toList() ?: emptyList()
|
||||
_members.value = memberCache[roomId] ?: emptyList()
|
||||
_channelName.value = channelNameCache[roomId]
|
||||
|
||||
loadChannelName(roomId)
|
||||
timelineJob = loadTimeline(roomId)
|
||||
membersJob = loadMembers(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadChannelName(roomId: RoomId) {
|
||||
val client = authRepository.getClient() ?: return
|
||||
viewModelScope.launch {
|
||||
val room = client.room.getById(roomId).firstOrNull()
|
||||
val name = room?.name?.explicitName ?: roomId.full
|
||||
channelNameCache[roomId] = name
|
||||
_channelName.value = name
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTimeline(roomId: RoomId): Job {
|
||||
val client = authRepository.getClient() ?: return Job()
|
||||
val baseUrl = authRepository.getBaseUrl() ?: return Job()
|
||||
return viewModelScope.launch {
|
||||
try {
|
||||
val cached = messageCache.getOrPut(roomId) { mutableListOf() }
|
||||
val ids = messageIds.getOrPut(roomId) { mutableSetOf() }
|
||||
var dirty = false
|
||||
var lastEmitTime = 0L
|
||||
|
||||
client.room.getLastTimelineEvents(roomId).collectLatest { outerFlow ->
|
||||
if (outerFlow == null) return@collectLatest
|
||||
coroutineScope {
|
||||
outerFlow.collect { innerFlow ->
|
||||
val firstEvent = innerFlow.firstOrNull() ?: return@collect
|
||||
val eventId = firstEvent.event.id.full
|
||||
if (eventId in ids) {
|
||||
// Already have this event — but if encrypted, watch for decryption updates
|
||||
if (firstEvent.event.isEncrypted && firstEvent.content?.getOrNull() == null) {
|
||||
launch {
|
||||
innerFlow.collect { updated ->
|
||||
val decrypted = updated.content?.getOrNull() ?: return@collect
|
||||
val msgContent = resolveContent(decrypted, baseUrl) ?: return@collect
|
||||
updateCachedMessage(roomId, eventId, cached, msgContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return@collect
|
||||
}
|
||||
|
||||
val contentResult = firstEvent.content
|
||||
val content = contentResult?.getOrNull()
|
||||
val msgContent: MessageContent = when {
|
||||
content == null && firstEvent.event.isEncrypted -> MessageContent.Text(
|
||||
body = if (contentResult?.isFailure == true) "\uD83D\uDD12 Unable to decrypt message"
|
||||
else "\uD83D\uDD12 Waiting for decryption keys..."
|
||||
)
|
||||
content == null -> return@collect
|
||||
else -> resolveContent(content, baseUrl) ?: return@collect
|
||||
}
|
||||
|
||||
val senderId = firstEvent.event.sender.localpart
|
||||
val senderName = senderNameCache[senderId] ?: senderId
|
||||
val msg = MessageItem(
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
senderName = senderName,
|
||||
senderAvatarUrl = senderAvatarCache[senderId],
|
||||
content = msgContent,
|
||||
timestamp = firstEvent.event.originTimestamp,
|
||||
)
|
||||
|
||||
ids.add(eventId)
|
||||
val insertIdx = cached.binarySearch { other ->
|
||||
msg.timestamp.compareTo(other.timestamp)
|
||||
}.let { if (it < 0) -(it + 1) else it }
|
||||
cached.add(insertIdx, msg)
|
||||
dirty = true
|
||||
|
||||
// For encrypted events still waiting, watch for decryption
|
||||
if (firstEvent.event.isEncrypted && content == null) {
|
||||
launch {
|
||||
innerFlow.collect { updated ->
|
||||
val decrypted = updated.content?.getOrNull() ?: return@collect
|
||||
val resolved = resolveContent(decrypted, baseUrl) ?: return@collect
|
||||
updateCachedMessage(roomId, eventId, cached, resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle UI updates: max once per 200ms
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastEmitTime > 200) {
|
||||
if (_selectedChannel.value == roomId) {
|
||||
_messages.value = ArrayList(cached)
|
||||
}
|
||||
dirty = false
|
||||
lastEmitTime = now
|
||||
}
|
||||
}
|
||||
if (dirty && _selectedChannel.value == roomId) {
|
||||
_messages.value = ArrayList(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveContent(content: net.folivo.trixnity.core.model.events.RoomEventContent, baseUrl: String): MessageContent? {
|
||||
return when (content) {
|
||||
is RoomMessageEventContent.FileBased.Image -> MessageContent.Image(
|
||||
body = content.body,
|
||||
url = mxcToDownloadUrl(baseUrl, content.url) ?: return null,
|
||||
width = content.info?.width?.toInt(),
|
||||
height = content.info?.height?.toInt(),
|
||||
)
|
||||
is RoomMessageEventContent.FileBased.Video -> MessageContent.Video(
|
||||
body = content.body,
|
||||
url = mxcToDownloadUrl(baseUrl, content.url),
|
||||
thumbnailUrl = mxcToThumbnailUrl(baseUrl, content.info?.thumbnailUrl, 300),
|
||||
width = content.info?.width?.toInt(),
|
||||
height = content.info?.height?.toInt(),
|
||||
)
|
||||
is RoomMessageEventContent.FileBased.Audio -> MessageContent.File(
|
||||
body = content.body,
|
||||
fileName = content.fileName ?: content.body,
|
||||
size = content.info?.size,
|
||||
)
|
||||
is RoomMessageEventContent.FileBased.File -> MessageContent.File(
|
||||
body = content.body,
|
||||
fileName = content.fileName ?: content.body,
|
||||
size = content.info?.size,
|
||||
)
|
||||
is RoomMessageEventContent -> {
|
||||
val body = content.body
|
||||
MessageContent.Text(
|
||||
body = body,
|
||||
urls = urlRegex.findAll(body).map { it.value }.toList(),
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCachedMessage(roomId: RoomId, eventId: String, cached: MutableList<MessageItem>, newContent: MessageContent) {
|
||||
val idx = cached.indexOfFirst { it.eventId == eventId }
|
||||
if (idx >= 0) {
|
||||
cached[idx] = cached[idx].copy(content = newContent)
|
||||
if (_selectedChannel.value == roomId) {
|
||||
_messages.value = ArrayList(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
private fun loadMembers(roomId: RoomId): Job {
|
||||
val client = authRepository.getClient() ?: return Job()
|
||||
val baseUrl = authRepository.getBaseUrl()
|
||||
return viewModelScope.launch {
|
||||
try {
|
||||
client.user.loadMembers(roomId)
|
||||
client.user.getAll(roomId)
|
||||
.debounce(1000)
|
||||
.collect { userMap ->
|
||||
val memberList = withContext(Dispatchers.Default) {
|
||||
coroutineScope {
|
||||
userMap.values.map { userFlow ->
|
||||
async { userFlow.firstOrNull() }
|
||||
}.awaitAll().filterNotNull().map { user ->
|
||||
MemberItem(
|
||||
userId = user.userId,
|
||||
displayName = user.name,
|
||||
avatarUrl = baseUrl?.let {
|
||||
mxcToThumbnailUrl(it, user.event.content.avatarUrl, 64)
|
||||
},
|
||||
)
|
||||
}
|
||||
}.sortedBy { it.displayName.lowercase() }
|
||||
}
|
||||
memberCache[roomId] = memberList
|
||||
memberList.forEach { m ->
|
||||
val localpart = m.userId.localpart
|
||||
senderAvatarCache[localpart] = m.avatarUrl
|
||||
senderNameCache[localpart] = m.displayName
|
||||
}
|
||||
// Backfill avatars and display names into already-cached messages
|
||||
messageCache[roomId]?.let { cached ->
|
||||
var patched = false
|
||||
for (i in cached.indices) {
|
||||
val msg = cached[i]
|
||||
val newAvatar = if (msg.senderAvatarUrl == null) senderAvatarCache[msg.senderId] else null
|
||||
val newName = senderNameCache[msg.senderId]
|
||||
val nameChanged = newName != null && newName != msg.senderName
|
||||
if (newAvatar != null || nameChanged) {
|
||||
cached[i] = msg.copy(
|
||||
senderAvatarUrl = newAvatar ?: msg.senderAvatarUrl,
|
||||
senderName = newName ?: msg.senderName,
|
||||
)
|
||||
patched = true
|
||||
}
|
||||
}
|
||||
if (patched && _selectedChannel.value == roomId) {
|
||||
_messages.value = ArrayList(cached)
|
||||
}
|
||||
}
|
||||
if (_selectedChannel.value == roomId) {
|
||||
_members.value = memberList
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
_members.value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSpaceChildren(spaceId: RoomId) {
|
||||
val client = authRepository.getClient() ?: return
|
||||
spaceChildrenJob?.cancel()
|
||||
spaceChildrenJob = viewModelScope.launch {
|
||||
try {
|
||||
client.room.getAllState(spaceId, ChildEventContent::class).collect { stateMap ->
|
||||
_spaceChildren.value = stateMap.keys.map { RoomId(it) }.toSet()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
_spaceChildren.value = emptySet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(body: String) {
|
||||
val roomId = _selectedChannel.value ?: return
|
||||
val client = authRepository.getClient() ?: return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
client.room.sendMessage(roomId) { text(body) }
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSpace(spaceId: RoomId) {
|
||||
if (_selectedSpace.value == spaceId) {
|
||||
_showChannelList.value = !_showChannelList.value
|
||||
} else {
|
||||
_selectedSpace.value = spaceId
|
||||
_showChannelList.value = true
|
||||
loadSpaceChildren(spaceId)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectChannel(channelId: RoomId) {
|
||||
_selectedChannel.value = channelId
|
||||
}
|
||||
|
||||
fun toggleChannelList() {
|
||||
_showChannelList.value = !_showChannelList.value
|
||||
}
|
||||
|
||||
fun toggleMemberList() {
|
||||
_showMemberList.value = !_showMemberList.value
|
||||
}
|
||||
|
||||
fun toggleReorderMode() {
|
||||
_isReorderMode.value = !_isReorderMode.value
|
||||
}
|
||||
|
||||
fun moveChannel(from: Int, to: Int) {
|
||||
val current = _channels.value.toMutableList()
|
||||
if (from !in current.indices || to !in current.indices) return
|
||||
val item = current.removeAt(from)
|
||||
current.add(to, item)
|
||||
_channels.value = current
|
||||
val spaceId = _selectedSpace.value?.full ?: return
|
||||
val roomIds = current.map { it.id.full }
|
||||
_channelOrderMap.value = _channelOrderMap.value + (spaceId to roomIds)
|
||||
viewModelScope.launch {
|
||||
preferencesManager.saveChannelOrder(spaceId, roomIds)
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.example.fluffytrix.ui.screens.main.components
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.example.fluffytrix.ui.screens.main.ChannelItem
|
||||
import net.folivo.trixnity.core.model.RoomId
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ChannelList(
|
||||
channels: List<ChannelItem>,
|
||||
selectedChannel: RoomId?,
|
||||
onChannelClick: (RoomId) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
isReorderMode: Boolean = false,
|
||||
onToggleReorderMode: () -> Unit = {},
|
||||
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Drag state
|
||||
var draggingIndex by remember { mutableIntStateOf(-1) }
|
||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
if (showLogoutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogoutDialog = false },
|
||||
title = { Text("Logout") },
|
||||
text = { Text("Are you sure you want to logout?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false; onLogout() }) {
|
||||
Text("Logout")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(240.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(top = contentPadding.calculateTopPadding()),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Channels",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Row {
|
||||
IconButton(onClick = onToggleReorderMode) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SwapVert,
|
||||
contentDescription = "Reorder channels",
|
||||
tint = if (isReorderMode) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showLogoutDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = "Logout",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
itemsIndexed(channels, key = { _, ch -> ch.id.full }) { index, channel ->
|
||||
val isSelected = channel.id == selectedChannel
|
||||
val isDragging = draggingIndex == index
|
||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation")
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isDragging) Modifier
|
||||
.zIndex(1f)
|
||||
.offset { IntOffset(0, dragOffsetY.roundToInt()) }
|
||||
.shadow(elevation, RoundedCornerShape(4.dp))
|
||||
else Modifier
|
||||
)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(
|
||||
when {
|
||||
isDragging -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
.then(if (!isReorderMode) Modifier.clickable { onChannelClick(channel.id) } else Modifier)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isReorderMode) {
|
||||
val currentIndex by rememberUpdatedState(index)
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = "Drag to reorder",
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = {
|
||||
draggingIndex = currentIndex
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetY += dragAmount.y
|
||||
val itemHeight = 34.dp.toPx()
|
||||
val draggedPositions = (dragOffsetY / itemHeight).roundToInt()
|
||||
val targetIndex = (draggingIndex + draggedPositions).coerceIn(0, channels.lastIndex)
|
||||
if (targetIndex != draggingIndex) {
|
||||
onMoveChannel(draggingIndex, targetIndex)
|
||||
dragOffsetY -= (targetIndex - draggingIndex) * itemHeight
|
||||
draggingIndex = targetIndex
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
draggingIndex = -1
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggingIndex = -1
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(18.dp),
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = channel.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.example.fluffytrix.ui.screens.main.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.example.fluffytrix.ui.screens.main.MemberItem
|
||||
|
||||
@Composable
|
||||
fun MemberList(
|
||||
members: List<MemberItem>,
|
||||
contentPadding: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(240.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(top = contentPadding.calculateTopPadding()),
|
||||
) {
|
||||
Text(
|
||||
text = "Members — ${members.size}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
items(members, key = { it.userId.full }) { member ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (member.avatarUrl != null) {
|
||||
AsyncImage(
|
||||
model = member.avatarUrl,
|
||||
contentDescription = member.displayName,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = member.displayName.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = member.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
package com.example.fluffytrix.ui.screens.main.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.example.fluffytrix.data.repository.AuthRepository
|
||||
import org.koin.compose.koinInject
|
||||
import coil3.compose.AsyncImage
|
||||
import com.example.fluffytrix.ui.screens.main.MessageContent
|
||||
import com.example.fluffytrix.ui.screens.main.MessageItem
|
||||
import net.folivo.trixnity.core.model.RoomId
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} }
|
||||
private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
|
||||
|
||||
private val senderColors = arrayOf(
|
||||
Color(0xFF5865F2),
|
||||
Color(0xFF57F287),
|
||||
Color(0xFFFEE75C),
|
||||
Color(0xFFEB459E),
|
||||
Color(0xFFED4245),
|
||||
Color(0xFFFF7F50),
|
||||
Color(0xFF9B59B6),
|
||||
Color(0xFF1ABC9C),
|
||||
)
|
||||
|
||||
private fun colorForSender(name: String): Color {
|
||||
return senderColors[name.hashCode().ushr(1) % senderColors.size]
|
||||
}
|
||||
|
||||
private val todayFormat = SimpleDateFormat("h:mm a", Locale.US)
|
||||
private val olderFormat = SimpleDateFormat("MM/dd/yyyy h:mm a", Locale.US)
|
||||
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
if (timestamp == 0L) return ""
|
||||
val diff = System.currentTimeMillis() - timestamp
|
||||
return if (diff < 86_400_000) todayFormat.format(Date(timestamp))
|
||||
else olderFormat.format(Date(timestamp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageTimeline(
|
||||
selectedChannel: RoomId?,
|
||||
channelName: String?,
|
||||
messages: List<MessageItem>,
|
||||
onToggleMemberList: () -> Unit,
|
||||
onSendMessage: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
|
||||
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
if (fullscreenImageUrl != null) {
|
||||
FullscreenImageViewer(
|
||||
url = fullscreenImageUrl!!,
|
||||
onDismiss = { fullscreenImageUrl = null },
|
||||
)
|
||||
}
|
||||
|
||||
if (fullscreenVideoUrl != null) {
|
||||
FullscreenVideoPlayer(
|
||||
url = fullscreenVideoUrl!!,
|
||||
onDismiss = { fullscreenVideoUrl = null },
|
||||
)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalImageViewer provides { url -> fullscreenImageUrl = url },
|
||||
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(top = contentPadding.calculateTopPadding()),
|
||||
) {
|
||||
if (selectedChannel != null) {
|
||||
TopBar(channelName ?: selectedChannel.full, onToggleMemberList)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||
}
|
||||
|
||||
if (selectedChannel == null) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"Select a room to start chatting",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val isAtBottom by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll when near bottom and new messages arrive
|
||||
LaunchedEffect(messages.size) {
|
||||
if (listState.firstVisibleItemIndex <= 2) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
val count = messages.size
|
||||
items(
|
||||
count = count,
|
||||
key = { messages[it].eventId },
|
||||
contentType = {
|
||||
val msg = messages[it]
|
||||
val next = if (it + 1 < count) messages[it + 1] else null
|
||||
if (next == null || next.senderId != msg.senderId) 0 else 1
|
||||
},
|
||||
) { index ->
|
||||
val message = messages[index]
|
||||
val next = if (index + 1 < count) messages[index + 1] else null
|
||||
val isFirstInGroup = next == null || next.senderName != message.senderName
|
||||
|
||||
if (isFirstInGroup) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FullMessage(message)
|
||||
} else {
|
||||
CompactMessage(message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to bottom button
|
||||
if (!isAtBottom) {
|
||||
IconButton(
|
||||
onClick = { scope.launch { listState.animateScrollToItem(0) } },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown,
|
||||
"Jump to bottom",
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||
MessageInput(channelName ?: "message", onSendMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.Tag, null, Modifier.size(20.dp), MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onToggleMemberList) {
|
||||
Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FullMessage(message: MessageItem) {
|
||||
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
|
||||
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
if (message.senderAvatarUrl != null) {
|
||||
AsyncImage(
|
||||
model = message.senderAvatarUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape).background(senderColor.copy(alpha = 0.3f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
message.senderName.take(1).uppercase(),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = senderColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(message.senderName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = senderColor)
|
||||
Text(time, style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
MessageContentView(message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactMessage(content: MessageContent) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
||||
Spacer(Modifier.width(52.dp))
|
||||
MessageContentView(content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageContentView(content: MessageContent) {
|
||||
when (content) {
|
||||
is MessageContent.Text -> TextContent(content)
|
||||
is MessageContent.Image -> ImageContent(content)
|
||||
is MessageContent.Video -> VideoContent(content)
|
||||
is MessageContent.File -> FileContent(content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextContent(content: MessageContent.Text) {
|
||||
Markdown(
|
||||
content = content.body,
|
||||
colors = markdownColor(
|
||||
text = MaterialTheme.colorScheme.onBackground,
|
||||
),
|
||||
typography = markdownTypography(
|
||||
text = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
|
||||
),
|
||||
imageTransformer = Coil3ImageTransformerImpl,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageContent(content: MessageContent.Image) {
|
||||
val onViewImage = LocalImageViewer.current
|
||||
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
|
||||
content.width.toFloat() / content.height.toFloat() else null
|
||||
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
contentDescription = content.body,
|
||||
modifier = Modifier
|
||||
.let { if (aspectRatio != null) it.width((300.dp * aspectRatio).coerceAtMost(400.dp)) else it.fillMaxWidth(0.6f) }
|
||||
.height(300.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onViewImage(content.url) },
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoContent(content: MessageContent.Video) {
|
||||
val onPlayVideo = LocalVideoPlayer.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(200.dp)
|
||||
.let { mod ->
|
||||
val ar = if (content.width != null && content.height != null && content.height > 0)
|
||||
content.width.toFloat() / content.height.toFloat() else 16f / 9f
|
||||
mod.width((200.dp * ar).coerceAtMost(400.dp))
|
||||
}
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { content.url?.let { onPlayVideo(it) } },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (content.thumbnailUrl != null) {
|
||||
AsyncImage(
|
||||
model = content.thumbnailUrl,
|
||||
contentDescription = content.body,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant))
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.PlayCircleFilled, "Play",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = Color.White.copy(alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FullscreenImageViewer(url: String, onDismiss: () -> Unit) {
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
var offsetY by remember { mutableStateOf(0f) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
scale = (scale * zoom).coerceIn(1f, 5f)
|
||||
if (scale > 1f) {
|
||||
offsetX += pan.x
|
||||
offsetY += pan.y
|
||||
} else {
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY,
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
) {
|
||||
Icon(Icons.Default.Close, "Close", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
private fun FullscreenVideoPlayer(url: String, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val authRepository: AuthRepository = koinInject()
|
||||
val exoPlayer = remember {
|
||||
val token = authRepository.getAccessToken()
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
||||
if (token != null) {
|
||||
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
||||
}
|
||||
}
|
||||
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(Uri.parse(url)))
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaSource(mediaSource)
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { exoPlayer.release() }
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
exoPlayer.stop()
|
||||
onDismiss()
|
||||
},
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
useController = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
exoPlayer.stop()
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
) {
|
||||
Icon(Icons.Default.Close, "Close", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileContent(content: MessageContent.File) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Default.AttachFile, null, Modifier.size(20.dp), MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Column {
|
||||
Text(
|
||||
content.fileName ?: content.body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (content.size != null) {
|
||||
Text(
|
||||
formatFileSize(content.size),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> "${"%.1f".format(bytes / (1024.0 * 1024.0))} MB"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) },
|
||||
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
onClick = { if (text.isNotBlank()) { onSendMessage(text.trim()); text = "" } },
|
||||
enabled = text.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send, "Send",
|
||||
tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.example.fluffytrix.ui.screens.main.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.example.fluffytrix.ui.screens.main.SpaceItem
|
||||
import net.folivo.trixnity.core.model.RoomId
|
||||
|
||||
@Composable
|
||||
fun SpaceList(
|
||||
spaces: List<SpaceItem>,
|
||||
selectedSpace: RoomId?,
|
||||
onSpaceClick: (RoomId) -> Unit,
|
||||
onToggleChannelList: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.width(64.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(top = contentPadding.calculateTopPadding()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
// Channel list toggle
|
||||
item {
|
||||
IconButton(onClick = onToggleChannelList) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = "Toggle channel list",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Home button
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(if (selectedSpace == null) RoundedCornerShape(16.dp) else CircleShape)
|
||||
.background(
|
||||
if (selectedSpace == null) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
.clickable { /* home/all rooms */ },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Home,
|
||||
contentDescription = "Home",
|
||||
tint = if (selectedSpace == null) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(spaces, key = { it.id.full }) { space ->
|
||||
val isSelected = space.id == selectedSpace
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape)
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
.clickable { onSpaceClick(space.id) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (space.avatarUrl != null) {
|
||||
var imageError by remember { mutableStateOf(false) }
|
||||
if (!imageError) {
|
||||
AsyncImage(
|
||||
model = space.avatarUrl,
|
||||
contentDescription = space.name,
|
||||
modifier = Modifier.size(48.dp).clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
onError = { imageError = true },
|
||||
)
|
||||
} else {
|
||||
SpaceInitial(space.name, isSelected)
|
||||
}
|
||||
} else {
|
||||
SpaceInitial(space.name, isSelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpaceInitial(name: String, isSelected: Boolean) {
|
||||
Text(
|
||||
text = name.take(2).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package com.example.fluffytrix.ui.screens.verification
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun VerificationScreen(
|
||||
onVerified: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
viewModel: VerificationViewModel = koinViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(uiState) {
|
||||
if (uiState is VerificationUiState.AlreadyVerified ||
|
||||
uiState is VerificationUiState.VerificationDone
|
||||
) {
|
||||
onVerified()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 480.dp)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (val state = uiState) {
|
||||
is VerificationUiState.Loading -> LoadingContent()
|
||||
is VerificationUiState.NoCrossSigning -> NoCrossSigningContent(onSkip)
|
||||
is VerificationUiState.AlreadyVerified -> {} // handled by LaunchedEffect
|
||||
is VerificationUiState.VerificationDone -> VerificationDoneContent()
|
||||
is VerificationUiState.MethodSelection -> MethodSelectionContent(
|
||||
state = state,
|
||||
onDeviceVerification = { viewModel.startDeviceVerification() },
|
||||
onRecoveryKey = { viewModel.selectRecoveryKey() },
|
||||
onPassphrase = { viewModel.selectPassphrase() },
|
||||
onSkip = onSkip,
|
||||
)
|
||||
is VerificationUiState.RecoveryKeyInput -> RecoveryKeyInputContent(
|
||||
onVerify = { viewModel.verifyWithRecoveryKey(it) },
|
||||
onBack = { viewModel.goBack() },
|
||||
)
|
||||
is VerificationUiState.PassphraseInput -> PassphraseInputContent(
|
||||
onVerify = { viewModel.verifyWithPassphrase(it) },
|
||||
onBack = { viewModel.goBack() },
|
||||
)
|
||||
is VerificationUiState.WaitingForDevice -> WaitingForDeviceContent(
|
||||
onCancel = { viewModel.goBack() },
|
||||
)
|
||||
is VerificationUiState.EmojiComparison -> EmojiComparisonContent(
|
||||
emojis = state.emojis,
|
||||
decimals = state.decimals,
|
||||
onMatch = { viewModel.confirmEmojiMatch() },
|
||||
onNoMatch = { viewModel.rejectEmojiMatch() },
|
||||
)
|
||||
is VerificationUiState.Error -> ErrorContent(
|
||||
message = state.message,
|
||||
onRetry = { viewModel.goBack() },
|
||||
onSkip = onSkip,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent() {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Checking verification status...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoCrossSigningContent(onSkip: () -> Unit) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Cross-signing not set up",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Your account doesn't have cross-signing enabled. Set it up from another client like Element to verify this device.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(onClick = onSkip) {
|
||||
Text("Continue without verification")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MethodSelectionContent(
|
||||
state: VerificationUiState.MethodSelection,
|
||||
onDeviceVerification: () -> Unit,
|
||||
onRecoveryKey: () -> Unit,
|
||||
onPassphrase: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Verified,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Verify this device",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Confirm your identity to access encrypted messages.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (state.hasDeviceVerification) {
|
||||
Card(
|
||||
onClick = onDeviceVerification,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PhoneAndroid,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "Verify with another device",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Compare emojis on both devices",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hasRecoveryKey) {
|
||||
Card(
|
||||
onClick = onRecoveryKey,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Key,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "Enter recovery key",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Use your security key to verify",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hasPassphrase) {
|
||||
Card(
|
||||
onClick = onPassphrase,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "Enter security phrase",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Use your passphrase to verify",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(onClick = onSkip) {
|
||||
Text("Skip for now")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecoveryKeyInputContent(
|
||||
onVerify: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var recoveryKey by remember { mutableStateOf("") }
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Key,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Enter recovery key",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Enter the recovery key you saved when setting up cross-signing.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = recoveryKey,
|
||||
onValueChange = { recoveryKey = it },
|
||||
label = { Text("Recovery key") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = false,
|
||||
minLines = 2,
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { onVerify(recoveryKey.trim()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = recoveryKey.isNotBlank(),
|
||||
) {
|
||||
Text("Verify")
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack) {
|
||||
Text("Back")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassphraseInputContent(
|
||||
onVerify: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Enter security phrase",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Enter the passphrase you set when configuring cross-signing.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = passphrase,
|
||||
onValueChange = { passphrase = it },
|
||||
label = { Text("Security phrase") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { onVerify(passphrase) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = passphrase.isNotBlank(),
|
||||
) {
|
||||
Text("Verify")
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack) {
|
||||
Text("Back")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaitingForDeviceContent(onCancel: () -> Unit) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Waiting for other device",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Accept the verification request on your other device.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
OutlinedButton(onClick = onCancel) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun EmojiComparisonContent(
|
||||
emojis: List<Pair<Int, String>>,
|
||||
decimals: List<Int>,
|
||||
onMatch: () -> Unit,
|
||||
onNoMatch: () -> Unit,
|
||||
) {
|
||||
Text(
|
||||
text = "Compare emojis",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Confirm the emojis below match on your other device.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Emoji grid
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
emojis.forEach { (code, description) ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = emojiForCode(code),
|
||||
fontSize = 32.sp,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Decimal comparison
|
||||
Text(
|
||||
text = decimals.joinToString(" "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onNoMatch,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("They don't match")
|
||||
}
|
||||
Button(
|
||||
onClick = onMatch,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("They match")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerificationDoneContent() {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Verified,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Device verified!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorContent(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
) {
|
||||
Text(
|
||||
text = "Verification failed",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Button(onClick = onRetry) {
|
||||
Text("Try again")
|
||||
}
|
||||
TextButton(onClick = onSkip) {
|
||||
Text("Skip for now")
|
||||
}
|
||||
}
|
||||
|
||||
// SAS emoji mapping per Matrix spec
|
||||
private fun emojiForCode(code: Int): String {
|
||||
val emojiMap = arrayOf(
|
||||
"\uD83D\uDC36", "\uD83D\uDC31", "\uD83E\uDD81", "\uD83D\uDC34", "\uD83E\uDD84", // dog, cat, lion, horse, unicorn
|
||||
"\uD83D\uDC37", "\uD83D\uDC18", "\uD83D\uDC30", "\uD83D\uDC3C", "\uD83D\uDC14", // pig, elephant, rabbit, panda, rooster
|
||||
"\uD83D\uDC27", "\uD83D\uDC22", "\uD83D\uDC1F", "\uD83D\uDC19", "\uD83E\uDD8B", // penguin, turtle, fish, octopus, butterfly
|
||||
"\uD83C\uDF3B", "\uD83C\uDF35", "\uD83C\uDF44", "\uD83C\uDF3E", "\uD83C\uDF3A", // sunflower, cactus, mushroom, globe, rose (using rice/hibiscus)
|
||||
"\uD83C\uDF39", "\uD83C\uDF3C", "\uD83C\uDF34", "\uD83C\uDF32", "\uD83C\uDF33", // rose, daisy, palm, evergreen, deciduous
|
||||
"\uD83C\uDFE0", "\uD83C\uDFE2", "\uD83C\uDFE9", "\uD83D\uDC69", "\uD83D\uDE3A", // house, office, church (approximated), woman, smiley cat (approximated)
|
||||
"\uD83D\uDE38", "\uD83C\uDF0D", "\uD83C\uDF19", "\u2601\uFE0F", "\uD83D\uDD25", // grinning cat, globe, moon, cloud, fire
|
||||
"\uD83C\uDF4C", "\uD83C\uDF4E", "\uD83C\uDF53", "\uD83C\uDF5E", "\uD83C\uDF54", // banana, apple, strawberry, bread (approximated), hamburger (approximated)
|
||||
"\uD83C\uDF55", "\u2615", "\uD83C\uDF70", "\uD83D\uDC53", "\uD83D\uDC54", // pizza, coffee (approximated), cake (approximated), glasses, tie (approximated)
|
||||
"\uD83C\uDFA9", "\uD83D\uDD11", "\uD83C\uDFC6", "\u2764\uFE0F", "\uD83D\uDC8D", // top hat (approximated), key, trophy, heart, ring (approximated)
|
||||
"\uD83D\uDCF1", "\uD83D\uDCBB", "\uD83D\uDCE1", "\u2708\uFE0F", "\uD83D\uDE80", // phone, laptop, satellite, plane, rocket
|
||||
"\uD83D\uDE82", "\u2693", "\uD83D\uDE97", "\u26BD", "\uD83C\uDFB5", // train, anchor, car, soccer, music (approximated)
|
||||
"\uD83C\uDFB8", "\uD83C\uDFBA", "\uD83D\uDD14", "\u2648", "\u2649", // guitar, trumpet, bell (approximated), aries (approximated), taurus (approximated)
|
||||
"\u264A", "\u264B", // gemini, cancer (approximated)
|
||||
)
|
||||
return if (code in emojiMap.indices) emojiMap[code] else "\u2753" // question mark fallback
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.example.fluffytrix.ui.screens.verification
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.fluffytrix.data.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import net.folivo.trixnity.client.verification
|
||||
import net.folivo.trixnity.client.verification.ActiveDeviceVerification
|
||||
import net.folivo.trixnity.client.verification.ActiveSasVerificationState
|
||||
import net.folivo.trixnity.client.verification.ActiveVerificationState
|
||||
import net.folivo.trixnity.client.verification.SelfVerificationMethod
|
||||
import net.folivo.trixnity.client.verification.VerificationService.SelfVerificationMethods
|
||||
|
||||
sealed class VerificationUiState {
|
||||
data object Loading : VerificationUiState()
|
||||
data object AlreadyVerified : VerificationUiState()
|
||||
data object NoCrossSigning : VerificationUiState()
|
||||
data class MethodSelection(
|
||||
val hasDeviceVerification: Boolean,
|
||||
val hasRecoveryKey: Boolean,
|
||||
val hasPassphrase: Boolean,
|
||||
) : VerificationUiState()
|
||||
data object RecoveryKeyInput : VerificationUiState()
|
||||
data object PassphraseInput : VerificationUiState()
|
||||
data object WaitingForDevice : VerificationUiState()
|
||||
data class EmojiComparison(
|
||||
val emojis: List<Pair<Int, String>>,
|
||||
val decimals: List<Int>,
|
||||
) : VerificationUiState()
|
||||
data object VerificationDone : VerificationUiState()
|
||||
data class Error(val message: String) : VerificationUiState()
|
||||
}
|
||||
|
||||
class VerificationViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<VerificationUiState>(VerificationUiState.Loading)
|
||||
val uiState: StateFlow<VerificationUiState> = _uiState
|
||||
|
||||
private var selfVerificationMethods: SelfVerificationMethods? = null
|
||||
private var activeDeviceVerification: ActiveDeviceVerification? = null
|
||||
|
||||
init {
|
||||
loadVerificationMethods()
|
||||
}
|
||||
|
||||
private fun loadVerificationMethods() {
|
||||
val client = authRepository.getClient() ?: run {
|
||||
_uiState.value = VerificationUiState.Error("Not logged in")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
client.verification.getSelfVerificationMethods().collectLatest { methods ->
|
||||
selfVerificationMethods = methods
|
||||
when (methods) {
|
||||
is SelfVerificationMethods.PreconditionsNotMet ->
|
||||
_uiState.value = VerificationUiState.Loading
|
||||
|
||||
is SelfVerificationMethods.NoCrossSigningEnabled ->
|
||||
_uiState.value = VerificationUiState.NoCrossSigning
|
||||
|
||||
is SelfVerificationMethods.AlreadyCrossSigned ->
|
||||
_uiState.value = VerificationUiState.AlreadyVerified
|
||||
|
||||
is SelfVerificationMethods.CrossSigningEnabled -> {
|
||||
if (methods.methods.isEmpty()) {
|
||||
_uiState.value = VerificationUiState.NoCrossSigning
|
||||
} else {
|
||||
_uiState.value = VerificationUiState.MethodSelection(
|
||||
hasDeviceVerification = methods.methods.any {
|
||||
it is SelfVerificationMethod.CrossSignedDeviceVerification
|
||||
},
|
||||
hasRecoveryKey = methods.methods.any {
|
||||
it is SelfVerificationMethod.AesHmacSha2RecoveryKey
|
||||
},
|
||||
hasPassphrase = methods.methods.any {
|
||||
it is SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startDeviceVerification() {
|
||||
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
|
||||
val deviceMethod = methods.methods.filterIsInstance<SelfVerificationMethod.CrossSignedDeviceVerification>()
|
||||
.firstOrNull() ?: return
|
||||
|
||||
_uiState.value = VerificationUiState.WaitingForDevice
|
||||
|
||||
viewModelScope.launch {
|
||||
deviceMethod.createDeviceVerification()
|
||||
.onSuccess { verification ->
|
||||
activeDeviceVerification = verification
|
||||
observeVerificationState(verification)
|
||||
}
|
||||
.onFailure {
|
||||
_uiState.value = VerificationUiState.Error(it.message ?: "Failed to start verification")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeVerificationState(verification: ActiveDeviceVerification) {
|
||||
viewModelScope.launch {
|
||||
verification.state.collectLatest { state ->
|
||||
when (state) {
|
||||
is ActiveVerificationState.TheirRequest -> {
|
||||
state.ready()
|
||||
}
|
||||
|
||||
is ActiveVerificationState.Ready -> {
|
||||
state.start(net.folivo.trixnity.core.model.events.m.key.verification.VerificationMethod.Sas)
|
||||
}
|
||||
|
||||
is ActiveVerificationState.Start -> {
|
||||
val method = state.method
|
||||
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
||||
observeSasState(method)
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveVerificationState.Done -> {
|
||||
_uiState.value = VerificationUiState.VerificationDone
|
||||
}
|
||||
|
||||
is ActiveVerificationState.Cancel -> {
|
||||
_uiState.value = VerificationUiState.Error(
|
||||
"Verification cancelled: ${state.content.reason}"
|
||||
)
|
||||
}
|
||||
|
||||
else -> { /* OwnRequest, WaitForDone, etc - keep current state */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSasState(method: net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
||||
viewModelScope.launch {
|
||||
method.state.collectLatest { sasState ->
|
||||
when (sasState) {
|
||||
is ActiveSasVerificationState.TheirSasStart -> {
|
||||
sasState.accept()
|
||||
}
|
||||
|
||||
is ActiveSasVerificationState.ComparisonByUser -> {
|
||||
_uiState.value = VerificationUiState.EmojiComparison(
|
||||
emojis = sasState.emojis,
|
||||
decimals = sasState.decimal,
|
||||
)
|
||||
}
|
||||
|
||||
else -> { /* OwnSasStart, Accept, WaitForKeys, WaitForMacs */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmEmojiMatch() {
|
||||
val state = (_uiState.value as? VerificationUiState.EmojiComparison) ?: return
|
||||
viewModelScope.launch {
|
||||
val verification = activeDeviceVerification ?: return@launch
|
||||
val verState = verification.state.value
|
||||
if (verState is ActiveVerificationState.Start) {
|
||||
val method = verState.method
|
||||
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
||||
val sasState = method.state.value
|
||||
if (sasState is ActiveSasVerificationState.ComparisonByUser) {
|
||||
sasState.match()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rejectEmojiMatch() {
|
||||
viewModelScope.launch {
|
||||
val verification = activeDeviceVerification ?: return@launch
|
||||
val verState = verification.state.value
|
||||
if (verState is ActiveVerificationState.Start) {
|
||||
val method = verState.method
|
||||
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
||||
val sasState = method.state.value
|
||||
if (sasState is ActiveSasVerificationState.ComparisonByUser) {
|
||||
sasState.noMatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRecoveryKey() {
|
||||
_uiState.value = VerificationUiState.RecoveryKeyInput
|
||||
}
|
||||
|
||||
fun selectPassphrase() {
|
||||
_uiState.value = VerificationUiState.PassphraseInput
|
||||
}
|
||||
|
||||
fun verifyWithRecoveryKey(recoveryKey: String) {
|
||||
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
|
||||
val keyMethod = methods.methods.filterIsInstance<SelfVerificationMethod.AesHmacSha2RecoveryKey>()
|
||||
.firstOrNull() ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
keyMethod.verify(recoveryKey)
|
||||
.onSuccess {
|
||||
_uiState.value = VerificationUiState.VerificationDone
|
||||
}
|
||||
.onFailure {
|
||||
_uiState.value = VerificationUiState.Error(
|
||||
it.message ?: "Invalid recovery key"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyWithPassphrase(passphrase: String) {
|
||||
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
|
||||
val passphraseMethod = methods.methods
|
||||
.filterIsInstance<SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase>()
|
||||
.firstOrNull() ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
passphraseMethod.verify(passphrase)
|
||||
.onSuccess {
|
||||
_uiState.value = VerificationUiState.VerificationDone
|
||||
}
|
||||
.onFailure {
|
||||
_uiState.value = VerificationUiState.Error(
|
||||
it.message ?: "Invalid passphrase"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goBack() {
|
||||
loadVerificationMethods()
|
||||
}
|
||||
}
|
||||
33
app/src/main/java/com/example/fluffytrix/ui/theme/Color.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.example.fluffytrix.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Discord-inspired dark fallback palette
|
||||
val DarkPrimary = Color(0xFF5865F2)
|
||||
val DarkOnPrimary = Color(0xFFFFFFFF)
|
||||
val DarkPrimaryContainer = Color(0xFF4752C4)
|
||||
val DarkOnPrimaryContainer = Color(0xFFDDE1FF)
|
||||
val DarkSecondary = Color(0xFF72767D)
|
||||
val DarkOnSecondary = Color(0xFFFFFFFF)
|
||||
val DarkBackground = Color(0xFF313338)
|
||||
val DarkOnBackground = Color(0xFFDBDEE1)
|
||||
val DarkSurface = Color(0xFF2B2D31)
|
||||
val DarkOnSurface = Color(0xFFDBDEE1)
|
||||
val DarkSurfaceVariant = Color(0xFF1E1F22)
|
||||
val DarkOnSurfaceVariant = Color(0xFFB5BAC1)
|
||||
val DarkError = Color(0xFFED4245)
|
||||
|
||||
// Light fallback palette
|
||||
val LightPrimary = Color(0xFF5865F2)
|
||||
val LightOnPrimary = Color(0xFFFFFFFF)
|
||||
val LightPrimaryContainer = Color(0xFFE3E5FC)
|
||||
val LightOnPrimaryContainer = Color(0xFF2D3180)
|
||||
val LightSecondary = Color(0xFF4F5660)
|
||||
val LightOnSecondary = Color(0xFFFFFFFF)
|
||||
val LightBackground = Color(0xFFFFFFFF)
|
||||
val LightOnBackground = Color(0xFF060607)
|
||||
val LightSurface = Color(0xFFF2F3F5)
|
||||
val LightOnSurface = Color(0xFF060607)
|
||||
val LightSurfaceVariant = Color(0xFFE3E5E8)
|
||||
val LightOnSurfaceVariant = Color(0xFF4E5058)
|
||||
val LightError = Color(0xFFED4245)
|
||||
65
app/src/main/java/com/example/fluffytrix/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.example.fluffytrix.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = DarkPrimary,
|
||||
onPrimary = DarkOnPrimary,
|
||||
primaryContainer = DarkPrimaryContainer,
|
||||
onPrimaryContainer = DarkOnPrimaryContainer,
|
||||
secondary = DarkSecondary,
|
||||
onSecondary = DarkOnSecondary,
|
||||
background = DarkBackground,
|
||||
onBackground = DarkOnBackground,
|
||||
surface = DarkSurface,
|
||||
onSurface = DarkOnSurface,
|
||||
surfaceVariant = DarkSurfaceVariant,
|
||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||
error = DarkError,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = LightPrimary,
|
||||
onPrimary = LightOnPrimary,
|
||||
primaryContainer = LightPrimaryContainer,
|
||||
onPrimaryContainer = LightOnPrimaryContainer,
|
||||
secondary = LightSecondary,
|
||||
onSecondary = LightOnSecondary,
|
||||
background = LightBackground,
|
||||
onBackground = LightOnBackground,
|
||||
surface = LightSurface,
|
||||
onSurface = LightOnSurface,
|
||||
surfaceVariant = LightSurfaceVariant,
|
||||
onSurfaceVariant = LightOnSurfaceVariant,
|
||||
error = LightError,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun FluffytrixTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = FluffytrixTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
49
app/src/main/java/com/example/fluffytrix/ui/theme/Type.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.example.fluffytrix.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val FluffytrixTypography = Typography(
|
||||
headlineLarge = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 34.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
)
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
19
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hello World!"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
7
app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.Fluffytrix" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
</style>
|
||||
</resources>
|
||||
5
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Fluffytrix</string>
|
||||
</resources>
|
||||
9
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.Fluffytrix" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
</style>
|
||||
|
||||
<style name="Theme.Fluffytrix" parent="Base.Theme.Fluffytrix" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
17
app/src/test/java/com/example/fluffytrix/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.example.fluffytrix
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||