works
15
.claude/settings.local.json
Normal file
@@ -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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.gitignore
vendored
Normal file
@@ -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
|
||||||
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
19
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectMigrations">
|
||||||
|
<option name="MigrateToGradleLocalJavaHome">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
40
CLAUDE.md
Normal file
@@ -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
|
||||||
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
120
app/build.gradle.kts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
53
app/proguard-rules.pro
vendored
Normal file
@@ -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.** { *; }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.fluffytrix
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("com.example.fluffytrix", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".FluffytrixApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Fluffytrix">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.example.fluffytrix
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.disk.DiskCache
|
||||||
|
import coil3.disk.directory
|
||||||
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import com.example.fluffytrix.di.appModule
|
||||||
|
import com.example.fluffytrix.di.dataModule
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
|
class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
startKoin {
|
||||||
|
androidContext(this@FluffytrixApplication)
|
||||||
|
modules(appModule, dataModule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(context: coil3.PlatformContext): ImageLoader {
|
||||||
|
val authRepository: AuthRepository by inject()
|
||||||
|
val okHttpClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
val token = authRepository.getAccessToken()
|
||||||
|
if (token != null && request.url.encodedPath.contains("/_matrix/")) {
|
||||||
|
chain.proceed(
|
||||||
|
request.newBuilder()
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
||||||
|
}
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder()
|
||||||
|
.maxSizePercent(context, 0.25)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(cacheDir.resolve("image_cache"))
|
||||||
|
.maxSizeBytes(50L * 1024 * 1024) // 50 MB
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/src/main/java/com/example/fluffytrix/MainActivity.kt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.fluffytrix
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import com.example.fluffytrix.ui.navigation.FluffytrixNavigation
|
||||||
|
import com.example.fluffytrix.ui.theme.FluffytrixTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
FluffytrixTheme {
|
||||||
|
FluffytrixNavigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.fluffytrix.data
|
||||||
|
|
||||||
|
object MxcUrlHelper {
|
||||||
|
fun mxcToDownloadUrl(
|
||||||
|
baseUrl: String,
|
||||||
|
mxcUri: String?,
|
||||||
|
): String? {
|
||||||
|
if (mxcUri == null || !mxcUri.startsWith("mxc://")) return null
|
||||||
|
val parts = mxcUri.removePrefix("mxc://").split("/", limit = 2)
|
||||||
|
if (parts.size != 2) return null
|
||||||
|
val (serverName, mediaId) = parts
|
||||||
|
val base = baseUrl.trimEnd('/')
|
||||||
|
return "$base/_matrix/client/v1/media/download/$serverName/$mediaId"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mxcToThumbnailUrl(
|
||||||
|
baseUrl: String,
|
||||||
|
mxcUri: String?,
|
||||||
|
size: Int = 64,
|
||||||
|
): String? {
|
||||||
|
if (mxcUri == null || !mxcUri.startsWith("mxc://")) return null
|
||||||
|
val parts = mxcUri.removePrefix("mxc://").split("/", limit = 2)
|
||||||
|
if (parts.size != 2) return null
|
||||||
|
val (serverName, mediaId) = parts
|
||||||
|
val base = baseUrl.trimEnd('/')
|
||||||
|
return "$base/_matrix/client/v1/media/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.example.fluffytrix.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "fluffytrix_prefs")
|
||||||
|
|
||||||
|
class PreferencesManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
|
||||||
|
private val KEY_USER_ID = stringPreferencesKey("user_id")
|
||||||
|
private val KEY_DEVICE_ID = stringPreferencesKey("device_id")
|
||||||
|
private val KEY_HOMESERVER_URL = stringPreferencesKey("homeserver_url")
|
||||||
|
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||||
|
private val KEY_PASSWORD = stringPreferencesKey("password")
|
||||||
|
private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
|
||||||
|
private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order")
|
||||||
|
}
|
||||||
|
|
||||||
|
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_IS_LOGGED_IN] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
val accessToken: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_ACCESS_TOKEN]
|
||||||
|
}
|
||||||
|
|
||||||
|
val userId: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_USER_ID]
|
||||||
|
}
|
||||||
|
|
||||||
|
val homeserverUrl: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_HOMESERVER_URL]
|
||||||
|
}
|
||||||
|
|
||||||
|
val username: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_USERNAME]
|
||||||
|
}
|
||||||
|
|
||||||
|
val password: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[KEY_PASSWORD]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveSession(
|
||||||
|
userId: String,
|
||||||
|
deviceId: String,
|
||||||
|
homeserverUrl: String,
|
||||||
|
) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_USER_ID] = userId
|
||||||
|
prefs[KEY_DEVICE_ID] = deviceId
|
||||||
|
prefs[KEY_HOMESERVER_URL] = homeserverUrl
|
||||||
|
prefs[KEY_IS_LOGGED_IN] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val channelOrder: Flow<Map<String, List<String>>> = context.dataStore.data.map { prefs ->
|
||||||
|
val raw = prefs[KEY_CHANNEL_ORDER] ?: return@map emptyMap()
|
||||||
|
try {
|
||||||
|
Json.decodeFromString<Map<String, List<String>>>(raw)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveChannelOrder(spaceId: String, roomIds: List<String>) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val existing = prefs[KEY_CHANNEL_ORDER]?.let {
|
||||||
|
try { Json.decodeFromString<Map<String, List<String>>>(it) } catch (_: Exception) { emptyMap() }
|
||||||
|
} ?: emptyMap()
|
||||||
|
prefs[KEY_CHANNEL_ORDER] = Json.encodeToString(existing + (spaceId to roomIds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearSession() {
|
||||||
|
context.dataStore.edit { it.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.example.fluffytrix.data.model
|
||||||
|
|
||||||
|
sealed class AuthState {
|
||||||
|
data object Idle : AuthState()
|
||||||
|
data object Loading : AuthState()
|
||||||
|
data class Success(val userId: String) : AuthState()
|
||||||
|
data class Error(val message: String) : AuthState()
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.example.fluffytrix.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import io.ktor.http.Url
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import net.folivo.trixnity.client.MatrixClient
|
||||||
|
import net.folivo.trixnity.client.fromStore
|
||||||
|
import net.folivo.trixnity.client.loginWithPassword
|
||||||
|
import net.folivo.trixnity.client.media.createInMemoryMediaStoreModule
|
||||||
|
import net.folivo.trixnity.client.store.AccountStore
|
||||||
|
import net.folivo.trixnity.client.store.repository.room.TrixnityRoomDatabase
|
||||||
|
import net.folivo.trixnity.client.store.repository.room.createRoomRepositoriesModule
|
||||||
|
import net.folivo.trixnity.clientserverapi.model.authentication.IdentifierType
|
||||||
|
|
||||||
|
class AuthRepository(
|
||||||
|
private val preferencesManager: PreferencesManager,
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
private var matrixClient: MatrixClient? = null
|
||||||
|
private var accessToken: String? = null
|
||||||
|
|
||||||
|
private fun createDatabaseBuilder() =
|
||||||
|
Room.databaseBuilder(context, TrixnityRoomDatabase::class.java, "trixnity")
|
||||||
|
.fallbackToDestructiveMigration(false)
|
||||||
|
|
||||||
|
suspend fun login(
|
||||||
|
homeserverUrl: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
): Result<MatrixClient> {
|
||||||
|
val normalizedUrl = homeserverUrl.let {
|
||||||
|
if (!it.startsWith("http")) "https://$it" else it
|
||||||
|
}
|
||||||
|
val baseUrl = Url(normalizedUrl)
|
||||||
|
|
||||||
|
val result = MatrixClient.loginWithPassword(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
identifier = IdentifierType.User(username),
|
||||||
|
password = password,
|
||||||
|
initialDeviceDisplayName = "Fluffytrix Android",
|
||||||
|
repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()),
|
||||||
|
mediaStoreModule = createInMemoryMediaStoreModule(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result.onSuccess { client ->
|
||||||
|
matrixClient = client
|
||||||
|
try {
|
||||||
|
val accountStore = client.di.get<AccountStore>()
|
||||||
|
accessToken = accountStore.getAccount()?.accessToken
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
preferencesManager.saveSession(
|
||||||
|
userId = client.userId.full,
|
||||||
|
deviceId = client.deviceId,
|
||||||
|
homeserverUrl = homeserverUrl,
|
||||||
|
)
|
||||||
|
client.startSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreSession(): Boolean {
|
||||||
|
if (matrixClient != null) return true
|
||||||
|
|
||||||
|
val isLoggedIn = preferencesManager.isLoggedIn.firstOrNull() ?: false
|
||||||
|
if (!isLoggedIn) return false
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val client = MatrixClient.fromStore(
|
||||||
|
repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()),
|
||||||
|
mediaStoreModule = createInMemoryMediaStoreModule(),
|
||||||
|
).getOrNull()
|
||||||
|
|
||||||
|
if (client != null) {
|
||||||
|
matrixClient = client
|
||||||
|
try {
|
||||||
|
val accountStore = client.di.get<AccountStore>()
|
||||||
|
accessToken = accountStore.getAccount()?.accessToken
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
client.startSync()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
// Store was empty or corrupt — clear saved state
|
||||||
|
preferencesManager.clearSession()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
preferencesManager.clearSession()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getClient(): MatrixClient? = matrixClient
|
||||||
|
fun getAccessToken(): String? = accessToken
|
||||||
|
fun getBaseUrl(): String? = matrixClient?.baseUrl?.toString()?.trimEnd('/')
|
||||||
|
|
||||||
|
suspend fun logout() {
|
||||||
|
matrixClient?.logout()
|
||||||
|
matrixClient?.close()
|
||||||
|
matrixClient = null
|
||||||
|
accessToken = null
|
||||||
|
preferencesManager.clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/src/main/java/com/example/fluffytrix/di/AppModule.kt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.fluffytrix.di
|
||||||
|
|
||||||
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import com.example.fluffytrix.ui.screens.login.LoginViewModel
|
||||||
|
import com.example.fluffytrix.ui.screens.main.MainViewModel
|
||||||
|
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
|
||||||
|
import org.koin.core.module.dsl.viewModel
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val appModule = module {
|
||||||
|
viewModel { LoginViewModel(get()) }
|
||||||
|
viewModel { VerificationViewModel(get()) }
|
||||||
|
viewModel { MainViewModel(get(), get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataModule = module {
|
||||||
|
single { PreferencesManager(get()) }
|
||||||
|
single { AuthRepository(get(), get()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.example.fluffytrix.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import com.example.fluffytrix.ui.screens.login.LoginScreen
|
||||||
|
import com.example.fluffytrix.ui.screens.main.MainScreen
|
||||||
|
import com.example.fluffytrix.ui.screens.verification.VerificationScreen
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FluffytrixNavigation() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val preferencesManager: PreferencesManager = koinInject()
|
||||||
|
val authRepository: AuthRepository = koinInject()
|
||||||
|
val isLoggedIn by preferencesManager.isLoggedIn.collectAsState(initial = false)
|
||||||
|
|
||||||
|
var isRestoring by remember { mutableStateOf(true) }
|
||||||
|
var restoreSucceeded by remember { mutableStateOf(false) }
|
||||||
|
var restoreFailed by remember { mutableStateOf(false) }
|
||||||
|
var retryTrigger by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
LaunchedEffect(isLoggedIn, retryTrigger) {
|
||||||
|
if (isLoggedIn && authRepository.getClient() == null) {
|
||||||
|
isRestoring = true
|
||||||
|
restoreFailed = false
|
||||||
|
restoreSucceeded = authRepository.restoreSession()
|
||||||
|
if (!restoreSucceeded) {
|
||||||
|
// Don't clear credentials — let user retry or go to login
|
||||||
|
restoreFailed = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
restoreSucceeded = authRepository.getClient() != null
|
||||||
|
}
|
||||||
|
isRestoring = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRestoring && isLoggedIn) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore failed — show retry screen instead of wiping credentials
|
||||||
|
if (restoreFailed && !restoreSucceeded) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Failed to reconnect",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Could not restore your session",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Button(onClick = { retryTrigger++ }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
TextButton(onClick = {
|
||||||
|
restoreFailed = false
|
||||||
|
restoreSucceeded = false
|
||||||
|
}) {
|
||||||
|
Text("Log in again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val startDestination = if (restoreSucceeded || (isLoggedIn && authRepository.getClient() != null)) {
|
||||||
|
Screen.Main.route
|
||||||
|
} else {
|
||||||
|
Screen.Login.route
|
||||||
|
}
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination,
|
||||||
|
) {
|
||||||
|
composable(Screen.Login.route) {
|
||||||
|
LoginScreen(
|
||||||
|
onLoginSuccess = {
|
||||||
|
navController.navigate(Screen.Verification.route) {
|
||||||
|
popUpTo(Screen.Login.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Verification.route) {
|
||||||
|
VerificationScreen(
|
||||||
|
onVerified = {
|
||||||
|
navController.navigate(Screen.Main.route) {
|
||||||
|
popUpTo(Screen.Verification.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSkip = {
|
||||||
|
navController.navigate(Screen.Main.route) {
|
||||||
|
popUpTo(Screen.Verification.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Main.route) {
|
||||||
|
MainScreen(
|
||||||
|
onLogout = {
|
||||||
|
navController.navigate(Screen.Login.route) {
|
||||||
|
popUpTo(Screen.Main.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.fluffytrix.ui.navigation
|
||||||
|
|
||||||
|
sealed class Screen(val route: String) {
|
||||||
|
data object Login : Screen("login")
|
||||||
|
data object Verification : Screen("verification")
|
||||||
|
data object Main : Screen("main")
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.fluffytrix.data.model.AuthState
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
onLoginSuccess: () -> Unit,
|
||||||
|
viewModel: LoginViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val homeserverUrl by viewModel.homeserverUrl.collectAsState()
|
||||||
|
val username by viewModel.username.collectAsState()
|
||||||
|
val password by viewModel.password.collectAsState()
|
||||||
|
val authState by viewModel.authState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(authState) {
|
||||||
|
if (authState is AuthState.Success) {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 400.dp)
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Fluffytrix",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = homeserverUrl,
|
||||||
|
onValueChange = { viewModel.homeserverUrl.value = it },
|
||||||
|
label = { Text("Homeserver URL") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { viewModel.username.value = it },
|
||||||
|
label = { Text("Username") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { viewModel.password.value = it },
|
||||||
|
label = { Text("Password") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { viewModel.login() }),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = authState !is AuthState.Loading,
|
||||||
|
) {
|
||||||
|
if (authState is AuthState.Loading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.height(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Log In")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState is AuthState.Error) {
|
||||||
|
Text(
|
||||||
|
text = (authState as AuthState.Error).message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.login
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.fluffytrix.data.model.AuthState
|
||||||
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class LoginViewModel(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val homeserverUrl = MutableStateFlow("matrix.org")
|
||||||
|
val username = MutableStateFlow("")
|
||||||
|
val password = MutableStateFlow("")
|
||||||
|
|
||||||
|
private val _authState = MutableStateFlow<AuthState>(AuthState.Idle)
|
||||||
|
val authState: StateFlow<AuthState> = _authState
|
||||||
|
|
||||||
|
fun login() {
|
||||||
|
if (username.value.isBlank() || password.value.isBlank()) {
|
||||||
|
_authState.value = AuthState.Error("Username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_authState.value = AuthState.Loading
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = authRepository.login(
|
||||||
|
homeserverUrl = homeserverUrl.value,
|
||||||
|
username = username.value,
|
||||||
|
password = password.value,
|
||||||
|
)
|
||||||
|
_authState.value = result.fold(
|
||||||
|
onSuccess = { AuthState.Success(it.userId.full) },
|
||||||
|
onFailure = { AuthState.Error(it.message ?: "Login failed") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.main
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import com.example.fluffytrix.ui.screens.main.components.ChannelList
|
||||||
|
import com.example.fluffytrix.ui.screens.main.components.MemberList
|
||||||
|
import com.example.fluffytrix.ui.screens.main.components.MessageTimeline
|
||||||
|
import com.example.fluffytrix.ui.screens.main.components.SpaceList
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
viewModel: MainViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val spaces by viewModel.spaces.collectAsState()
|
||||||
|
val channels by viewModel.channels.collectAsState()
|
||||||
|
val selectedSpace by viewModel.selectedSpace.collectAsState()
|
||||||
|
val selectedChannel by viewModel.selectedChannel.collectAsState()
|
||||||
|
val showChannelList by viewModel.showChannelList.collectAsState()
|
||||||
|
val showMemberList by viewModel.showMemberList.collectAsState()
|
||||||
|
val messages by viewModel.messages.collectAsState()
|
||||||
|
val members by viewModel.members.collectAsState()
|
||||||
|
val channelName by viewModel.channelName.collectAsState()
|
||||||
|
val isReorderMode by viewModel.isReorderMode.collectAsState()
|
||||||
|
|
||||||
|
Scaffold { padding ->
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Main content: SpaceList + chat + members
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
SpaceList(
|
||||||
|
spaces = spaces,
|
||||||
|
selectedSpace = selectedSpace,
|
||||||
|
onSpaceClick = { viewModel.selectSpace(it) },
|
||||||
|
onToggleChannelList = { viewModel.toggleChannelList() },
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
|
||||||
|
MessageTimeline(
|
||||||
|
selectedChannel = selectedChannel,
|
||||||
|
channelName = channelName,
|
||||||
|
messages = messages,
|
||||||
|
onToggleMemberList = { viewModel.toggleMemberList() },
|
||||||
|
onSendMessage = { viewModel.sendMessage(it) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = showMemberList) {
|
||||||
|
MemberList(
|
||||||
|
members = members,
|
||||||
|
contentPadding = padding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel list overlays on top, slides in from left
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showChannelList,
|
||||||
|
enter = slideInHorizontally { -it },
|
||||||
|
exit = slideOutHorizontally { -it },
|
||||||
|
modifier = Modifier.zIndex(1f),
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
Spacer(modifier = Modifier.width(64.dp))
|
||||||
|
ChannelList(
|
||||||
|
channels = channels,
|
||||||
|
selectedChannel = selectedChannel,
|
||||||
|
onChannelClick = {
|
||||||
|
viewModel.selectChannel(it)
|
||||||
|
viewModel.toggleChannelList()
|
||||||
|
},
|
||||||
|
onLogout = {
|
||||||
|
viewModel.logout()
|
||||||
|
onLogout()
|
||||||
|
},
|
||||||
|
contentPadding = padding,
|
||||||
|
isReorderMode = isReorderMode,
|
||||||
|
onToggleReorderMode = { viewModel.toggleReorderMode() },
|
||||||
|
onMoveChannel = { from, to -> viewModel.moveChannel(from, to) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.main
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.fluffytrix.data.MxcUrlHelper.mxcToDownloadUrl
|
||||||
|
import com.example.fluffytrix.data.MxcUrlHelper.mxcToThumbnailUrl
|
||||||
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import net.folivo.trixnity.client.room
|
||||||
|
import net.folivo.trixnity.client.room.message.text
|
||||||
|
import net.folivo.trixnity.client.store.Room
|
||||||
|
import net.folivo.trixnity.client.store.isEncrypted
|
||||||
|
import net.folivo.trixnity.client.user
|
||||||
|
import net.folivo.trixnity.core.model.RoomId
|
||||||
|
import net.folivo.trixnity.core.model.UserId
|
||||||
|
import net.folivo.trixnity.core.model.events.m.room.CreateEventContent.RoomType
|
||||||
|
import net.folivo.trixnity.core.model.events.m.room.Membership
|
||||||
|
import net.folivo.trixnity.core.model.events.m.room.RoomMessageEventContent
|
||||||
|
import net.folivo.trixnity.core.model.events.m.space.ChildEventContent
|
||||||
|
|
||||||
|
data class SpaceItem(
|
||||||
|
val id: RoomId,
|
||||||
|
val name: String,
|
||||||
|
val avatarUrl: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChannelItem(
|
||||||
|
val id: RoomId,
|
||||||
|
val name: String,
|
||||||
|
val isEncrypted: Boolean,
|
||||||
|
val avatarUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface MessageContent {
|
||||||
|
data class Text(val body: String, val urls: List<String> = emptyList()) : MessageContent
|
||||||
|
data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent
|
||||||
|
data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
|
||||||
|
data class File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent
|
||||||
|
}
|
||||||
|
|
||||||
|
private val urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""")
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class MessageItem(
|
||||||
|
val eventId: String,
|
||||||
|
val senderId: String,
|
||||||
|
val senderName: String,
|
||||||
|
val senderAvatarUrl: String? = null,
|
||||||
|
val content: MessageContent,
|
||||||
|
val timestamp: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MemberItem(
|
||||||
|
val userId: UserId,
|
||||||
|
val displayName: String,
|
||||||
|
val avatarUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class MainViewModel(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val preferencesManager: PreferencesManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList())
|
||||||
|
val spaces: StateFlow<List<SpaceItem>> = _spaces
|
||||||
|
|
||||||
|
private val _channels = MutableStateFlow<List<ChannelItem>>(emptyList())
|
||||||
|
val channels: StateFlow<List<ChannelItem>> = _channels
|
||||||
|
|
||||||
|
private val _selectedSpace = MutableStateFlow<RoomId?>(null)
|
||||||
|
val selectedSpace: StateFlow<RoomId?> = _selectedSpace
|
||||||
|
|
||||||
|
private val _selectedChannel = MutableStateFlow<RoomId?>(null)
|
||||||
|
val selectedChannel: StateFlow<RoomId?> = _selectedChannel
|
||||||
|
|
||||||
|
private val _showChannelList = MutableStateFlow(true)
|
||||||
|
val showChannelList: StateFlow<Boolean> = _showChannelList
|
||||||
|
|
||||||
|
private val _showMemberList = MutableStateFlow(false)
|
||||||
|
val showMemberList: StateFlow<Boolean> = _showMemberList
|
||||||
|
|
||||||
|
private val _messages = MutableStateFlow<List<MessageItem>>(emptyList())
|
||||||
|
val messages: StateFlow<List<MessageItem>> = _messages
|
||||||
|
|
||||||
|
private val _members = MutableStateFlow<List<MemberItem>>(emptyList())
|
||||||
|
val members: StateFlow<List<MemberItem>> = _members
|
||||||
|
|
||||||
|
private val _channelName = MutableStateFlow<String?>(null)
|
||||||
|
val channelName: StateFlow<String?> = _channelName
|
||||||
|
|
||||||
|
private val _isReorderMode = MutableStateFlow(false)
|
||||||
|
val isReorderMode: StateFlow<Boolean> = _isReorderMode
|
||||||
|
|
||||||
|
private val _channelOrderMap = MutableStateFlow<Map<String, List<String>>>(emptyMap())
|
||||||
|
|
||||||
|
private val _allChannelRooms = MutableStateFlow<List<ChannelItem>>(emptyList())
|
||||||
|
private val _spaceChildren = MutableStateFlow<Set<RoomId>?>(null)
|
||||||
|
|
||||||
|
// Per-room caches
|
||||||
|
private val messageCache = mutableMapOf<RoomId, MutableList<MessageItem>>()
|
||||||
|
private val messageIds = mutableMapOf<RoomId, MutableSet<String>>()
|
||||||
|
private val memberCache = mutableMapOf<RoomId, List<MemberItem>>()
|
||||||
|
private val channelNameCache = mutableMapOf<RoomId, String>()
|
||||||
|
private val senderAvatarCache = mutableMapOf<String, String?>()
|
||||||
|
private val senderNameCache = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
// Room data cache — avoid re-resolving unchanged rooms
|
||||||
|
private var cachedRoomData = mapOf<RoomId, Room>()
|
||||||
|
|
||||||
|
private var timelineJob: Job? = null
|
||||||
|
private var membersJob: Job? = null
|
||||||
|
private var spaceChildrenJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadRooms()
|
||||||
|
observeSelectedChannel()
|
||||||
|
observeSpaceFiltering()
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesManager.channelOrder.collect { _channelOrderMap.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||||
|
private fun loadRooms() {
|
||||||
|
val client = authRepository.getClient() ?: return
|
||||||
|
val baseUrl = authRepository.getBaseUrl() ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
client.room.getAll()
|
||||||
|
.debounce(1000)
|
||||||
|
.collect { roomMap ->
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
// Only resolve rooms whose keys changed
|
||||||
|
val currentKeys = roomMap.keys
|
||||||
|
val newRoomIds = currentKeys - cachedRoomData.keys
|
||||||
|
val removedRoomIds = cachedRoomData.keys - currentKeys
|
||||||
|
|
||||||
|
// Resolve only new/changed rooms
|
||||||
|
val newlyResolved = if (newRoomIds.isNotEmpty()) {
|
||||||
|
coroutineScope {
|
||||||
|
newRoomIds.mapNotNull { roomId ->
|
||||||
|
roomMap[roomId]?.let { flow ->
|
||||||
|
async { roomId to flow.firstOrNull() }
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
.filter { it.second != null }
|
||||||
|
.associate { it.first to it.second!! }
|
||||||
|
}
|
||||||
|
} else emptyMap()
|
||||||
|
|
||||||
|
// On first load, resolve everything; after that, only new ones
|
||||||
|
val allResolved = if (cachedRoomData.isEmpty()) {
|
||||||
|
coroutineScope {
|
||||||
|
roomMap.entries.map { (roomId, flow) ->
|
||||||
|
async { roomId to flow.firstOrNull() }
|
||||||
|
}.awaitAll()
|
||||||
|
.filter { it.second != null }
|
||||||
|
.associate { it.first to it.second!! }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(cachedRoomData - removedRoomIds) + newlyResolved
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedRoomData = allResolved
|
||||||
|
val joinedRooms = allResolved.values.filter { it.membership == Membership.JOIN }
|
||||||
|
|
||||||
|
val allSpaces = joinedRooms
|
||||||
|
.filter { it.createEventContent?.type is RoomType.Space }
|
||||||
|
|
||||||
|
// Collect child space IDs so we only show top-level spaces
|
||||||
|
val childSpaceIds = mutableSetOf<RoomId>()
|
||||||
|
val allSpaceIds = allSpaces.map { it.roomId }.toSet()
|
||||||
|
coroutineScope {
|
||||||
|
allSpaces.map { space ->
|
||||||
|
async {
|
||||||
|
try {
|
||||||
|
val children = client.room.getAllState(space.roomId, ChildEventContent::class)
|
||||||
|
.firstOrNull()?.keys?.map { RoomId(it) } ?: emptyList()
|
||||||
|
children.filter { it in allSpaceIds }
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
}.awaitAll().forEach { childSpaceIds.addAll(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
_spaces.value = allSpaces
|
||||||
|
.filter { it.roomId !in childSpaceIds }
|
||||||
|
.map { room ->
|
||||||
|
SpaceItem(
|
||||||
|
id = room.roomId,
|
||||||
|
name = room.name?.explicitName ?: room.roomId.full,
|
||||||
|
avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 96),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_allChannelRooms.value = joinedRooms
|
||||||
|
.filter { it.createEventContent?.type !is RoomType.Space }
|
||||||
|
.map { room ->
|
||||||
|
ChannelItem(
|
||||||
|
id = room.roomId,
|
||||||
|
name = room.name?.explicitName ?: room.roomId.full,
|
||||||
|
isEncrypted = room.encrypted,
|
||||||
|
avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 64),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSpaceFiltering() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap) { allChannels, children, spaceId, orderMap ->
|
||||||
|
val filtered = if (children == null) allChannels
|
||||||
|
else allChannels.filter { it.id in children }
|
||||||
|
val savedOrder = spaceId?.let { orderMap[it.full] }
|
||||||
|
if (savedOrder != null) {
|
||||||
|
val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i }
|
||||||
|
filtered.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE }
|
||||||
|
} else filtered
|
||||||
|
}.collect { _channels.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSelectedChannel() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_selectedChannel.collect { roomId ->
|
||||||
|
timelineJob?.cancel()
|
||||||
|
membersJob?.cancel()
|
||||||
|
|
||||||
|
if (roomId == null) {
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_members.value = emptyList()
|
||||||
|
_channelName.value = null
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore from cache instantly
|
||||||
|
_messages.value = messageCache[roomId]?.toList() ?: emptyList()
|
||||||
|
_members.value = memberCache[roomId] ?: emptyList()
|
||||||
|
_channelName.value = channelNameCache[roomId]
|
||||||
|
|
||||||
|
loadChannelName(roomId)
|
||||||
|
timelineJob = loadTimeline(roomId)
|
||||||
|
membersJob = loadMembers(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadChannelName(roomId: RoomId) {
|
||||||
|
val client = authRepository.getClient() ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val room = client.room.getById(roomId).firstOrNull()
|
||||||
|
val name = room?.name?.explicitName ?: roomId.full
|
||||||
|
channelNameCache[roomId] = name
|
||||||
|
_channelName.value = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadTimeline(roomId: RoomId): Job {
|
||||||
|
val client = authRepository.getClient() ?: return Job()
|
||||||
|
val baseUrl = authRepository.getBaseUrl() ?: return Job()
|
||||||
|
return viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val cached = messageCache.getOrPut(roomId) { mutableListOf() }
|
||||||
|
val ids = messageIds.getOrPut(roomId) { mutableSetOf() }
|
||||||
|
var dirty = false
|
||||||
|
var lastEmitTime = 0L
|
||||||
|
|
||||||
|
client.room.getLastTimelineEvents(roomId).collectLatest { outerFlow ->
|
||||||
|
if (outerFlow == null) return@collectLatest
|
||||||
|
coroutineScope {
|
||||||
|
outerFlow.collect { innerFlow ->
|
||||||
|
val firstEvent = innerFlow.firstOrNull() ?: return@collect
|
||||||
|
val eventId = firstEvent.event.id.full
|
||||||
|
if (eventId in ids) {
|
||||||
|
// Already have this event — but if encrypted, watch for decryption updates
|
||||||
|
if (firstEvent.event.isEncrypted && firstEvent.content?.getOrNull() == null) {
|
||||||
|
launch {
|
||||||
|
innerFlow.collect { updated ->
|
||||||
|
val decrypted = updated.content?.getOrNull() ?: return@collect
|
||||||
|
val msgContent = resolveContent(decrypted, baseUrl) ?: return@collect
|
||||||
|
updateCachedMessage(roomId, eventId, cached, msgContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentResult = firstEvent.content
|
||||||
|
val content = contentResult?.getOrNull()
|
||||||
|
val msgContent: MessageContent = when {
|
||||||
|
content == null && firstEvent.event.isEncrypted -> MessageContent.Text(
|
||||||
|
body = if (contentResult?.isFailure == true) "\uD83D\uDD12 Unable to decrypt message"
|
||||||
|
else "\uD83D\uDD12 Waiting for decryption keys..."
|
||||||
|
)
|
||||||
|
content == null -> return@collect
|
||||||
|
else -> resolveContent(content, baseUrl) ?: return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
val senderId = firstEvent.event.sender.localpart
|
||||||
|
val senderName = senderNameCache[senderId] ?: senderId
|
||||||
|
val msg = MessageItem(
|
||||||
|
eventId = eventId,
|
||||||
|
senderId = senderId,
|
||||||
|
senderName = senderName,
|
||||||
|
senderAvatarUrl = senderAvatarCache[senderId],
|
||||||
|
content = msgContent,
|
||||||
|
timestamp = firstEvent.event.originTimestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
ids.add(eventId)
|
||||||
|
val insertIdx = cached.binarySearch { other ->
|
||||||
|
msg.timestamp.compareTo(other.timestamp)
|
||||||
|
}.let { if (it < 0) -(it + 1) else it }
|
||||||
|
cached.add(insertIdx, msg)
|
||||||
|
dirty = true
|
||||||
|
|
||||||
|
// For encrypted events still waiting, watch for decryption
|
||||||
|
if (firstEvent.event.isEncrypted && content == null) {
|
||||||
|
launch {
|
||||||
|
innerFlow.collect { updated ->
|
||||||
|
val decrypted = updated.content?.getOrNull() ?: return@collect
|
||||||
|
val resolved = resolveContent(decrypted, baseUrl) ?: return@collect
|
||||||
|
updateCachedMessage(roomId, eventId, cached, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle UI updates: max once per 200ms
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastEmitTime > 200) {
|
||||||
|
if (_selectedChannel.value == roomId) {
|
||||||
|
_messages.value = ArrayList(cached)
|
||||||
|
}
|
||||||
|
dirty = false
|
||||||
|
lastEmitTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dirty && _selectedChannel.value == roomId) {
|
||||||
|
_messages.value = ArrayList(cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveContent(content: net.folivo.trixnity.core.model.events.RoomEventContent, baseUrl: String): MessageContent? {
|
||||||
|
return when (content) {
|
||||||
|
is RoomMessageEventContent.FileBased.Image -> MessageContent.Image(
|
||||||
|
body = content.body,
|
||||||
|
url = mxcToDownloadUrl(baseUrl, content.url) ?: return null,
|
||||||
|
width = content.info?.width?.toInt(),
|
||||||
|
height = content.info?.height?.toInt(),
|
||||||
|
)
|
||||||
|
is RoomMessageEventContent.FileBased.Video -> MessageContent.Video(
|
||||||
|
body = content.body,
|
||||||
|
url = mxcToDownloadUrl(baseUrl, content.url),
|
||||||
|
thumbnailUrl = mxcToThumbnailUrl(baseUrl, content.info?.thumbnailUrl, 300),
|
||||||
|
width = content.info?.width?.toInt(),
|
||||||
|
height = content.info?.height?.toInt(),
|
||||||
|
)
|
||||||
|
is RoomMessageEventContent.FileBased.Audio -> MessageContent.File(
|
||||||
|
body = content.body,
|
||||||
|
fileName = content.fileName ?: content.body,
|
||||||
|
size = content.info?.size,
|
||||||
|
)
|
||||||
|
is RoomMessageEventContent.FileBased.File -> MessageContent.File(
|
||||||
|
body = content.body,
|
||||||
|
fileName = content.fileName ?: content.body,
|
||||||
|
size = content.info?.size,
|
||||||
|
)
|
||||||
|
is RoomMessageEventContent -> {
|
||||||
|
val body = content.body
|
||||||
|
MessageContent.Text(
|
||||||
|
body = body,
|
||||||
|
urls = urlRegex.findAll(body).map { it.value }.toList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCachedMessage(roomId: RoomId, eventId: String, cached: MutableList<MessageItem>, newContent: MessageContent) {
|
||||||
|
val idx = cached.indexOfFirst { it.eventId == eventId }
|
||||||
|
if (idx >= 0) {
|
||||||
|
cached[idx] = cached[idx].copy(content = newContent)
|
||||||
|
if (_selectedChannel.value == roomId) {
|
||||||
|
_messages.value = ArrayList(cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||||
|
private fun loadMembers(roomId: RoomId): Job {
|
||||||
|
val client = authRepository.getClient() ?: return Job()
|
||||||
|
val baseUrl = authRepository.getBaseUrl()
|
||||||
|
return viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
client.user.loadMembers(roomId)
|
||||||
|
client.user.getAll(roomId)
|
||||||
|
.debounce(1000)
|
||||||
|
.collect { userMap ->
|
||||||
|
val memberList = withContext(Dispatchers.Default) {
|
||||||
|
coroutineScope {
|
||||||
|
userMap.values.map { userFlow ->
|
||||||
|
async { userFlow.firstOrNull() }
|
||||||
|
}.awaitAll().filterNotNull().map { user ->
|
||||||
|
MemberItem(
|
||||||
|
userId = user.userId,
|
||||||
|
displayName = user.name,
|
||||||
|
avatarUrl = baseUrl?.let {
|
||||||
|
mxcToThumbnailUrl(it, user.event.content.avatarUrl, 64)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.sortedBy { it.displayName.lowercase() }
|
||||||
|
}
|
||||||
|
memberCache[roomId] = memberList
|
||||||
|
memberList.forEach { m ->
|
||||||
|
val localpart = m.userId.localpart
|
||||||
|
senderAvatarCache[localpart] = m.avatarUrl
|
||||||
|
senderNameCache[localpart] = m.displayName
|
||||||
|
}
|
||||||
|
// Backfill avatars and display names into already-cached messages
|
||||||
|
messageCache[roomId]?.let { cached ->
|
||||||
|
var patched = false
|
||||||
|
for (i in cached.indices) {
|
||||||
|
val msg = cached[i]
|
||||||
|
val newAvatar = if (msg.senderAvatarUrl == null) senderAvatarCache[msg.senderId] else null
|
||||||
|
val newName = senderNameCache[msg.senderId]
|
||||||
|
val nameChanged = newName != null && newName != msg.senderName
|
||||||
|
if (newAvatar != null || nameChanged) {
|
||||||
|
cached[i] = msg.copy(
|
||||||
|
senderAvatarUrl = newAvatar ?: msg.senderAvatarUrl,
|
||||||
|
senderName = newName ?: msg.senderName,
|
||||||
|
)
|
||||||
|
patched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patched && _selectedChannel.value == roomId) {
|
||||||
|
_messages.value = ArrayList(cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_selectedChannel.value == roomId) {
|
||||||
|
_members.value = memberList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
_members.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSpaceChildren(spaceId: RoomId) {
|
||||||
|
val client = authRepository.getClient() ?: return
|
||||||
|
spaceChildrenJob?.cancel()
|
||||||
|
spaceChildrenJob = viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
client.room.getAllState(spaceId, ChildEventContent::class).collect { stateMap ->
|
||||||
|
_spaceChildren.value = stateMap.keys.map { RoomId(it) }.toSet()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
_spaceChildren.value = emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(body: String) {
|
||||||
|
val roomId = _selectedChannel.value ?: return
|
||||||
|
val client = authRepository.getClient() ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
client.room.sendMessage(roomId) { text(body) }
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectSpace(spaceId: RoomId) {
|
||||||
|
if (_selectedSpace.value == spaceId) {
|
||||||
|
_showChannelList.value = !_showChannelList.value
|
||||||
|
} else {
|
||||||
|
_selectedSpace.value = spaceId
|
||||||
|
_showChannelList.value = true
|
||||||
|
loadSpaceChildren(spaceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectChannel(channelId: RoomId) {
|
||||||
|
_selectedChannel.value = channelId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleChannelList() {
|
||||||
|
_showChannelList.value = !_showChannelList.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleMemberList() {
|
||||||
|
_showMemberList.value = !_showMemberList.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleReorderMode() {
|
||||||
|
_isReorderMode.value = !_isReorderMode.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveChannel(from: Int, to: Int) {
|
||||||
|
val current = _channels.value.toMutableList()
|
||||||
|
if (from !in current.indices || to !in current.indices) return
|
||||||
|
val item = current.removeAt(from)
|
||||||
|
current.add(to, item)
|
||||||
|
_channels.value = current
|
||||||
|
val spaceId = _selectedSpace.value?.full ?: return
|
||||||
|
val roomIds = current.map { it.id.full }
|
||||||
|
_channelOrderMap.value = _channelOrderMap.value + (spaceId to roomIds)
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesManager.saveChannelOrder(spaceId, roomIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
authRepository.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.main.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.SwapVert
|
||||||
|
import androidx.compose.material.icons.filled.Tag
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import com.example.fluffytrix.ui.screens.main.ChannelItem
|
||||||
|
import net.folivo.trixnity.core.model.RoomId
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChannelList(
|
||||||
|
channels: List<ChannelItem>,
|
||||||
|
selectedChannel: RoomId?,
|
||||||
|
onChannelClick: (RoomId) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
isReorderMode: Boolean = false,
|
||||||
|
onToggleReorderMode: () -> Unit = {},
|
||||||
|
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
|
||||||
|
) {
|
||||||
|
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
var draggingIndex by remember { mutableIntStateOf(-1) }
|
||||||
|
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
if (showLogoutDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showLogoutDialog = false },
|
||||||
|
title = { Text("Logout") },
|
||||||
|
text = { Text("Are you sure you want to logout?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showLogoutDialog = false; onLogout() }) {
|
||||||
|
Text("Logout")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showLogoutDialog = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(240.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(top = contentPadding.calculateTopPadding()),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Channels",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Row {
|
||||||
|
IconButton(onClick = onToggleReorderMode) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SwapVert,
|
||||||
|
contentDescription = "Reorder channels",
|
||||||
|
tint = if (isReorderMode) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showLogoutDialog = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||||
|
contentDescription = "Logout",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(channels, key = { _, ch -> ch.id.full }) { index, channel ->
|
||||||
|
val isSelected = channel.id == selectedChannel
|
||||||
|
val isDragging = draggingIndex == index
|
||||||
|
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation")
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (isDragging) Modifier
|
||||||
|
.zIndex(1f)
|
||||||
|
.offset { IntOffset(0, dragOffsetY.roundToInt()) }
|
||||||
|
.shadow(elevation, RoundedCornerShape(4.dp))
|
||||||
|
else Modifier
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(
|
||||||
|
when {
|
||||||
|
isDragging -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(if (!isReorderMode) Modifier.clickable { onChannelClick(channel.id) } else Modifier)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (isReorderMode) {
|
||||||
|
val currentIndex by rememberUpdatedState(index)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DragHandle,
|
||||||
|
contentDescription = "Drag to reorder",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = {
|
||||||
|
draggingIndex = currentIndex
|
||||||
|
dragOffsetY = 0f
|
||||||
|
},
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
dragOffsetY += dragAmount.y
|
||||||
|
val itemHeight = 34.dp.toPx()
|
||||||
|
val draggedPositions = (dragOffsetY / itemHeight).roundToInt()
|
||||||
|
val targetIndex = (draggingIndex + draggedPositions).coerceIn(0, channels.lastIndex)
|
||||||
|
if (targetIndex != draggingIndex) {
|
||||||
|
onMoveChannel(draggingIndex, targetIndex)
|
||||||
|
dragOffsetY -= (targetIndex - draggingIndex) * itemHeight
|
||||||
|
draggingIndex = targetIndex
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
draggingIndex = -1
|
||||||
|
dragOffsetY = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
draggingIndex = -1
|
||||||
|
dragOffsetY = 0f
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.height(18.dp),
|
||||||
|
tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = channel.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.main.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.example.fluffytrix.ui.screens.main.MemberItem
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MemberList(
|
||||||
|
members: List<MemberItem>,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(240.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(top = contentPadding.calculateTopPadding()),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Members — ${members.size}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
items(members, key = { it.userId.full }) { member ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
if (member.avatarUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = member.avatarUrl,
|
||||||
|
contentDescription = member.displayName,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = member.displayName.take(1).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = member.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.main.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.People
|
||||||
|
import androidx.compose.material.icons.filled.Tag
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
|
import com.mikepenz.markdown.m3.markdownColor
|
||||||
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
|
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.datasource.DefaultHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.example.fluffytrix.ui.screens.main.MessageContent
|
||||||
|
import com.example.fluffytrix.ui.screens.main.MessageItem
|
||||||
|
import net.folivo.trixnity.core.model.RoomId
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} }
|
||||||
|
private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
|
||||||
|
|
||||||
|
private val senderColors = arrayOf(
|
||||||
|
Color(0xFF5865F2),
|
||||||
|
Color(0xFF57F287),
|
||||||
|
Color(0xFFFEE75C),
|
||||||
|
Color(0xFFEB459E),
|
||||||
|
Color(0xFFED4245),
|
||||||
|
Color(0xFFFF7F50),
|
||||||
|
Color(0xFF9B59B6),
|
||||||
|
Color(0xFF1ABC9C),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun colorForSender(name: String): Color {
|
||||||
|
return senderColors[name.hashCode().ushr(1) % senderColors.size]
|
||||||
|
}
|
||||||
|
|
||||||
|
private val todayFormat = SimpleDateFormat("h:mm a", Locale.US)
|
||||||
|
private val olderFormat = SimpleDateFormat("MM/dd/yyyy h:mm a", Locale.US)
|
||||||
|
|
||||||
|
private fun formatTimestamp(timestamp: Long): String {
|
||||||
|
if (timestamp == 0L) return ""
|
||||||
|
val diff = System.currentTimeMillis() - timestamp
|
||||||
|
return if (diff < 86_400_000) todayFormat.format(Date(timestamp))
|
||||||
|
else olderFormat.format(Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageTimeline(
|
||||||
|
selectedChannel: RoomId?,
|
||||||
|
channelName: String?,
|
||||||
|
messages: List<MessageItem>,
|
||||||
|
onToggleMemberList: () -> Unit,
|
||||||
|
onSendMessage: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(),
|
||||||
|
) {
|
||||||
|
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
if (fullscreenImageUrl != null) {
|
||||||
|
FullscreenImageViewer(
|
||||||
|
url = fullscreenImageUrl!!,
|
||||||
|
onDismiss = { fullscreenImageUrl = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenVideoUrl != null) {
|
||||||
|
FullscreenVideoPlayer(
|
||||||
|
url = fullscreenVideoUrl!!,
|
||||||
|
onDismiss = { fullscreenVideoUrl = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalImageViewer provides { url -> fullscreenImageUrl = url },
|
||||||
|
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(top = contentPadding.calculateTopPadding()),
|
||||||
|
) {
|
||||||
|
if (selectedChannel != null) {
|
||||||
|
TopBar(channelName ?: selectedChannel.full, onToggleMemberList)
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedChannel == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Select a room to start chatting",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val isAtBottom by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll when near bottom and new messages arrive
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (listState.firstVisibleItemIndex <= 2) {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = listState,
|
||||||
|
reverseLayout = true,
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
val count = messages.size
|
||||||
|
items(
|
||||||
|
count = count,
|
||||||
|
key = { messages[it].eventId },
|
||||||
|
contentType = {
|
||||||
|
val msg = messages[it]
|
||||||
|
val next = if (it + 1 < count) messages[it + 1] else null
|
||||||
|
if (next == null || next.senderId != msg.senderId) 0 else 1
|
||||||
|
},
|
||||||
|
) { index ->
|
||||||
|
val message = messages[index]
|
||||||
|
val next = if (index + 1 < count) messages[index + 1] else null
|
||||||
|
val isFirstInGroup = next == null || next.senderName != message.senderName
|
||||||
|
|
||||||
|
if (isFirstInGroup) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
FullMessage(message)
|
||||||
|
} else {
|
||||||
|
CompactMessage(message.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump to bottom button
|
||||||
|
if (!isAtBottom) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { scope.launch { listState.animateScrollToItem(0) } },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.KeyboardArrowDown,
|
||||||
|
"Jump to bottom",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
MessageInput(channelName ?: "message", onSendMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Tag, null, Modifier.size(20.dp), MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
IconButton(onClick = onToggleMemberList) {
|
||||||
|
Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FullMessage(message: MessageItem) {
|
||||||
|
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
|
||||||
|
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
|
if (message.senderAvatarUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = message.senderAvatarUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(40.dp).clip(CircleShape),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(40.dp).clip(CircleShape).background(senderColor.copy(alpha = 0.3f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
message.senderName.take(1).uppercase(),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = senderColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(message.senderName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = senderColor)
|
||||||
|
Text(time, style = MaterialTheme.typography.labelSmall, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
MessageContentView(message.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompactMessage(content: MessageContent) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
||||||
|
Spacer(Modifier.width(52.dp))
|
||||||
|
MessageContentView(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageContentView(content: MessageContent) {
|
||||||
|
when (content) {
|
||||||
|
is MessageContent.Text -> TextContent(content)
|
||||||
|
is MessageContent.Image -> ImageContent(content)
|
||||||
|
is MessageContent.Video -> VideoContent(content)
|
||||||
|
is MessageContent.File -> FileContent(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextContent(content: MessageContent.Text) {
|
||||||
|
Markdown(
|
||||||
|
content = content.body,
|
||||||
|
colors = markdownColor(
|
||||||
|
text = MaterialTheme.colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
typography = markdownTypography(
|
||||||
|
text = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
|
||||||
|
),
|
||||||
|
imageTransformer = Coil3ImageTransformerImpl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImageContent(content: MessageContent.Image) {
|
||||||
|
val onViewImage = LocalImageViewer.current
|
||||||
|
val aspectRatio = if (content.width != null && content.height != null && content.height > 0)
|
||||||
|
content.width.toFloat() / content.height.toFloat() else null
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model = content.url,
|
||||||
|
contentDescription = content.body,
|
||||||
|
modifier = Modifier
|
||||||
|
.let { if (aspectRatio != null) it.width((300.dp * aspectRatio).coerceAtMost(400.dp)) else it.fillMaxWidth(0.6f) }
|
||||||
|
.height(300.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { onViewImage(content.url) },
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoContent(content: MessageContent.Video) {
|
||||||
|
val onPlayVideo = LocalVideoPlayer.current
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(200.dp)
|
||||||
|
.let { mod ->
|
||||||
|
val ar = if (content.width != null && content.height != null && content.height > 0)
|
||||||
|
content.width.toFloat() / content.height.toFloat() else 16f / 9f
|
||||||
|
mod.width((200.dp * ar).coerceAtMost(400.dp))
|
||||||
|
}
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { content.url?.let { onPlayVideo(it) } },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (content.thumbnailUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = content.thumbnailUrl,
|
||||||
|
contentDescription = content.body,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant))
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PlayCircleFilled, "Play",
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = Color.White.copy(alpha = 0.85f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FullscreenImageViewer(url: String, onDismiss: () -> Unit) {
|
||||||
|
var scale by remember { mutableStateOf(1f) }
|
||||||
|
var offsetX by remember { mutableStateOf(0f) }
|
||||||
|
var offsetY by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures { _, pan, zoom, _ ->
|
||||||
|
scale = (scale * zoom).coerceIn(1f, 5f)
|
||||||
|
if (scale > 1f) {
|
||||||
|
offsetX += pan.x
|
||||||
|
offsetY += pan.y
|
||||||
|
} else {
|
||||||
|
offsetX = 0f
|
||||||
|
offsetY = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = scale,
|
||||||
|
scaleY = scale,
|
||||||
|
translationX = offsetX,
|
||||||
|
translationY = offsetY,
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f)),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Close, "Close", tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
|
private fun FullscreenVideoPlayer(url: String, onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val authRepository: AuthRepository = koinInject()
|
||||||
|
val exoPlayer = remember {
|
||||||
|
val token = authRepository.getAccessToken()
|
||||||
|
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
||||||
|
if (token != null) {
|
||||||
|
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||||
|
.createMediaSource(MediaItem.fromUri(Uri.parse(url)))
|
||||||
|
ExoPlayer.Builder(context).build().apply {
|
||||||
|
setMediaSource(mediaSource)
|
||||||
|
prepare()
|
||||||
|
playWhenReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { exoPlayer.release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
exoPlayer.stop()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
PlayerView(ctx).apply {
|
||||||
|
player = exoPlayer
|
||||||
|
useController = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
exoPlayer.stop()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f)),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Close, "Close", tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FileContent(content: MessageContent.File) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.AttachFile, null, Modifier.size(20.dp), MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
content.fileName ?: content.body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
if (content.size != null) {
|
||||||
|
Text(
|
||||||
|
formatFileSize(content.size),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatFileSize(bytes: Long): String {
|
||||||
|
return when {
|
||||||
|
bytes < 1024 -> "$bytes B"
|
||||||
|
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||||
|
else -> "${"%.1f".format(bytes / (1024.0 * 1024.0))} MB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit) {
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) },
|
||||||
|
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)),
|
||||||
|
singleLine = true,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { if (text.isNotBlank()) { onSendMessage(text.trim()); text = "" } },
|
||||||
|
enabled = text.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Send, "Send",
|
||||||
|
tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.main.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.example.fluffytrix.ui.screens.main.SpaceItem
|
||||||
|
import net.folivo.trixnity.core.model.RoomId
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SpaceList(
|
||||||
|
spaces: List<SpaceItem>,
|
||||||
|
selectedSpace: RoomId?,
|
||||||
|
onSpaceClick: (RoomId) -> Unit,
|
||||||
|
onToggleChannelList: () -> Unit,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(64.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(top = contentPadding.calculateTopPadding()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
// Channel list toggle
|
||||||
|
item {
|
||||||
|
IconButton(onClick = onToggleChannelList) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = "Toggle channel list",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home button
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(if (selectedSpace == null) RoundedCornerShape(16.dp) else CircleShape)
|
||||||
|
.background(
|
||||||
|
if (selectedSpace == null) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
.clickable { /* home/all rooms */ },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Home,
|
||||||
|
contentDescription = "Home",
|
||||||
|
tint = if (selectedSpace == null) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(spaces, key = { it.id.full }) { space ->
|
||||||
|
val isSelected = space.id == selectedSpace
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape)
|
||||||
|
.background(
|
||||||
|
if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
.clickable { onSpaceClick(space.id) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (space.avatarUrl != null) {
|
||||||
|
var imageError by remember { mutableStateOf(false) }
|
||||||
|
if (!imageError) {
|
||||||
|
AsyncImage(
|
||||||
|
model = space.avatarUrl,
|
||||||
|
contentDescription = space.name,
|
||||||
|
modifier = Modifier.size(48.dp).clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
onError = { imageError = true },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SpaceInitial(space.name, isSelected)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SpaceInitial(space.name, isSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpaceInitial(name: String, isSelected: Boolean) {
|
||||||
|
Text(
|
||||||
|
text = name.take(2).uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.verification
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.Verified
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerificationScreen(
|
||||||
|
onVerified: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
viewModel: VerificationViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(uiState) {
|
||||||
|
if (uiState is VerificationUiState.AlreadyVerified ||
|
||||||
|
uiState is VerificationUiState.VerificationDone
|
||||||
|
) {
|
||||||
|
onVerified()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 480.dp)
|
||||||
|
.padding(24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
when (val state = uiState) {
|
||||||
|
is VerificationUiState.Loading -> LoadingContent()
|
||||||
|
is VerificationUiState.NoCrossSigning -> NoCrossSigningContent(onSkip)
|
||||||
|
is VerificationUiState.AlreadyVerified -> {} // handled by LaunchedEffect
|
||||||
|
is VerificationUiState.VerificationDone -> VerificationDoneContent()
|
||||||
|
is VerificationUiState.MethodSelection -> MethodSelectionContent(
|
||||||
|
state = state,
|
||||||
|
onDeviceVerification = { viewModel.startDeviceVerification() },
|
||||||
|
onRecoveryKey = { viewModel.selectRecoveryKey() },
|
||||||
|
onPassphrase = { viewModel.selectPassphrase() },
|
||||||
|
onSkip = onSkip,
|
||||||
|
)
|
||||||
|
is VerificationUiState.RecoveryKeyInput -> RecoveryKeyInputContent(
|
||||||
|
onVerify = { viewModel.verifyWithRecoveryKey(it) },
|
||||||
|
onBack = { viewModel.goBack() },
|
||||||
|
)
|
||||||
|
is VerificationUiState.PassphraseInput -> PassphraseInputContent(
|
||||||
|
onVerify = { viewModel.verifyWithPassphrase(it) },
|
||||||
|
onBack = { viewModel.goBack() },
|
||||||
|
)
|
||||||
|
is VerificationUiState.WaitingForDevice -> WaitingForDeviceContent(
|
||||||
|
onCancel = { viewModel.goBack() },
|
||||||
|
)
|
||||||
|
is VerificationUiState.EmojiComparison -> EmojiComparisonContent(
|
||||||
|
emojis = state.emojis,
|
||||||
|
decimals = state.decimals,
|
||||||
|
onMatch = { viewModel.confirmEmojiMatch() },
|
||||||
|
onNoMatch = { viewModel.rejectEmojiMatch() },
|
||||||
|
)
|
||||||
|
is VerificationUiState.Error -> ErrorContent(
|
||||||
|
message = state.message,
|
||||||
|
onRetry = { viewModel.goBack() },
|
||||||
|
onSkip = onSkip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingContent() {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Checking verification status...",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NoCrossSigningContent(onSkip: () -> Unit) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Cross-signing not set up",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your account doesn't have cross-signing enabled. Set it up from another client like Element to verify this device.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Button(onClick = onSkip) {
|
||||||
|
Text("Continue without verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MethodSelectionContent(
|
||||||
|
state: VerificationUiState.MethodSelection,
|
||||||
|
onDeviceVerification: () -> Unit,
|
||||||
|
onRecoveryKey: () -> Unit,
|
||||||
|
onPassphrase: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Verified,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Verify this device",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Confirm your identity to access encrypted messages.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (state.hasDeviceVerification) {
|
||||||
|
Card(
|
||||||
|
onClick = onDeviceVerification,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PhoneAndroid,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Verify with another device",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Compare emojis on both devices",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.hasRecoveryKey) {
|
||||||
|
Card(
|
||||||
|
onClick = onRecoveryKey,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Key,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Enter recovery key",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Use your security key to verify",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.hasPassphrase) {
|
||||||
|
Card(
|
||||||
|
onClick = onPassphrase,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Enter security phrase",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Use your passphrase to verify",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = onSkip) {
|
||||||
|
Text("Skip for now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecoveryKeyInputContent(
|
||||||
|
onVerify: (String) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var recoveryKey by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Key,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Enter recovery key",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Enter the recovery key you saved when setting up cross-signing.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = recoveryKey,
|
||||||
|
onValueChange = { recoveryKey = it },
|
||||||
|
label = { Text("Recovery key") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = false,
|
||||||
|
minLines = 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onVerify(recoveryKey.trim()) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = recoveryKey.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("Verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onBack) {
|
||||||
|
Text("Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PassphraseInputContent(
|
||||||
|
onVerify: (String) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var passphrase by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Enter security phrase",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Enter the passphrase you set when configuring cross-signing.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = passphrase,
|
||||||
|
onValueChange = { passphrase = it },
|
||||||
|
label = { Text("Security phrase") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onVerify(passphrase) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = passphrase.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("Verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onBack) {
|
||||||
|
Text("Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaitingForDeviceContent(onCancel: () -> Unit) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Waiting for other device",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Accept the verification request on your other device.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
OutlinedButton(onClick = onCancel) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun EmojiComparisonContent(
|
||||||
|
emojis: List<Pair<Int, String>>,
|
||||||
|
decimals: List<Int>,
|
||||||
|
onMatch: () -> Unit,
|
||||||
|
onNoMatch: () -> Unit,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Compare emojis",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Confirm the emojis below match on your other device.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Emoji grid
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
emojis.forEach { (code, description) ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = emojiForCode(code),
|
||||||
|
fontSize = 32.sp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Decimal comparison
|
||||||
|
Text(
|
||||||
|
text = decimals.joinToString(" "),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onNoMatch,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("They don't match")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onMatch,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("They match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VerificationDoneContent() {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Verified,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Device verified!",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorContent(
|
||||||
|
message: String,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Verification failed",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(onClick = onRetry) {
|
||||||
|
Text("Try again")
|
||||||
|
}
|
||||||
|
TextButton(onClick = onSkip) {
|
||||||
|
Text("Skip for now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAS emoji mapping per Matrix spec
|
||||||
|
private fun emojiForCode(code: Int): String {
|
||||||
|
val emojiMap = arrayOf(
|
||||||
|
"\uD83D\uDC36", "\uD83D\uDC31", "\uD83E\uDD81", "\uD83D\uDC34", "\uD83E\uDD84", // dog, cat, lion, horse, unicorn
|
||||||
|
"\uD83D\uDC37", "\uD83D\uDC18", "\uD83D\uDC30", "\uD83D\uDC3C", "\uD83D\uDC14", // pig, elephant, rabbit, panda, rooster
|
||||||
|
"\uD83D\uDC27", "\uD83D\uDC22", "\uD83D\uDC1F", "\uD83D\uDC19", "\uD83E\uDD8B", // penguin, turtle, fish, octopus, butterfly
|
||||||
|
"\uD83C\uDF3B", "\uD83C\uDF35", "\uD83C\uDF44", "\uD83C\uDF3E", "\uD83C\uDF3A", // sunflower, cactus, mushroom, globe, rose (using rice/hibiscus)
|
||||||
|
"\uD83C\uDF39", "\uD83C\uDF3C", "\uD83C\uDF34", "\uD83C\uDF32", "\uD83C\uDF33", // rose, daisy, palm, evergreen, deciduous
|
||||||
|
"\uD83C\uDFE0", "\uD83C\uDFE2", "\uD83C\uDFE9", "\uD83D\uDC69", "\uD83D\uDE3A", // house, office, church (approximated), woman, smiley cat (approximated)
|
||||||
|
"\uD83D\uDE38", "\uD83C\uDF0D", "\uD83C\uDF19", "\u2601\uFE0F", "\uD83D\uDD25", // grinning cat, globe, moon, cloud, fire
|
||||||
|
"\uD83C\uDF4C", "\uD83C\uDF4E", "\uD83C\uDF53", "\uD83C\uDF5E", "\uD83C\uDF54", // banana, apple, strawberry, bread (approximated), hamburger (approximated)
|
||||||
|
"\uD83C\uDF55", "\u2615", "\uD83C\uDF70", "\uD83D\uDC53", "\uD83D\uDC54", // pizza, coffee (approximated), cake (approximated), glasses, tie (approximated)
|
||||||
|
"\uD83C\uDFA9", "\uD83D\uDD11", "\uD83C\uDFC6", "\u2764\uFE0F", "\uD83D\uDC8D", // top hat (approximated), key, trophy, heart, ring (approximated)
|
||||||
|
"\uD83D\uDCF1", "\uD83D\uDCBB", "\uD83D\uDCE1", "\u2708\uFE0F", "\uD83D\uDE80", // phone, laptop, satellite, plane, rocket
|
||||||
|
"\uD83D\uDE82", "\u2693", "\uD83D\uDE97", "\u26BD", "\uD83C\uDFB5", // train, anchor, car, soccer, music (approximated)
|
||||||
|
"\uD83C\uDFB8", "\uD83C\uDFBA", "\uD83D\uDD14", "\u2648", "\u2649", // guitar, trumpet, bell (approximated), aries (approximated), taurus (approximated)
|
||||||
|
"\u264A", "\u264B", // gemini, cancer (approximated)
|
||||||
|
)
|
||||||
|
return if (code in emojiMap.indices) emojiMap[code] else "\u2753" // question mark fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.verification
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import net.folivo.trixnity.client.verification
|
||||||
|
import net.folivo.trixnity.client.verification.ActiveDeviceVerification
|
||||||
|
import net.folivo.trixnity.client.verification.ActiveSasVerificationState
|
||||||
|
import net.folivo.trixnity.client.verification.ActiveVerificationState
|
||||||
|
import net.folivo.trixnity.client.verification.SelfVerificationMethod
|
||||||
|
import net.folivo.trixnity.client.verification.VerificationService.SelfVerificationMethods
|
||||||
|
|
||||||
|
sealed class VerificationUiState {
|
||||||
|
data object Loading : VerificationUiState()
|
||||||
|
data object AlreadyVerified : VerificationUiState()
|
||||||
|
data object NoCrossSigning : VerificationUiState()
|
||||||
|
data class MethodSelection(
|
||||||
|
val hasDeviceVerification: Boolean,
|
||||||
|
val hasRecoveryKey: Boolean,
|
||||||
|
val hasPassphrase: Boolean,
|
||||||
|
) : VerificationUiState()
|
||||||
|
data object RecoveryKeyInput : VerificationUiState()
|
||||||
|
data object PassphraseInput : VerificationUiState()
|
||||||
|
data object WaitingForDevice : VerificationUiState()
|
||||||
|
data class EmojiComparison(
|
||||||
|
val emojis: List<Pair<Int, String>>,
|
||||||
|
val decimals: List<Int>,
|
||||||
|
) : VerificationUiState()
|
||||||
|
data object VerificationDone : VerificationUiState()
|
||||||
|
data class Error(val message: String) : VerificationUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class VerificationViewModel(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<VerificationUiState>(VerificationUiState.Loading)
|
||||||
|
val uiState: StateFlow<VerificationUiState> = _uiState
|
||||||
|
|
||||||
|
private var selfVerificationMethods: SelfVerificationMethods? = null
|
||||||
|
private var activeDeviceVerification: ActiveDeviceVerification? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadVerificationMethods()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadVerificationMethods() {
|
||||||
|
val client = authRepository.getClient() ?: run {
|
||||||
|
_uiState.value = VerificationUiState.Error("Not logged in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
client.verification.getSelfVerificationMethods().collectLatest { methods ->
|
||||||
|
selfVerificationMethods = methods
|
||||||
|
when (methods) {
|
||||||
|
is SelfVerificationMethods.PreconditionsNotMet ->
|
||||||
|
_uiState.value = VerificationUiState.Loading
|
||||||
|
|
||||||
|
is SelfVerificationMethods.NoCrossSigningEnabled ->
|
||||||
|
_uiState.value = VerificationUiState.NoCrossSigning
|
||||||
|
|
||||||
|
is SelfVerificationMethods.AlreadyCrossSigned ->
|
||||||
|
_uiState.value = VerificationUiState.AlreadyVerified
|
||||||
|
|
||||||
|
is SelfVerificationMethods.CrossSigningEnabled -> {
|
||||||
|
if (methods.methods.isEmpty()) {
|
||||||
|
_uiState.value = VerificationUiState.NoCrossSigning
|
||||||
|
} else {
|
||||||
|
_uiState.value = VerificationUiState.MethodSelection(
|
||||||
|
hasDeviceVerification = methods.methods.any {
|
||||||
|
it is SelfVerificationMethod.CrossSignedDeviceVerification
|
||||||
|
},
|
||||||
|
hasRecoveryKey = methods.methods.any {
|
||||||
|
it is SelfVerificationMethod.AesHmacSha2RecoveryKey
|
||||||
|
},
|
||||||
|
hasPassphrase = methods.methods.any {
|
||||||
|
it is SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startDeviceVerification() {
|
||||||
|
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
|
||||||
|
val deviceMethod = methods.methods.filterIsInstance<SelfVerificationMethod.CrossSignedDeviceVerification>()
|
||||||
|
.firstOrNull() ?: return
|
||||||
|
|
||||||
|
_uiState.value = VerificationUiState.WaitingForDevice
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
deviceMethod.createDeviceVerification()
|
||||||
|
.onSuccess { verification ->
|
||||||
|
activeDeviceVerification = verification
|
||||||
|
observeVerificationState(verification)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_uiState.value = VerificationUiState.Error(it.message ?: "Failed to start verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeVerificationState(verification: ActiveDeviceVerification) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
verification.state.collectLatest { state ->
|
||||||
|
when (state) {
|
||||||
|
is ActiveVerificationState.TheirRequest -> {
|
||||||
|
state.ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
is ActiveVerificationState.Ready -> {
|
||||||
|
state.start(net.folivo.trixnity.core.model.events.m.key.verification.VerificationMethod.Sas)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ActiveVerificationState.Start -> {
|
||||||
|
val method = state.method
|
||||||
|
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
||||||
|
observeSasState(method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ActiveVerificationState.Done -> {
|
||||||
|
_uiState.value = VerificationUiState.VerificationDone
|
||||||
|
}
|
||||||
|
|
||||||
|
is ActiveVerificationState.Cancel -> {
|
||||||
|
_uiState.value = VerificationUiState.Error(
|
||||||
|
"Verification cancelled: ${state.content.reason}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> { /* OwnRequest, WaitForDone, etc - keep current state */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSasState(method: net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
method.state.collectLatest { sasState ->
|
||||||
|
when (sasState) {
|
||||||
|
is ActiveSasVerificationState.TheirSasStart -> {
|
||||||
|
sasState.accept()
|
||||||
|
}
|
||||||
|
|
||||||
|
is ActiveSasVerificationState.ComparisonByUser -> {
|
||||||
|
_uiState.value = VerificationUiState.EmojiComparison(
|
||||||
|
emojis = sasState.emojis,
|
||||||
|
decimals = sasState.decimal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> { /* OwnSasStart, Accept, WaitForKeys, WaitForMacs */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmEmojiMatch() {
|
||||||
|
val state = (_uiState.value as? VerificationUiState.EmojiComparison) ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val verification = activeDeviceVerification ?: return@launch
|
||||||
|
val verState = verification.state.value
|
||||||
|
if (verState is ActiveVerificationState.Start) {
|
||||||
|
val method = verState.method
|
||||||
|
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
||||||
|
val sasState = method.state.value
|
||||||
|
if (sasState is ActiveSasVerificationState.ComparisonByUser) {
|
||||||
|
sasState.match()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rejectEmojiMatch() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val verification = activeDeviceVerification ?: return@launch
|
||||||
|
val verState = verification.state.value
|
||||||
|
if (verState is ActiveVerificationState.Start) {
|
||||||
|
val method = verState.method
|
||||||
|
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
||||||
|
val sasState = method.state.value
|
||||||
|
if (sasState is ActiveSasVerificationState.ComparisonByUser) {
|
||||||
|
sasState.noMatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectRecoveryKey() {
|
||||||
|
_uiState.value = VerificationUiState.RecoveryKeyInput
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectPassphrase() {
|
||||||
|
_uiState.value = VerificationUiState.PassphraseInput
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyWithRecoveryKey(recoveryKey: String) {
|
||||||
|
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
|
||||||
|
val keyMethod = methods.methods.filterIsInstance<SelfVerificationMethod.AesHmacSha2RecoveryKey>()
|
||||||
|
.firstOrNull() ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
keyMethod.verify(recoveryKey)
|
||||||
|
.onSuccess {
|
||||||
|
_uiState.value = VerificationUiState.VerificationDone
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_uiState.value = VerificationUiState.Error(
|
||||||
|
it.message ?: "Invalid recovery key"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyWithPassphrase(passphrase: String) {
|
||||||
|
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
|
||||||
|
val passphraseMethod = methods.methods
|
||||||
|
.filterIsInstance<SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase>()
|
||||||
|
.firstOrNull() ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
passphraseMethod.verify(passphrase)
|
||||||
|
.onSuccess {
|
||||||
|
_uiState.value = VerificationUiState.VerificationDone
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_uiState.value = VerificationUiState.Error(
|
||||||
|
it.message ?: "Invalid passphrase"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goBack() {
|
||||||
|
loadVerificationMethods()
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/src/main/java/com/example/fluffytrix/ui/theme/Color.kt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package com.example.fluffytrix.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Discord-inspired dark fallback palette
|
||||||
|
val DarkPrimary = Color(0xFF5865F2)
|
||||||
|
val DarkOnPrimary = Color(0xFFFFFFFF)
|
||||||
|
val DarkPrimaryContainer = Color(0xFF4752C4)
|
||||||
|
val DarkOnPrimaryContainer = Color(0xFFDDE1FF)
|
||||||
|
val DarkSecondary = Color(0xFF72767D)
|
||||||
|
val DarkOnSecondary = Color(0xFFFFFFFF)
|
||||||
|
val DarkBackground = Color(0xFF313338)
|
||||||
|
val DarkOnBackground = Color(0xFFDBDEE1)
|
||||||
|
val DarkSurface = Color(0xFF2B2D31)
|
||||||
|
val DarkOnSurface = Color(0xFFDBDEE1)
|
||||||
|
val DarkSurfaceVariant = Color(0xFF1E1F22)
|
||||||
|
val DarkOnSurfaceVariant = Color(0xFFB5BAC1)
|
||||||
|
val DarkError = Color(0xFFED4245)
|
||||||
|
|
||||||
|
// Light fallback palette
|
||||||
|
val LightPrimary = Color(0xFF5865F2)
|
||||||
|
val LightOnPrimary = Color(0xFFFFFFFF)
|
||||||
|
val LightPrimaryContainer = Color(0xFFE3E5FC)
|
||||||
|
val LightOnPrimaryContainer = Color(0xFF2D3180)
|
||||||
|
val LightSecondary = Color(0xFF4F5660)
|
||||||
|
val LightOnSecondary = Color(0xFFFFFFFF)
|
||||||
|
val LightBackground = Color(0xFFFFFFFF)
|
||||||
|
val LightOnBackground = Color(0xFF060607)
|
||||||
|
val LightSurface = Color(0xFFF2F3F5)
|
||||||
|
val LightOnSurface = Color(0xFF060607)
|
||||||
|
val LightSurfaceVariant = Color(0xFFE3E5E8)
|
||||||
|
val LightOnSurfaceVariant = Color(0xFF4E5058)
|
||||||
|
val LightError = Color(0xFFED4245)
|
||||||
65
app/src/main/java/com/example/fluffytrix/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package com.example.fluffytrix.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = DarkPrimary,
|
||||||
|
onPrimary = DarkOnPrimary,
|
||||||
|
primaryContainer = DarkPrimaryContainer,
|
||||||
|
onPrimaryContainer = DarkOnPrimaryContainer,
|
||||||
|
secondary = DarkSecondary,
|
||||||
|
onSecondary = DarkOnSecondary,
|
||||||
|
background = DarkBackground,
|
||||||
|
onBackground = DarkOnBackground,
|
||||||
|
surface = DarkSurface,
|
||||||
|
onSurface = DarkOnSurface,
|
||||||
|
surfaceVariant = DarkSurfaceVariant,
|
||||||
|
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||||
|
error = DarkError,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = LightPrimary,
|
||||||
|
onPrimary = LightOnPrimary,
|
||||||
|
primaryContainer = LightPrimaryContainer,
|
||||||
|
onPrimaryContainer = LightOnPrimaryContainer,
|
||||||
|
secondary = LightSecondary,
|
||||||
|
onSecondary = LightOnSecondary,
|
||||||
|
background = LightBackground,
|
||||||
|
onBackground = LightOnBackground,
|
||||||
|
surface = LightSurface,
|
||||||
|
onSurface = LightOnSurface,
|
||||||
|
surfaceVariant = LightSurfaceVariant,
|
||||||
|
onSurfaceVariant = LightOnSurfaceVariant,
|
||||||
|
error = LightError,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FluffytrixTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = FluffytrixTypography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
49
app/src/main/java/com/example/fluffytrix/ui/theme/Type.kt
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package com.example.fluffytrix.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val FluffytrixTypography = Typography(
|
||||||
|
headlineLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 34.sp,
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
19
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/main"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Hello World!"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
7
app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Base.Theme.Fluffytrix" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your dark theme here. -->
|
||||||
|
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
5
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Fluffytrix</string>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Base.Theme.Fluffytrix" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<!-- Customize your light theme here. -->
|
||||||
|
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Theme.Fluffytrix" parent="Base.Theme.Fluffytrix" />
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
17
app/src/test/java/com/example/fluffytrix/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.fluffytrix
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
build.gradle.kts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
23
gradle.properties
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
85
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.0.1"
|
||||||
|
kotlin = "2.2.10"
|
||||||
|
coreKtx = "1.17.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.3.0"
|
||||||
|
espressoCore = "3.7.0"
|
||||||
|
appcompat = "1.6.1"
|
||||||
|
material = "1.10.0"
|
||||||
|
activity = "1.12.4"
|
||||||
|
constraintlayout = "2.1.4"
|
||||||
|
composeBom = "2025.06.00"
|
||||||
|
navigationCompose = "2.9.0"
|
||||||
|
lifecycleViewModel = "2.9.1"
|
||||||
|
koin = "4.1.1"
|
||||||
|
datastore = "1.1.7"
|
||||||
|
coroutines = "1.10.2"
|
||||||
|
trixnity = "4.22.7"
|
||||||
|
ktor = "3.3.0"
|
||||||
|
coil = "3.2.0"
|
||||||
|
media3 = "1.6.0"
|
||||||
|
markdownRenderer = "0.37.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
|
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||||
|
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||||
|
|
||||||
|
# Compose
|
||||||
|
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
compose-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
|
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
|
||||||
|
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModel" }
|
||||||
|
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewModel" }
|
||||||
|
|
||||||
|
# Koin
|
||||||
|
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
||||||
|
koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
|
||||||
|
|
||||||
|
# DataStore
|
||||||
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
|
||||||
|
# Coroutines
|
||||||
|
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
|
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
# Trixnity (using -jvm variants for Android)
|
||||||
|
trixnity-client = { group = "net.folivo", name = "trixnity-client-jvm", version.ref = "trixnity" }
|
||||||
|
trixnity-clientserverapi-client = { group = "net.folivo", name = "trixnity-clientserverapi-client-jvm", version.ref = "trixnity" }
|
||||||
|
trixnity-olm = { group = "net.folivo", name = "trixnity-olm-android", version.ref = "trixnity" }
|
||||||
|
trixnity-client-repository-room = { group = "net.folivo", name = "trixnity-client-repository-room-jvm", version.ref = "trixnity" }
|
||||||
|
|
||||||
|
# Ktor (needed by Trixnity on Android)
|
||||||
|
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
|
||||||
|
|
||||||
|
# Coil (image loading)
|
||||||
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
|
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||||
|
|
||||||
|
# Media3 (ExoPlayer)
|
||||||
|
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||||
|
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||||
|
|
||||||
|
# Markdown renderer
|
||||||
|
markdown-renderer-core = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
|
||||||
|
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3-android", version.ref = "markdownRenderer" }
|
||||||
|
markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" }
|
||||||
|
markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#Thu Feb 19 19:08:43 GMT 2026
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
24
settings.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Fluffytrix"
|
||||||
|
include(":app")
|
||||||
|
|
||||||