commit 42486ac5df0599433b26789e0c22c16b89d54fdf Author: mrfluffy Date: Fri Feb 20 13:46:31 2026 +0000 works diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3d34b07 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(JAVA_HOME=/nix/store/3xf2cjni3xqn10xnsa0cyvjmnd8sqg7b-openjdk-17.0.18+8 ./gradlew:*)", + "Bash(TERM=dumb curl:*)", + "WebSearch", + "WebFetch(domain:trixnity.gitlab.io)", + "WebFetch(domain:github.com)", + "WebFetch(domain:central.sonatype.com)", + "WebFetch(domain:gitlab.com)", + "Bash(JAVA_HOME=/nix/store/3xf2cjni3xqn10xnsa0cyvjmnd8sqg7b-openjdk-17.0.18+8 jar tf:*)", + "Bash(unzip:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8093080 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with Kotlin, targeting Android 14+ (minSdk 34, targetSdk 36, compileSdk 36). + +## Build Commands + +```bash +./gradlew assembleDebug # Build debug APK +./gradlew assembleRelease # Build release APK +./gradlew test # Run unit tests +./gradlew connectedAndroidTest # Run instrumented tests +./gradlew app:testDebugUnitTest # Run unit tests for debug variant only +``` + +## Architecture + +- **Package**: `com.example.fluffytrix` +- **Build system**: Gradle with Kotlin DSL, version catalog at `gradle/libs.versions.toml` +- **AGP**: 9.0.1 +- **Java compatibility**: 11 (configured in `app/build.gradle.kts`) +- Single module (`:app`) + +## Key Files + +- `gradle/libs.versions.toml` — all dependency versions and library declarations +- `app/build.gradle.kts` — app module build config +- `app/src/main/java/com/example/fluffytrix/` — Kotlin sources +- `app/src/main/res/` — Android resources (layouts, themes, drawables) + +## Design Intent + +- Discord-like layout: space sidebar → channel list → message area → member list +- Static channel ordering (never auto-sort by recency) +- Material You (Material 3 dynamic colors) theming +- Trixnity SDK for Matrix protocol +- Jetpack Compose for UI diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e3315a4 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,120 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.example.fluffytrix" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "com.example.fluffytrix" + minSdk = 34 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + // Compose is extremely slow in unoptimized debug builds. + // R8 with isDebuggable keeps debuggability but strips the massive + // material-icons-extended library and optimizes Compose codegen. + isMinifyEnabled = true + isShrinkResources = true + isDebuggable = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.getByName("debug") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + } + packaging { + dex { + useLegacyPackaging = true + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + + // Compose + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.icons.extended) + implementation(libs.compose.foundation) + implementation(libs.activity.compose) + implementation(libs.navigation.compose) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.compose) + debugImplementation(libs.compose.ui.tooling) + + // Koin + implementation(libs.koin.android) + implementation(libs.koin.compose) + + // DataStore + implementation(libs.datastore.preferences) + + // Coroutines + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + + // Trixnity + implementation(libs.trixnity.client) { + exclude(group = "net.folivo", module = "trixnity-olm-jvm") + } + implementation(libs.trixnity.clientserverapi.client) { + exclude(group = "net.folivo", module = "trixnity-olm-jvm") + } + implementation(libs.trixnity.olm) + implementation(libs.trixnity.client.repository.room) { + exclude(group = "net.folivo", module = "trixnity-olm-jvm") + } + + // Ktor engine for Trixnity + implementation(libs.ktor.client.okhttp) + + // Coil (image loading) + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + + // Media3 (video playback) + implementation(libs.media3.exoplayer) + implementation(libs.media3.ui) + + // Markdown renderer + implementation(libs.markdown.renderer.core) + implementation(libs.markdown.renderer.m3) + implementation(libs.markdown.renderer.code) + implementation(libs.markdown.renderer.coil3) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..ee46bb5 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,53 @@ +# Keep line numbers for debugging stack traces +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# kotlinx.serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.** +-keepclassmembers class kotlinx.serialization.json.** { *** Companion; } +-keepclasseswithmembers class kotlinx.serialization.json.** { + kotlinx.serialization.KSerializer serializer(...); +} +-keep,includedescriptorclasses class com.example.fluffytrix.**$$serializer { *; } +-keepclassmembers class com.example.fluffytrix.** { + *** Companion; +} +-keepclasseswithmembers class com.example.fluffytrix.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Trixnity — keep all SDK classes (uses reflection/serialization heavily) +-keep class net.folivo.trixnity.** { *; } +-dontwarn net.folivo.trixnity.** + +# Ktor +-keep class io.ktor.** { *; } +-dontwarn io.ktor.** + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** + +# Koin +-keep class org.koin.** { *; } + +# Coil +-keep class coil3.** { *; } + +# JNA (used by Trixnity OLM bindings) +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } +-dontwarn com.sun.jna.** + +# Media3 / ExoPlayer +-keep class androidx.media3.** { *; } +-dontwarn androidx.media3.** + +# Markdown renderer & syntax highlighting +-keep class com.mikepenz.markdown.** { *; } +-keep class dev.snipme.highlights.** { *; } +-dontwarn dev.snipme.highlights.** + +# Olm native library +-keep class org.matrix.olm.** { *; } diff --git a/app/src/androidTest/java/com/example/fluffytrix/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/fluffytrix/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..3df35a7 --- /dev/null +++ b/app/src/androidTest/java/com/example/fluffytrix/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d188c5d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt b/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt new file mode 100644 index 0000000..9c2b6c0 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt @@ -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() + } +} diff --git a/app/src/main/java/com/example/fluffytrix/MainActivity.kt b/app/src/main/java/com/example/fluffytrix/MainActivity.kt new file mode 100644 index 0000000..ce144d8 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/MainActivity.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/data/MxcUrlHelper.kt b/app/src/main/java/com/example/fluffytrix/data/MxcUrlHelper.kt new file mode 100644 index 0000000..cc738da --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/data/MxcUrlHelper.kt @@ -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" + } +} diff --git a/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt b/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt new file mode 100644 index 0000000..c304c16 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt @@ -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 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 = context.dataStore.data.map { prefs -> + prefs[KEY_IS_LOGGED_IN] == true + } + + val accessToken: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_ACCESS_TOKEN] + } + + val userId: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_USER_ID] + } + + val homeserverUrl: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_HOMESERVER_URL] + } + + val username: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_USERNAME] + } + + val password: Flow = 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>> = context.dataStore.data.map { prefs -> + val raw = prefs[KEY_CHANNEL_ORDER] ?: return@map emptyMap() + try { + Json.decodeFromString>>(raw) + } catch (_: Exception) { + emptyMap() + } + } + + suspend fun saveChannelOrder(spaceId: String, roomIds: List) { + context.dataStore.edit { prefs -> + val existing = prefs[KEY_CHANNEL_ORDER]?.let { + try { Json.decodeFromString>>(it) } catch (_: Exception) { emptyMap() } + } ?: emptyMap() + prefs[KEY_CHANNEL_ORDER] = Json.encodeToString(existing + (spaceId to roomIds)) + } + } + + suspend fun clearSession() { + context.dataStore.edit { it.clear() } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/data/model/AuthState.kt b/app/src/main/java/com/example/fluffytrix/data/model/AuthState.kt new file mode 100644 index 0000000..e538456 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/data/model/AuthState.kt @@ -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() +} diff --git a/app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt b/app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt new file mode 100644 index 0000000..2a649c2 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt @@ -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 { + 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() + 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() + 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() + } +} diff --git a/app/src/main/java/com/example/fluffytrix/di/AppModule.kt b/app/src/main/java/com/example/fluffytrix/di/AppModule.kt new file mode 100644 index 0000000..70c585c --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/di/AppModule.kt @@ -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()) } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt b/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt new file mode 100644 index 0000000..d580d6f --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt @@ -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 } + } + } + ) + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/navigation/Screen.kt b/app/src/main/java/com/example/fluffytrix/ui/navigation/Screen.kt new file mode 100644 index 0000000..240288c --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/navigation/Screen.kt @@ -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") +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginScreen.kt new file mode 100644 index 0000000..ea681c9 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginScreen.kt @@ -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, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginViewModel.kt new file mode 100644 index 0000000..e7621a5 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginViewModel.kt @@ -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.Idle) + val authState: StateFlow = _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") }, + ) + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt new file mode 100644 index 0000000..6c67d07 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -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) }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt new file mode 100644 index 0000000..37233d2 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -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 = 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>(emptyList()) + val spaces: StateFlow> = _spaces + + private val _channels = MutableStateFlow>(emptyList()) + val channels: StateFlow> = _channels + + private val _selectedSpace = MutableStateFlow(null) + val selectedSpace: StateFlow = _selectedSpace + + private val _selectedChannel = MutableStateFlow(null) + val selectedChannel: StateFlow = _selectedChannel + + private val _showChannelList = MutableStateFlow(true) + val showChannelList: StateFlow = _showChannelList + + private val _showMemberList = MutableStateFlow(false) + val showMemberList: StateFlow = _showMemberList + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + private val _members = MutableStateFlow>(emptyList()) + val members: StateFlow> = _members + + private val _channelName = MutableStateFlow(null) + val channelName: StateFlow = _channelName + + private val _isReorderMode = MutableStateFlow(false) + val isReorderMode: StateFlow = _isReorderMode + + private val _channelOrderMap = MutableStateFlow>>(emptyMap()) + + private val _allChannelRooms = MutableStateFlow>(emptyList()) + private val _spaceChildren = MutableStateFlow?>(null) + + // Per-room caches + private val messageCache = mutableMapOf>() + private val messageIds = mutableMapOf>() + private val memberCache = mutableMapOf>() + private val channelNameCache = mutableMapOf() + private val senderAvatarCache = mutableMapOf() + private val senderNameCache = mutableMapOf() + + // Room data cache — avoid re-resolving unchanged rooms + private var cachedRoomData = mapOf() + + 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() + 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, 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() + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt new file mode 100644 index 0000000..f950a73 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt @@ -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, + 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, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt new file mode 100644 index 0000000..127dde2 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt @@ -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, + 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, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt new file mode 100644 index 0000000..99d8657 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -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, + onToggleMemberList: () -> Unit, + onSendMessage: (String) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + var fullscreenImageUrl by remember { mutableStateOf(null) } + var fullscreenVideoUrl by remember { mutableStateOf(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, + ) + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt new file mode 100644 index 0000000..9aa33ec --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt @@ -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, + 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, + ) +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationScreen.kt new file mode 100644 index 0000000..f372d8d --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationScreen.kt @@ -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>, + decimals: List, + 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 +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationViewModel.kt new file mode 100644 index 0000000..b30c770 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationViewModel.kt @@ -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>, + val decimals: List, + ) : VerificationUiState() + data object VerificationDone : VerificationUiState() + data class Error(val message: String) : VerificationUiState() +} + +class VerificationViewModel( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(VerificationUiState.Loading) + val uiState: StateFlow = _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() + .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() + .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() + .firstOrNull() ?: return + + viewModelScope.launch { + passphraseMethod.verify(passphrase) + .onSuccess { + _uiState.value = VerificationUiState.VerificationDone + } + .onFailure { + _uiState.value = VerificationUiState.Error( + it.message ?: "Invalid passphrase" + ) + } + } + } + + fun goBack() { + loadVerificationMethods() + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/theme/Color.kt b/app/src/main/java/com/example/fluffytrix/ui/theme/Color.kt new file mode 100644 index 0000000..57f83f5 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/example/fluffytrix/ui/theme/Theme.kt b/app/src/main/java/com/example/fluffytrix/ui/theme/Theme.kt new file mode 100644 index 0000000..ea0dfd6 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/theme/Type.kt b/app/src/main/java/com/example/fluffytrix/ui/theme/Type.kt new file mode 100644 index 0000000..6409b60 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/theme/Type.kt @@ -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, + ), +) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..86a5d97 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..494500d --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c8524cd --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b3ee9fe --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Fluffytrix + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2dddc8d --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +