From 42486ac5df0599433b26789e0c22c16b89d54fdf Mon Sep 17 00:00:00 2001 From: mrfluffy Date: Fri, 20 Feb 2026 13:46:31 +0000 Subject: [PATCH] works --- .claude/settings.local.json | 15 + .gitignore | 15 + .idea/.gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 10 + .idea/gradle.xml | 19 + .idea/migrations.xml | 10 + .idea/misc.xml | 10 + .idea/runConfigurations.xml | 17 + CLAUDE.md | 40 ++ app/.gitignore | 1 + app/build.gradle.kts | 120 ++++ app/proguard-rules.pro | 53 ++ .../fluffytrix/ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 28 + .../fluffytrix/FluffytrixApplication.kt | 62 ++ .../com/example/fluffytrix/MainActivity.kt | 20 + .../example/fluffytrix/data/MxcUrlHelper.kt | 28 + .../data/local/PreferencesManager.kt | 88 +++ .../fluffytrix/data/model/AuthState.kt | 8 + .../data/repository/AuthRepository.kt | 106 ++++ .../com/example/fluffytrix/di/AppModule.kt | 20 + .../ui/navigation/FluffytrixNavigation.kt | 150 +++++ .../fluffytrix/ui/navigation/Screen.kt | 7 + .../ui/screens/login/LoginScreen.kt | 134 +++++ .../ui/screens/login/LoginViewModel.kt | 42 ++ .../fluffytrix/ui/screens/main/MainScreen.kt | 99 +++ .../ui/screens/main/MainViewModel.kt | 538 +++++++++++++++++ .../ui/screens/main/components/ChannelList.kt | 223 +++++++ .../ui/screens/main/components/MemberList.kt | 99 +++ .../main/components/MessageTimeline.kt | 569 ++++++++++++++++++ .../ui/screens/main/components/SpaceList.kt | 134 +++++ .../verification/VerificationScreen.kt | 539 +++++++++++++++++ .../verification/VerificationViewModel.kt | 248 ++++++++ .../com/example/fluffytrix/ui/theme/Color.kt | 33 + .../com/example/fluffytrix/ui/theme/Theme.kt | 65 ++ .../com/example/fluffytrix/ui/theme/Type.kt | 49 ++ .../res/drawable/ic_launcher_background.xml | 170 ++++++ .../res/drawable/ic_launcher_foreground.xml | 30 + app/src/main/res/layout/activity_main.xml | 19 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values-night/themes.xml | 7 + app/src/main/res/values/colors.xml | 5 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 9 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../com/example/fluffytrix/ExampleUnitTest.kt | 17 + build.gradle.kts | 5 + gradle.properties | 23 + gradle/libs.versions.toml | 85 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 ++++++++ gradlew.bat | 94 +++ settings.gradle.kts | 24 + 68 files changed, 4433 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 CLAUDE.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/example/fluffytrix/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt create mode 100644 app/src/main/java/com/example/fluffytrix/MainActivity.kt create mode 100644 app/src/main/java/com/example/fluffytrix/data/MxcUrlHelper.kt create mode 100644 app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt create mode 100644 app/src/main/java/com/example/fluffytrix/data/model/AuthState.kt create mode 100644 app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/example/fluffytrix/di/AppModule.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/navigation/Screen.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginScreen.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginViewModel.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationScreen.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationViewModel.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/theme/Color.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/example/fluffytrix/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/com/example/fluffytrix/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts 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 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmWIYbaN|VWnc(*bqWXzu!!JdU|xt`5!v_jjvkEam13D%HG@k-IMc zNLB6Ts|;5bzSwJe>ULbl1cvf&ZkhaCoSq2>@AZ5-D>jj1lIT2H&dag$gn7N+e4L{u z^V=qU9`n4<|KGj+Jg1Vc;+*Ym8}@ms^PajGxhz`fUcGqB^ot%&0-(G6 ztJqa}H}n3HU3Yip`SGVeoN_0jHpk{$^4G>a6%8p%x7&Z)J5^*02Bzg(emT zh6~~ha~ZWf7(5v43`IJe7AY!6)06s|5LTRZ~a|9(#3-?A(9e&YZ4?>X2s?GW>o|33SDelz`B z`+wn|Q;!w?=CKR)$n9qK&JB=|i?B8=Za(_yby{8ahEC_FQc?|fX6jq-S#{;T*P>@x z_ZT?env#-TMm+56J zYxbSv3`wunSg8L0QM8}=O*rp?-VOhM|NEg<)7%*xHvh-siA*kcCjMS>GB?P(>_@}3 z)Ft&xoDRNt&3eFh)jsaGt6V1Y-utsGX3D{BPrGiMe#bFC=cA;8JMEoLi`oHXxI)DE^oHMbkaEEw% z-9tKtyZ;ro&wXg4x@O8`m1O~q z?MexGg12&nX4y|S@pnFV^V}u9giQ-GT$3$KnU6R18RkeC3%K&!v-YvBIofAmw>PFs zIdS)#VNole5AMDC;{Wfgucb?`mgwZOH*=)K)xFxh zvAbsbOYcV^yis0yb^(2L-iLG^EU>@ZcYlA->$iWR*2l$qNu~2Y6n&DlA@j?-eeQ>s z$tnK&Z05FhmCbjTD+dy9O%PYyrL7Vlbht#oNZ2QS;ASZ@?QSi@KIA`)Q{Plar-EBMn zOue{8S#RsoI9{I8-1%wjYL3ra-WXN>myuB|k`TLaRw0(}Q{v*)neU%|Uf=%kE$@XZ zhG9pKE&f~)a@RGze*OGS1~2BsylnCL*)X3i|99J|eC4QK#}-~?>BH&Aafd zWjZOh{NVc@|Mx8WeQ&M$PqIskKh9jN|JQv<&;0)q`yZ~E&-DGzd;15MW#|73p4FDw z7Oym+LiWGhsn`4Zdv7t_;1XiVJF+=y-{rn5k2YR9d}BwU-%Zx7^dl3uQO%=KtB6@_8OJ`?s99PuHc(Wv{Uvn57cQnzH^! z4YT&W4>v_49d0g>;5}1QsqLw*bguIK-eRRko_V#EY72aobT&L-pQO|$YAra)UWXy) z?H0HH>?%h$pNjLSyTHFTTZD1@o5kYirK&#Ospl5Bs^WBcdditu$Df4zc4P@8o+&!k z$5~=Hz27y&v^)8mahg-|JA1KhUV`UTT02EQ$+(1e7MN)IY?yFdc~#TV8Gl98qIT3X z<+w`qp4Rl)QQ*p5k{WN6_9(e-vDU^NGYy%0D%Eo$*=nm_Iu+cp7Fo9EyM*zH8S<-( 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 AkN^Mx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 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

( 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9287f5083623b375139afb391af71cc533a7dd37 GIT binary patch literal 5914 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

0vl^(ubK57i3$j`RXVdItc2Zu zTjP%3T{qqDyHu$1wf>en`}f$lI?l)hfy+`aCTxu8`Mp4Wt8~lzsk7fIKUzdbU&D@u>@#|8JoKt2mbv8|~*&2E%ct_*EIJ>)7o&PKE5_$1H zYTdgu^~m!0A4{%YZ`c&qZ)$yr|AXm!sqXjcKR?_Pn|Ao^jNS9)1$Q|wns@3F%fsvg zwSO(SHf}Wd&gY@5dZETG=E3!B4m$1-}j)^|n6tq;mOd3k8c0DH_GHdj+bC#}?wC!#j7mQS#vf_fXiT$x+$0-Ud(~5MTubmb>WqqsT;k{xpsIQhc5wmzv^leMXZ>rUpkvod!E z%4X!{CT@DfUb}1l9;OF%&cZj$=~`?izExW$`!W#MmrU&bU9x zY4?Y|ihvD`aVe?!Jd*bn>a_Ljw%A+cxQM63#5tgD>7pvIfk zXZB`A(+B?|xK;mhgAxy6j8m5a|tYYQtz-&)`wciH>w!z4Jl6EbdLNgW&t4n&Z^`r` zg&vWF3I>a?I;mM9pQdx?h+g`4^#9q@#m5&cUv`wUW67(N(f8cqnde#^*{|z4`#8&% z$(*MTuC#KwaPVof`>D`&U$yg@H74@YWB+XNzOk9HzHI)?S*96(9jadGnl+RhV?HYU zTPymOU!2^Y9Y;SYsn1QEpStG70rM^O4*D71J+(bX{#n9KLDg&XW+cq#d%E33PUWv_ zkfOTshO5hBt#1C8*W^yL{vnXQVRqiLErQMrj6U1<_5@j|Em*A4E4-te^ZzpYDI0B` zXg;x>w9{g(u&mK%{^Fz8n{xBlOZuI;!E!86S+BwIO2Xl&Fh%#ZoBUQU_O0g4%ANk> z0Jmq=?VG;}VuQcWs7ieBJAdXY(fhi`7ADvReRQ>AUivA=PwM}&`iLJJJJZ{_&k3Y2 zUvhnu->Tx7bEg!!9%6moQp;o7lU20XJ=QDEK<4}Dx`6F-nzAlROmUdnwpX%JBgyHE&EJd_^1F8lZE|Tb`4Z9o=_5<>!KzQ} zGZoDj*B>l-_{cMi-TF`Qmi1X-=QLK8d3<{luV|^Id8CphQgh85osBxngzXY9@84_h z%f`4XHqy%Sk8jJOIRCXp`#HYZ9tjb8r7sFp_47S~Y8LMZ0|ACD*T9*?vNtsq>c#FZ;4AF83!N-}7Zh7R+l= zw!4tMaFcX!P3Els8SK9LLhRyJK z+&t%0aQM50`k&8S%~Uwp?rm21|LFNWlV+G(ACydR-+6qUWz*kv3iZ{C%If{&>euW) z@@W5$w|~FeitEW*v$L+4y5z_58%Bq(G3{FX-GAnci|2Se?q;?>GnLW&F(+=#|9Z#w z-#_o;-&VrrqOwwO%B-^WHLF&}EjZj>w|%nOHqFIdHlY~LM|@kt^cB?v?aRN!JSJaHjLeW zb$GNz$NM?U8z(oNo3`r8e%lS3CEre$zVUyZ&{wym*PqV*QMTjIf~G4kJUQ0C+9_cC zLSfq{(=7&@u0G04(^)DtZ};(To{cq(j|-+htN*jEpuxFk0?Ru8XP3^z-&SNkURt^2 zTSfZcN5x^>ML|ky1rH}oViJ7+@cetDleV!hBksSwEn~4q@Xz`GmCrjHOK)GEd0p-B z(Y6SS8poDMuE(=E|4rGmcvp9MsGHU9V8)Ek>HE*irk%F9rK7RU-2N}yiz$uUO57Qx zXRAv!tdNVQd!7^~xNg;>=ojmFGC8tun)m$oP3PKhc*onS@K5?rr^}yu zUmUrRUESJJ=>F0}m;TMP>i;SjwwR~)_nQ*mc_z*-WwSXs6&fs_bqPz9s|2|0xg0$2 t@u$Pda=rYAtrOikX7%&GRsG&;Z1Lh{&yL;Ao6ROYUN$pxVgmyL003>zdZqvX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9126ae37cbc3587421d6889eadd1d91fbf1994d4 GIT binary patch literal 7778 zcmWIYbaRW6V_*n(bqWXzu!!JdU|~fj$$Xu^|Mi2edtS{o z3zt<=OJK4|yuEEt?zY_9b#taO@tJ$hoLOybY}_OF?BQpgnQeb;7N=LHojG&Hf9A}c zrI*?+D!T7Fmn?syG$+#PxS%1U)_-9yCvT%|w>`KopDb!S#C_XU$WKf@=gx+_zrRXT z53D#h-_p4L)sv2+&NuS+7paLN;IC()Hr()%!obv0rzJ5_+5y&6qC#>-XwH zdBff=tzT#K|D5?5AbT+DOi1JWpRMORRvG0wO`ZAv=TCVX`Rg2E`DqXT$p88Ie&#~0 zWu7b2KlA_jv3y}#$JMuMKD?Cwe^B>JSLVVMIX~vf?`HlUcw|+0=e;{07vJC8C^d^K z@S=JBleC!b856Xw-ua_^U%*steu~{c=J!VGQhnwz6hFa(Vi!pCRcH_m2y@S7sPA-Cnm$-{T?E9+&5Z&FTHg zQWwwIp51XF8R^9|31&J6&&B6`rPp0x#=rS(w}bqpJJS|38JC z6gye;OJ6oQpz=w*=PZ+*-{pRZ7JqOr$rCRB6WJDi=KnW_scvg+zLm$Vd~@y}-`wb< zvh_7t6SRZ>hWk%2$^LXle&;%mvgP&NkID)kmiLvHs;>I~#!-tmy7E!^TbY*>=rW?H)aeL2fYUep<9n1K@xyd9@ZTqdd znOyr)*w60C<(*{oF)u*caak-=*OaHWDc9Z9CI_oIdtUh@x5i9Jz1?cZ5|5AH47c3PD(t7x#Dy?TvP=>3%#T_G{Y?Ac-p zPcFrHDhV>?=`vTT>s-IKYFgx?6*r^aT?|Sv$yu9te$v?&IoZ8!6`BV2_oLF!Z%8q& z-?jDJoMYaee)DtISr%#DC|+Bdl%ao+k8jI0!S=s?3h#5*Hj8<0-NV0i%8l0_f;`^4 z=Bb~qo~v`SF1v5qqbGUQo__DGeS*)Qwc5)vdEV@!Z&SX>6+S9w(vDxeGX1<(Jz|_VI&%omZXRX_dYAe?ydq`@1zW zpT*>U|NF+-AH2ExpZUbKlMnvyO`7s-jw);Wx?@?=GgtFjsLYk_Sa*NZ z;@WL-72BM3)^oY%O}Kq*S5}{&&T5ufxA(1El^SwAu7YF2H0FSdCto)$>(zFDb)?99 zL%#cxTdpg)%ak)`vXoerre4@k=z0FYf~Slv<@xGYwYT4cZT)zC* zmWvjRZ{JAXtDUs6{?)t6ubHz8%hSGDm0qv1IeTW;?q@HIFZayfd~ISMA6KyLHMzdP zwVB7_Q?6Mx@tLe`VQPyk4sD3dmURsKU}ALT$l`rrv#(}0q_XL2b^_m+9XvSLe>)@eowqbxE`4^3PS5Sc=&;_ew=|XcS{tA3TfL}} zMPY*IqaUB;|9+7bXXLyjaZ}*Hz2)!zG)ErJxY6%c8-Lm0r5Kk(gL2TWAI|doUMe+g zSz@?hEyIo9hn)HMJq_V&Uwkp+%JnU|%N9gjNigbC)vG=Kd;R_jHU-@Y(@wH!Gx)u) z{JHV?y-jQD>P%#K-D~5G&u+QGbf9gWbc?Fl{fgJU;V~OJUEiiojQDco-_7asYCl?R z+a%xHcKG3n_u0#4$}K22h`l~_o%XgRzTt7TZ&k(f=0~0{nr~TKasT%EeIJjV+_Kba z)zt1_>Ck0G#I`S)bdhCwBf7Z@6TN4|+ODX2J)f{`<@;47A z?#-9Aoh#A)S|dCD_dfaFUv`&gywpCRw{*(7DeGKbDzPXybZs@xzUDH)Qf8jrY_kN0 z^lzrw@!9d&Z-4tFFWcrUnEFv;G7uFl_U)ldH{%ZZU-4|#=6+Q zm-jCYxzF))N&U93kN0m&m%n0v@$cc9b$^{-B)`1>$L^v1nf+`3@BGjB`{FnM@>%8T!K87bYr%qW{Eb;kt|L?J%8caWD^Gws>7o5ns zJy!M35sw~)7gCQ6xfJ9*X3R)#n;opp!@nTO(01y_o7bvbi@Q$f?fSTFmc2!A{@m0R zp}AW6hc_47|G&KcE(g2AhwCf9UEjt$$vQE~RP{HUsZP~pD*vP&t0AUt7Kw-c7|MLV}E`$-TXk|vWfS2gw7eR-BcHJhTUyS^|R0W z*e@Hdp1ImPYkrG`()84b&vJ8<!-md1-1~>C(AfXExT|GN>xsuXB34_^wwgGWZ0=J(IQeX>cUQC5iKu z{YYvQT>tK*$*#67&#u z5!!WYN0d~T_ZuVbj_gVSDW%6V7bNh>|9`giqi)f=#cnP;wmjLD)um{TVSb zW*Ein90X!KZ2wwLQxMQ!~1JdLv?=-7)K{_|6iAZVPp;Y|P7yu$lHhDHAa4aAirVHQIAMw=65ffmkG>@m=uo-9ruW1qo~uon;&N}nuEOPqW=I^|&^f%{!$IR{5PsE`ltN>8neY&0hQw7$W&eZuciktXEd8_bZ3JiezNxAFc5UB< zIP6+KH#zRk+#}I7*~ew>=QpfLZtfMmS1wL_>fN-zR#KknztNoA+?r0)_RG$h{UUIc z4~Nq7!=VQ8&vtG4=N`YJvv+adCjP@-S%r)q+HanGW;uF$*Qp%_%kRY0u2o(7-P($M zn~vnt)`mz~-AYA^k1wK+mVfqsCN$gps`EcpArI$S>v`nYWQhPF4p^5uQ_iZ>!3d2L)euumAUd+3jTCNN#I^ z%`-QfZhgErtw;Osudi2IZ!v<7C~UGn zdg|kYniTIR(yQuJw$zjf*G@VT@MDKX2cO)#7YFnLPK#YDI-k02v-$bgyK+^n&YYOw z-Pk^B#rfoy+_U1RRkB~cIoagsh6zu?BywN-B>UQ%Zrt|&zt_z-ua$)!EA3To;NLCR z@>Od7%k!!4XZ-ct;{I*>nP1+L=Gn7PmIlrJ^y(S+nrELCH7cAKv)q<7rg$6(GOqd8 zdsxN=#v`xO?ai&N38usn>#v=Q(1qJ;aQvV-`H`q1L;OhC= zr&pUvP0ClPbiAp;wP63O9)`V#k9SY_x@Gf({i*%ov%ZE%SING7>(&r&z?rAcX;Zyz zd-gjY?!IL8ld@^ce|4~3+N;-fZ*$^muSfdYTwB;4vS=w(Uw*Uw{n|f&)iyB&B>jIo z_2<8P6+4?=Zhy1V@6?SA3p?KQrO!}*;If!)OGCwiULxPi>;nqB)*-B5XAh+V-q^TAjFc z{d(a~??Nm7FR;%F_;E!;*ni%4vB(1tMDDhDg*^SqV0`u5@43Y+2TprU@X762CgC6O z^*HOEC(rN9JN`O^?e}?$+T;_?!Uw)wz9h@ltSaP}cAC>{uD182&D(q?FTQELW?tcp z;NN?{zADODo!4ja&}YfY|Ar=PpN{_5kxLaYtPD)qWB>pEGtC$-qZd1OxLvOJy5VP; zm+B7DsFlULv^V62DXbKbkL#YWJ)(B`n*wR!f-B4S@BcXIX~^#S3kN64eOT(cTKDkg zmpfxmFIoF>{?@0bB^=kTnf7_UK~#3xjg9Y?@}AtX+s0*P^{0C?ttK{n^>{OL4paT{ zy_=8a3;W;D_7J~axGha7{Bh8s`;3zd-(0`FsqkXMM+k6=N85=GsfkYIg=I? z)@3I6&P>@G%D!WgtK#NB{RaWX%MbLJu8RM+;p<(y(mO`x0lcNlPkejK!0!0|_#`cX z*Gq1fiWEn+|C5zmW?l?!(5oK%;$rP@1oXV{%M#t;{YHG(;%>!hC^D?R-3Y*gQG zJM($(PPpt~Uth6h)d8F3t=^$b+>;a-76&Z*ANXyu|0lbK6}sO=*0Bg(=iBmhWzLkW zmACjC{&|Hbc3wN2dAnP>-rwt~`5e2y*Ju4bu>GRy|GBr;9Q;|eD$nz3{PEskkNcjB z_Q^fTXe!oKyeP3Z(S6tBJ>Sifd@uSqC|4bfEaL zb#MD)&d-pc%eG;D&Eo4HrmI|?xF-IB!|4YT9ta8ZWmh>BmZg5#cW~CrgIWzT6K%~! zj=lT!KYRWMD%YUuVefwa;3%Z78kle|MS{R#{&8{2FpXKOE(p zlu|ypPEgy%^X}aLC2KETso7t0pnA^N^`g(>1b^N0-?nzmlCljwM&>s*%j^~Q&72pm z&9SL=z3Nxh-G%#kFI@G1+M_;2<)!H{KE`~ONe+uSwluuGSF`DJ^x390-Qj7Mq91Lj z{rmFJ$^A0lE5eW6eOLPEzD`%zS?!U-`2o{n$-J#jYN#>Tzd(riU$lYj~z89e8|xtH_7P zTwmUv){`;+-1JAi_=I%so~NppmaJ2Fb|K)SjsA|l3$;$iKfStpFgdHUza@tKZSz)f zqbJg3i|$TIapN!B%c`%y(0jY0eahG0eI?&4Up;x#6|*b0LrJ~E&23S!kFuL>yPKNU z!i?=_xaTXL7HQqQY3fd;?pL_)HiD#@nbXzFy!q)tcYknU3DKXJ+sln>~d$u}n z@tU(c$ZT?Om`vhs*K;dp@T`0NC*YGFi~0EpiIV&pXHID98aVx|pR~y8(!-|UQor`g zZr9E#_s8gSR-C&&Dcm-;vo7%51+D*kOxS(Z!k@6b3HWL;D|b=xu}7c(6wZCZ zBKqvO{>J3yNTV~p^QAfz?3zOlJ%8-O-Z)> zD>`dc)1GU;$*YUb3)Y!Y%FM+1zj6Q053ZJbr~Lc%Jb%BuUTu@$I;Z%miX%%(+)R;DJkz{4{m6{`4u8Az+j$f?&MO;ecYWt0!nMbbpPuuV# z?cCH9g~`V5PPG%xN*N1e{#o*Z_*{KefBxBq9wyhv4;fE!@tLi!^1Qfy52HlAnxV>V=4_q^ zT7G^V8(zF%V7M^VLbtWPt?C*7`nZtooA!5Zp5JyM@obgMOS_$#()HVSOn2C4weZlH z^!zjX7Z;~oJ7S@IdF%JPvE`R7y(BH&*qQshvO|P&1$&Xr5&(*^x z?&2$R@w(^CS#Lb2D_o7S>XB_aUn5f+In#$-Mep#MtQARlI|ZkQ8Hl%SHQYD%Wv|J5 z9)`PH*tZ%=t$OMkyF+-zm-LOE(mN{6A~q!NzfkdDmUvm7+tCNVx8^?EB=6(N>UN{^ zql{v2cTu#ey1?1KZyOvZT#LMVHv0X4(WFAf@1lM6fl>By+#fcEw(4moW;GmJ8K-W( zZqjD%6-skge|5%77e2)&D{F4{qA_b88B-_1;gPPB+@C xnhRWv`IUK*^+E0R8{Q5smc^CM&d>M1G5EHH$7b>Dkbv658Y;W>pIu-80{{_~QQ!ao literal 0 HcmV?d00001 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 @@ + + + + +