Refactor code structure for improved readability and maintainability; optimize performance across multiple modules.
All checks were successful
Deploy to production / deploy (push) Successful in 2m56s
All checks were successful
Deploy to production / deploy (push) Successful in 2m56s
This commit is contained in:
154
android/native/app/build.gradle.kts
Normal file → Executable file
154
android/native/app/build.gradle.kts
Normal file → Executable file
@@ -4,10 +4,20 @@ plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.google.dagger.hilt.android")
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
// Only the production release uses the Firebase app registered as de.yourpart.nativeapp.
|
||||
// Local/staging builds have different application IDs and deliberately stay push-free.
|
||||
val isProductionReleaseBuild = gradle.startParameter.taskNames.any {
|
||||
it.contains("productionrelease", ignoreCase = true)
|
||||
}
|
||||
if (isProductionReleaseBuild && file("google-services.json").isFile) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
|
||||
val gitShaProvider = providers.exec {
|
||||
workingDir = rootDir.resolve("../..")
|
||||
commandLine("git", "rev-parse", "--short=12", "HEAD")
|
||||
@@ -15,6 +25,34 @@ val gitShaProvider = providers.exec {
|
||||
|
||||
val gitSha: String = runCatching { gitShaProvider.get() }.getOrDefault("dev")
|
||||
|
||||
data class EnvironmentConfig(
|
||||
val apiBaseUrl: String,
|
||||
val socketIoUrl: String,
|
||||
val daemonSocketUrl: String,
|
||||
val cleartextAllowed: Boolean,
|
||||
)
|
||||
|
||||
val environmentConfigs = mapOf(
|
||||
"local" to EnvironmentConfig(
|
||||
apiBaseUrl = "http://10.0.2.2:2020",
|
||||
socketIoUrl = "http://10.0.2.2:2020",
|
||||
daemonSocketUrl = "ws://10.0.2.2:2021",
|
||||
cleartextAllowed = true,
|
||||
),
|
||||
"staging" to EnvironmentConfig(
|
||||
apiBaseUrl = "https://staging.your-part.de",
|
||||
socketIoUrl = "https://staging.your-part.de",
|
||||
daemonSocketUrl = "wss://staging.your-part.de",
|
||||
cleartextAllowed = false,
|
||||
),
|
||||
"production" to EnvironmentConfig(
|
||||
apiBaseUrl = "https://www.your-part.de",
|
||||
socketIoUrl = "https://www.your-part.de",
|
||||
daemonSocketUrl = "wss://www.your-part.de",
|
||||
cleartextAllowed = false,
|
||||
),
|
||||
)
|
||||
|
||||
android {
|
||||
namespace = "de.yourpart.nativeapp"
|
||||
compileSdk = 36
|
||||
@@ -35,23 +73,32 @@ android {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".local"
|
||||
versionNameSuffix = "-local"
|
||||
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:2020\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"http://10.0.2.2:2020\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"ws://10.0.2.2:2021\"")
|
||||
val cfg = environmentConfigs.getValue(name)
|
||||
buildConfigField("String", "ENVIRONMENT", "\"$name\"")
|
||||
buildConfigField("String", "API_BASE_URL", "\"${cfg.apiBaseUrl}\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"${cfg.socketIoUrl}\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"${cfg.daemonSocketUrl}\"")
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", cfg.cleartextAllowed.toString())
|
||||
}
|
||||
create("staging") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".staging"
|
||||
versionNameSuffix = "-staging"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://staging.your-part.de\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"https://staging.your-part.de\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"wss://staging.your-part.de\"")
|
||||
val cfg = environmentConfigs.getValue(name)
|
||||
buildConfigField("String", "ENVIRONMENT", "\"$name\"")
|
||||
buildConfigField("String", "API_BASE_URL", "\"${cfg.apiBaseUrl}\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"${cfg.socketIoUrl}\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"${cfg.daemonSocketUrl}\"")
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", cfg.cleartextAllowed.toString())
|
||||
}
|
||||
create("production") {
|
||||
dimension = "environment"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://www.your-part.de\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"https://www.your-part.de\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"wss://www.your-part.de\"")
|
||||
val cfg = environmentConfigs.getValue(name)
|
||||
buildConfigField("String", "ENVIRONMENT", "\"$name\"")
|
||||
buildConfigField("String", "API_BASE_URL", "\"${cfg.apiBaseUrl}\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"${cfg.socketIoUrl}\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"${cfg.daemonSocketUrl}\"")
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", cfg.cleartextAllowed.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +106,21 @@ android {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
buildConfigField("boolean", "IS_RELEASE_BUILD", "false")
|
||||
buildConfigField("boolean", "FEATURE_ADMIN", "false")
|
||||
buildConfigField("boolean", "FEATURE_ADULT", "true")
|
||||
buildConfigField("boolean", "FEATURE_3D", "false")
|
||||
buildConfigField("boolean", "FEATURE_MINIGAMES", "false")
|
||||
buildConfigField("boolean", "FEATURE_PUSH", "false")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
buildConfigField("boolean", "IS_RELEASE_BUILD", "true")
|
||||
buildConfigField("boolean", "FEATURE_ADMIN", "false")
|
||||
buildConfigField("boolean", "FEATURE_ADULT", "false")
|
||||
buildConfigField("boolean", "FEATURE_3D", "false")
|
||||
buildConfigField("boolean", "FEATURE_MINIGAMES", "false")
|
||||
buildConfigField("boolean", "FEATURE_PUSH", "true")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
@@ -74,6 +133,29 @@ android {
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
managedDevices {
|
||||
devices {
|
||||
create<com.android.build.api.dsl.ManagedVirtualDevice>("mediumPhone") {
|
||||
device = "Medium Phone"
|
||||
apiLevel = 34
|
||||
systemImageSource = "google"
|
||||
}
|
||||
create<com.android.build.api.dsl.ManagedVirtualDevice>("mediumTablet") {
|
||||
device = "Medium Tablet"
|
||||
apiLevel = 34
|
||||
systemImageSource = "google"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = true
|
||||
checkReleaseBuilds = true
|
||||
@@ -91,13 +173,51 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
arguments {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
val validateReleaseConfig by tasks.registering {
|
||||
doLast {
|
||||
environmentConfigs
|
||||
.filterKeys { it != "local" }
|
||||
.forEach { (name, cfg) ->
|
||||
require(cfg.apiBaseUrl.startsWith("https://")) {
|
||||
"Release flavor '$name' must use HTTPS API URL, got ${cfg.apiBaseUrl}"
|
||||
}
|
||||
require(cfg.socketIoUrl.startsWith("https://")) {
|
||||
"Release flavor '$name' must use HTTPS Socket.IO URL, got ${cfg.socketIoUrl}"
|
||||
}
|
||||
require(cfg.daemonSocketUrl.startsWith("wss://")) {
|
||||
"Release flavor '$name' must use WSS daemon URL, got ${cfg.daemonSocketUrl}"
|
||||
}
|
||||
require(!cfg.apiBaseUrl.contains("10.0.2.2") && !cfg.apiBaseUrl.contains("localhost")) {
|
||||
"Release flavor '$name' must not use local API URL, got ${cfg.apiBaseUrl}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { task ->
|
||||
task.name.startsWith("assemble") && task.name.endsWith("Release")
|
||||
}.configureEach {
|
||||
dependsOn(validateReleaseConfig)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||
implementation("androidx.browser:browser:1.9.0")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
|
||||
implementation("androidx.compose.material3:material3:1.4.0")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.4.1")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
@@ -106,17 +226,27 @@ dependencies {
|
||||
implementation("com.squareup.retrofit2:retrofit:3.0.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:5.3.2")
|
||||
implementation("io.socket:socket.io-client:2.1.0")
|
||||
implementation("com.google.dagger:hilt-android:2.57.2")
|
||||
kapt("com.google.dagger:hilt-android-compiler:2.57.2")
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
kapt("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.room:room-runtime:2.8.0")
|
||||
implementation("androidx.room:room-ktx:2.8.0")
|
||||
kapt("androidx.room:room-compiler:2.8.0")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("com.google.firebase:firebase-messaging:24.1.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test:rules:1.7.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.9.4")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest:1.9.4")
|
||||
androidTestImplementation("androidx.room:room-testing:2.8.0")
|
||||
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
}
|
||||
|
||||
29
android/native/app/google-services.json
Normal file
29
android/native/app/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "872182891503",
|
||||
"project_id": "yourpart-2f689",
|
||||
"storage_bucket": "yourpart-2f689.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:872182891503:android:6004ef4c46ce27e26eec1a",
|
||||
"android_client_info": {
|
||||
"package_name": "de.yourpart.nativeapp"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyC28yw1StDE15tdHKDQEXqEC_cBNXG9xkM"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
17
android/native/app/google-services.json.example
Normal file
17
android/native/app/google-services.json.example
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "REPLACE_WITH_FIREBASE_PROJECT_NUMBER",
|
||||
"project_id": "REPLACE_WITH_FIREBASE_PROJECT_ID",
|
||||
"storage_bucket": "REPLACE_WITH_FIREBASE_STORAGE_BUCKET"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "REPLACE_WITH_ANDROID_APP_ID",
|
||||
"android_client_info": { "package_name": "de.yourpart.nativeapp" }
|
||||
},
|
||||
"api_key": [{ "current_key": "REPLACE_WITH_FIREBASE_API_KEY"}]
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
0
android/native/app/proguard-rules.pro
vendored
Normal file → Executable file
0
android/native/app/proguard-rules.pro
vendored
Normal file → Executable file
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "771f218fbabd59ebba146d6688176afa",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "user_profile_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT, `avatarUrl` TEXT, `profileJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`userId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "avatarUrl",
|
||||
"columnName": "avatarUrl",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "profileJson",
|
||||
"columnName": "profileJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"userId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "friend_search_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `resultsJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`query`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "query",
|
||||
"columnName": "query",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "resultsJson",
|
||||
"columnName": "resultsJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"query"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "falukant_status_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `statusJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`cacheKey`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "cacheKey",
|
||||
"columnName": "cacheKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "statusJson",
|
||||
"columnName": "statusJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"cacheKey"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "vocab_course_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT, `courseJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`courseId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "courseId",
|
||||
"columnName": "courseId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "summary",
|
||||
"columnName": "summary",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "courseJson",
|
||||
"columnName": "courseJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"courseId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "vocab_lesson_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lessonId` TEXT NOT NULL, `courseId` TEXT NOT NULL, `title` TEXT NOT NULL, `lessonJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`lessonId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "lessonId",
|
||||
"columnName": "lessonId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "courseId",
|
||||
"columnName": "courseId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lessonJson",
|
||||
"columnName": "lessonJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"lessonId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "vocab_review_outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`requestId` TEXT NOT NULL, `payloadJson` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, PRIMARY KEY(`requestId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "requestId",
|
||||
"columnName": "requestId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "payloadJson",
|
||||
"columnName": "payloadJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAtMillis",
|
||||
"columnName": "createdAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"requestId"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '771f218fbabd59ebba146d6688176afa')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.yourpart.nativeapp.core.auth.storage
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.core.auth.model.UserDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SessionStoreResumeTest {
|
||||
@Test fun savedSessionSurvivesStoreRecreation() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val firstStore = SessionStore(context, Json { ignoreUnknownKeys = true })
|
||||
firstStore.clearSession()
|
||||
firstStore.saveSession(AuthSession(UserDto("resume-user", "alice", true, authCode = "resume-code"), true, 1L))
|
||||
|
||||
val resumedStore = SessionStore(context, Json { ignoreUnknownKeys = true })
|
||||
|
||||
assertEquals("resume-user", resumedStore.currentSession?.user?.id)
|
||||
resumedStore.clearSession()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AppCacheMigrationTest {
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppCacheDatabase::class.java,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun migrationFromVersion1PreservesExistingCacheAndCreatesReviewOutbox() {
|
||||
helper.createDatabase(DB_NAME, 1).apply {
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `user_profile_cache` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT, `avatarUrl` TEXT, `profileJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`userId`))")
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `friend_search_cache` (`query` TEXT NOT NULL, `resultsJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`query`))")
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `falukant_status_cache` (`cacheKey` TEXT NOT NULL, `statusJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`cacheKey`))")
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `vocab_course_cache` (`courseId` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT, `courseJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`courseId`))")
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `vocab_lesson_cache` (`lessonId` TEXT NOT NULL, `courseId` TEXT NOT NULL, `title` TEXT NOT NULL, `lessonJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`lessonId`))")
|
||||
execSQL("INSERT INTO `vocab_lesson_cache` VALUES ('lesson-1', 'course-1', 'Titel', '{}', 1)")
|
||||
close()
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(DB_NAME, 2, true, AppCacheMigrations.MIGRATION_1_2).use { database ->
|
||||
database.query("SELECT lessonId FROM vocab_lesson_cache").use { cursor ->
|
||||
assertEquals(1, cursor.count)
|
||||
}
|
||||
database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='vocab_review_outbox'").use { cursor ->
|
||||
assertEquals(1, cursor.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object { const val DB_NAME = "cache-migration-test" }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.yourpart.nativeapp.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import de.yourpart.nativeapp.feature.auth.AuthScreen
|
||||
import de.yourpart.nativeapp.feature.auth.LoginUiState
|
||||
import de.yourpart.nativeapp.feature.home.HomeScreen
|
||||
import de.yourpart.nativeapp.feature.home.HomeUiState
|
||||
import de.yourpart.nativeapp.feature.vocab.VocabScreen
|
||||
import de.yourpart.nativeapp.feature.vocab.VocabUiState
|
||||
import de.yourpart.nativeapp.ui.theme.YourPartTheme
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class NativeScreenComposeTest {
|
||||
@get:Rule val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun loginShowsValidationAndPrimaryAction() {
|
||||
composeRule.setContent {
|
||||
YourPartTheme {
|
||||
AuthScreen(
|
||||
uiState = LoginUiState(errorMessage = "Ungültige Anmeldung"),
|
||||
oauthProviders = emptyList(),
|
||||
onUsernameChange = {}, onPasswordChange = {}, onRememberMeChange = {}, onLogin = {},
|
||||
onRegisterClick = {}, onForgotPasswordClick = {}, onOAuthLogin = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText("Anmelden").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Ungültige Anmeldung").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun navigationMovesToDestination() {
|
||||
composeRule.setContent {
|
||||
YourPartTheme {
|
||||
val navController = rememberNavController()
|
||||
LaunchedEffect(Unit) { navController.navigate("target") }
|
||||
NavHost(navController = navController, startDestination = "start") {
|
||||
composable("start") { Text("Start") }
|
||||
composable("target") { Text("Ziel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText("Ziel").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun homeRendersDashboardAndInvokesRefresh() {
|
||||
var refreshes = 0
|
||||
composeRule.setContent { YourPartTheme { HomeScreen(HomeUiState(isLoading = false), onRefresh = { refreshes += 1 }) } }
|
||||
composeRule.onNodeWithText("Dashboard").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Aktualisieren").performClick()
|
||||
composeRule.runOnIdle { assertEquals(1, refreshes) }
|
||||
}
|
||||
|
||||
@Test fun vocabRendersLoadedLesson() {
|
||||
val lesson = buildJsonObject { put("title", "Lektion eins") }
|
||||
composeRule.setContent {
|
||||
YourPartTheme {
|
||||
VocabScreen(
|
||||
state = VocabUiState(lesson = lesson),
|
||||
onCourses = {}, onCourseId = {}, onLoadCourse = {}, onLessonId = {}, onLoadLesson = {},
|
||||
onLanguageId = {}, onLanguageName = {}, onCreateLanguage = {}, onShareCode = {}, onSubscribe = {},
|
||||
onChapters = {}, onChapterId = {}, onChapterVocabs = {}, onDictionaryQuery = {}, onSearchDictionary = {},
|
||||
onPractice = {}, onStartPractice = {}, onCheckPractice = {}, onLearning = {}, onReference = {},
|
||||
onToggleCorrect = {}, onAnswer = {}, onReview = {}, onDictionary = {}, onProgress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText("Lektion").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Lektion eins").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
8
android/native/app/src/debug/res/xml/network_security_config.xml
Executable file
8
android/native/app/src/debug/res/xml/network_security_config.xml
Executable file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
21
android/native/app/src/main/AndroidManifest.xml
Normal file → Executable file
21
android/native/app/src/main/AndroidManifest.xml
Normal file → Executable file
@@ -1,15 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".YourPartNativeApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher_foreground"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@drawable/ic_launcher_foreground"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.YourPartNative">
|
||||
<service
|
||||
android:name=".feature.push.YourPartFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
@@ -17,6 +26,18 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/android/oauth/callback" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/socialnetwork" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/falukant" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/settings" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/blogs" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/guides" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:path="/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
50
android/native/app/src/main/java/de/yourpart/nativeapp/MainActivity.kt
Normal file → Executable file
50
android/native/app/src/main/java/de/yourpart/nativeapp/MainActivity.kt
Normal file → Executable file
@@ -1,22 +1,70 @@
|
||||
package de.yourpart.nativeapp
|
||||
|
||||
import android.os.Bundle
|
||||
import android.content.Intent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeManager
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeCacheInvalidator
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeEventCenter
|
||||
import de.yourpart.nativeapp.feature.auth.AuthViewModel
|
||||
import de.yourpart.nativeapp.core.navigation.AppLink
|
||||
import de.yourpart.nativeapp.core.navigation.AppLinkParser
|
||||
import de.yourpart.nativeapp.ui.YourPartNativeApp
|
||||
import de.yourpart.nativeapp.ui.theme.YourPartTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val authViewModel: AuthViewModel by viewModels()
|
||||
@Inject lateinit var realtimeManager: RealtimeManager
|
||||
@Inject lateinit var realtimeEventCenter: RealtimeEventCenter
|
||||
@Inject lateinit var realtimeCacheInvalidator: RealtimeCacheInvalidator
|
||||
private var incomingAppLink by mutableStateOf<AppLink?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
incomingAppLink = AppLinkParser.parse(intent) ?: intent.notificationRoute()
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
YourPartTheme {
|
||||
YourPartNativeApp()
|
||||
YourPartNativeApp(
|
||||
authViewModel = authViewModel,
|
||||
realtimeManager = realtimeManager,
|
||||
realtimeEventCenter = realtimeEventCenter,
|
||||
realtimeCacheInvalidator = realtimeCacheInvalidator,
|
||||
incomingAppLink = incomingAppLink,
|
||||
onSensitiveContentVisible = ::setSensitiveContentProtection,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
incomingAppLink = AppLinkParser.parse(intent) ?: intent.notificationRoute()
|
||||
}
|
||||
|
||||
private fun Intent.notificationRoute(): AppLink.Route? = getStringExtra(EXTRA_NOTIFICATION_ROUTE)
|
||||
?.let { AppLink.Route(route = it, requiresAuth = true) }
|
||||
|
||||
private fun setSensitiveContentProtection(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_NOTIFICATION_ROUTE = "de.yourpart.nativeapp.NOTIFICATION_ROUTE"
|
||||
}
|
||||
}
|
||||
|
||||
0
android/native/app/src/main/java/de/yourpart/nativeapp/YourPartNativeApplication.kt
Normal file → Executable file
0
android/native/app/src/main/java/de/yourpart/nativeapp/YourPartNativeApplication.kt
Normal file → Executable file
@@ -0,0 +1,58 @@
|
||||
package de.yourpart.nativeapp.core.auth.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserParamDto(
|
||||
val name: String,
|
||||
val value: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserDto(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val active: Boolean,
|
||||
val param: List<UserParamDto> = emptyList(),
|
||||
val authCode: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RegisterRequest(
|
||||
val email: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val language: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForgotPasswordRequest(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OAuthProviderDto(
|
||||
val slug: String,
|
||||
val label: String,
|
||||
val issuer: String,
|
||||
val scope: String,
|
||||
val configured: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ErrorResponse(
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthSession(
|
||||
val user: UserDto,
|
||||
val rememberMe: Boolean,
|
||||
val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.yourpart.nativeapp.core.auth.storage
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
|
||||
interface AuthSessionStore {
|
||||
val currentSession: AuthSession?
|
||||
fun saveSession(session: AuthSession)
|
||||
fun clearSession(notifyExpired: Boolean = false)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.yourpart.nativeapp.core.auth.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SessionStore @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val json: Json,
|
||||
) : AuthSessionStore {
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
private val _session = MutableStateFlow(loadSession())
|
||||
private val _sessionExpiredMessage = MutableStateFlow<String?>(null)
|
||||
|
||||
val session: StateFlow<AuthSession?> = _session.asStateFlow()
|
||||
val sessionExpiredMessage: StateFlow<String?> = _sessionExpiredMessage.asStateFlow()
|
||||
|
||||
override val currentSession: AuthSession?
|
||||
get() = _session.value
|
||||
|
||||
override fun saveSession(session: AuthSession) {
|
||||
prefs.edit().putString(KEY_SESSION, json.encodeToString(session)).apply()
|
||||
_session.value = session
|
||||
}
|
||||
|
||||
override fun clearSession(notifyExpired: Boolean) {
|
||||
prefs.edit().remove(KEY_SESSION).apply()
|
||||
_session.value = null
|
||||
if (notifyExpired) {
|
||||
_sessionExpiredMessage.value = "Deine Sitzung ist abgelaufen."
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeSessionExpiredMessage() {
|
||||
_sessionExpiredMessage.value = null
|
||||
}
|
||||
|
||||
private fun loadSession(): AuthSession? {
|
||||
val raw = prefs.getString(KEY_SESSION, null) ?: return null
|
||||
return runCatching { json.decodeFromString<AuthSession>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "yourpart_native_secure_auth"
|
||||
const val KEY_SESSION = "auth_session"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.yourpart.nativeapp.core.config
|
||||
|
||||
import de.yourpart.nativeapp.BuildConfig
|
||||
|
||||
data class AppConfig(
|
||||
val environment: String,
|
||||
val apiBaseUrl: String,
|
||||
val socketIoUrl: String,
|
||||
val daemonSocketUrl: String,
|
||||
val allowCleartext: Boolean,
|
||||
val isReleaseBuild: Boolean,
|
||||
val featureAdmin: Boolean,
|
||||
val featureAdult: Boolean,
|
||||
val feature3d: Boolean,
|
||||
val featureMinigames: Boolean,
|
||||
val featurePush: Boolean,
|
||||
val gitSha: String,
|
||||
) {
|
||||
companion object {
|
||||
fun fromBuildConfig(): AppConfig = AppConfig(
|
||||
environment = BuildConfig.ENVIRONMENT,
|
||||
apiBaseUrl = BuildConfig.API_BASE_URL,
|
||||
socketIoUrl = BuildConfig.SOCKET_IO_URL,
|
||||
daemonSocketUrl = BuildConfig.DAEMON_SOCKET_URL,
|
||||
allowCleartext = BuildConfig.ALLOW_CLEARTEXT,
|
||||
isReleaseBuild = BuildConfig.IS_RELEASE_BUILD,
|
||||
featureAdmin = BuildConfig.FEATURE_ADMIN,
|
||||
featureAdult = BuildConfig.FEATURE_ADULT,
|
||||
feature3d = BuildConfig.FEATURE_3D,
|
||||
featureMinigames = BuildConfig.FEATURE_MINIGAMES,
|
||||
featurePush = BuildConfig.FEATURE_PUSH,
|
||||
gitSha = BuildConfig.GIT_SHA,
|
||||
)
|
||||
}
|
||||
}
|
||||
60
android/native/app/src/main/java/de/yourpart/nativeapp/core/di/AppModule.kt
Executable file
60
android/native/app/src/main/java/de/yourpart/nativeapp/core/di/AppModule.kt
Executable file
@@ -0,0 +1,60 @@
|
||||
package de.yourpart.nativeapp.core.di
|
||||
|
||||
import de.yourpart.nativeapp.BuildConfig
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.AuthHeaderInterceptor
|
||||
import de.yourpart.nativeapp.core.network.NetworkPolicy
|
||||
import de.yourpart.nativeapp.core.realtime.DefaultRealtimeManager
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import javax.inject.Singleton
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppConfig(): AppConfig = AppConfig.fromBuildConfig()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
authHeaderInterceptor: AuthHeaderInterceptor,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.addInterceptor(authHeaderInterceptor)
|
||||
.connectTimeout(NetworkPolicy.connectTimeoutMillis(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(NetworkPolicy.readTimeoutMillis(), TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(NetworkPolicy.writeTimeoutMillis(), TimeUnit.MILLISECONDS)
|
||||
.callTimeout(NetworkPolicy.callTimeoutMillis(), TimeUnit.MILLISECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
|
||||
if (!BuildConfig.IS_RELEASE_BUILD) {
|
||||
builder.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRealtimeManager(impl: DefaultRealtimeManager): RealtimeManager = impl
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.yourpart.nativeapp.core.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.room.Room
|
||||
import de.yourpart.nativeapp.core.persistence.AppCacheDatabase
|
||||
import de.yourpart.nativeapp.core.persistence.AppCacheMigrations
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PersistenceModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePreferencesDataStore(
|
||||
@ApplicationContext context: Context,
|
||||
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.preferencesDataStoreFile("yourpart_native_user_prefs.preferences_pb") },
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppCacheDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): AppCacheDatabase = Room.databaseBuilder(
|
||||
context,
|
||||
AppCacheDatabase::class.java,
|
||||
"yourpart_native_cache.db",
|
||||
).addMigrations(AppCacheMigrations.MIGRATION_1_2).build()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.yourpart.nativeapp.core.di
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.AuthSessionStore
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class StorageModule {
|
||||
@Binds
|
||||
abstract fun bindAuthSessionStore(impl: SessionStore): AuthSessionStore
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.yourpart.nativeapp.core.navigation
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import de.yourpart.nativeapp.ui.navigation.BlogDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.FalukantDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.ForumTopicDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.ForumTopicsDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.GalleryDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.GuideDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.HomeDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.SettingsDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.SocialDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.SocialProfileDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.SocialSearchDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.VocabDestination
|
||||
|
||||
sealed interface AppLink {
|
||||
data class Route(val route: String, val requiresAuth: Boolean) : AppLink
|
||||
data class OAuthCallback(val code: String?, val state: String?, val issuer: String?, val error: String?) : AppLink
|
||||
}
|
||||
|
||||
object AppLinkParser {
|
||||
private const val HOST = "www.your-part.de"
|
||||
|
||||
fun parse(intent: Intent?): AppLink? = intent?.data?.let(::parse)
|
||||
|
||||
fun parse(uri: Uri): AppLink? {
|
||||
if (uri.scheme != "https" || uri.host != HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments == listOf("android", "oauth", "callback")) {
|
||||
return AppLink.OAuthCallback(
|
||||
code = uri.getQueryParameter("code"),
|
||||
state = uri.getQueryParameter("state"),
|
||||
issuer = uri.getQueryParameter("iss"),
|
||||
error = uri.getQueryParameter("error"),
|
||||
)
|
||||
}
|
||||
return when {
|
||||
segments.isEmpty() -> AppLink.Route(HomeDestination.route, requiresAuth = true)
|
||||
segments == listOf("socialnetwork", "search") -> AppLink.Route(SocialSearchDestination.route, true)
|
||||
segments == listOf("socialnetwork", "gallery") -> AppLink.Route(GalleryDestination.route, true)
|
||||
segments.getOrNull(0) == "socialnetwork" && segments.getOrNull(1) == "forum" ->
|
||||
segments.getOrNull(2)?.toLongOrNull()?.let { AppLink.Route(ForumTopicsDestination.createRoute(it), true) }
|
||||
segments.getOrNull(0) == "socialnetwork" && segments.getOrNull(1) == "forumtopic" ->
|
||||
segments.getOrNull(2)?.toLongOrNull()?.let { AppLink.Route(ForumTopicDestination.createRoute(it), true) }
|
||||
segments.getOrNull(0) == "socialnetwork" && segments.getOrNull(1) == "vocab" -> AppLink.Route(VocabDestination.route, true)
|
||||
segments.getOrNull(0) == "socialnetwork" && segments.getOrNull(1) == "profile" ->
|
||||
segments.getOrNull(2)?.takeIf { it.isNotBlank() }?.let { AppLink.Route(SocialProfileDestination.createRoute(it), true) }
|
||||
segments.firstOrNull() == "falukant" -> AppLink.Route(FalukantDestination.route, true)
|
||||
segments.firstOrNull() == "settings" -> AppLink.Route(SettingsDestination.route, true)
|
||||
segments.firstOrNull() == "blogs" -> AppLink.Route(BlogDestination.route, requiresAuth = false)
|
||||
segments.firstOrNull() == "guides" -> AppLink.Route(GuideDestination.route, requiresAuth = false)
|
||||
segments.firstOrNull() == "socialnetwork" -> AppLink.Route(SocialDestination.route, true)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
sealed interface ApiResult<out T> {
|
||||
data class Success<T>(val value: T) : ApiResult<T>
|
||||
data class Failure(val error: NetworkError) : ApiResult<Nothing>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.AuthSessionStore
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthHeaderInterceptor @Inject constructor(
|
||||
private val sessionStore: AuthSessionStore,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val session = sessionStore.currentSession
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
|
||||
session?.user?.let { user ->
|
||||
requestBuilder.header("userid", user.id)
|
||||
requestBuilder.header("authcode", user.authCode)
|
||||
}
|
||||
|
||||
val response = chain.proceed(requestBuilder.build())
|
||||
if (response.code == 401 && session != null) {
|
||||
sessionStore.clearSession(notifyExpired = true)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.ErrorResponse
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object BackendErrorParser {
|
||||
fun parseBackendCode(rawBody: String?, json: Json): String? {
|
||||
if (rawBody.isNullOrBlank()) return null
|
||||
return runCatching {
|
||||
json.decodeFromString<ErrorResponse>(rawBody).error
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun toNetworkError(
|
||||
statusCode: Int,
|
||||
rawBody: String?,
|
||||
json: Json,
|
||||
): NetworkError {
|
||||
return HttpNetworkError(
|
||||
statusCode = statusCode,
|
||||
backendCode = parseBackendCode(rawBody, json),
|
||||
rawBody = rawBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DownloadHelper @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
suspend fun downloadToFile(
|
||||
url: String,
|
||||
destination: File,
|
||||
): Result<File> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Download failed with HTTP ${response.code}")
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IllegalStateException("Download response body missing")
|
||||
destination.outputStream().use { output ->
|
||||
body.byteStream().use { input -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
destination
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class MultipartUploadHelper(
|
||||
private val contentResolver: ContentResolver,
|
||||
) {
|
||||
@WorkerThread
|
||||
fun toMultipartPart(
|
||||
uri: Uri,
|
||||
formField: String,
|
||||
fileName: String = "upload.bin",
|
||||
): MultipartBody.Part {
|
||||
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val bytes = requireNotNull(contentResolver.openInputStream(uri)?.use { it.readBytes() }) {
|
||||
"Unable to read content URI: $uri"
|
||||
}
|
||||
|
||||
val requestBody = bytes.toRequestBody(mimeType.toMediaType())
|
||||
return MultipartBody.Part.createFormData(formField, fileName, requestBody)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
sealed interface NetworkError {
|
||||
val message: String
|
||||
}
|
||||
|
||||
data class HttpNetworkError(
|
||||
val statusCode: Int,
|
||||
val backendCode: String? = null,
|
||||
val rawBody: String? = null,
|
||||
override val message: String = when (backendCode) {
|
||||
"credentialsinvalid" -> "Benutzername oder Passwort ist falsch."
|
||||
"userblocked" -> "Dieses Konto ist deaktiviert."
|
||||
"emailinuse" -> "Diese E-Mail-Adresse wird bereits verwendet."
|
||||
"languagenotfound" -> "Die Sprachkonfiguration konnte nicht geladen werden."
|
||||
else -> "Die Anfrage wurde vom Server abgelehnt."
|
||||
},
|
||||
) : NetworkError
|
||||
|
||||
data object NoNetworkError : NetworkError {
|
||||
override val message: String = "Keine Netzwerkverbindung."
|
||||
}
|
||||
|
||||
data object TimeoutNetworkError : NetworkError {
|
||||
override val message: String = "Die Anfrage hat zu lange gedauert."
|
||||
}
|
||||
|
||||
data class SerializationNetworkError(
|
||||
override val message: String = "Antwort konnte nicht verarbeitet werden.",
|
||||
) : NetworkError
|
||||
|
||||
data class UnknownNetworkError(
|
||||
override val message: String = "Unbekannter Netzwerkfehler.",
|
||||
) : NetworkError
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object NetworkPolicy {
|
||||
const val CONNECT_TIMEOUT_SECONDS = 15L
|
||||
const val READ_TIMEOUT_SECONDS = 30L
|
||||
const val WRITE_TIMEOUT_SECONDS = 30L
|
||||
const val CALL_TIMEOUT_SECONDS = 60L
|
||||
const val MAX_RETRY_ATTEMPTS = 2
|
||||
|
||||
fun connectTimeoutMillis(): Long = TimeUnit.SECONDS.toMillis(CONNECT_TIMEOUT_SECONDS)
|
||||
fun readTimeoutMillis(): Long = TimeUnit.SECONDS.toMillis(READ_TIMEOUT_SECONDS)
|
||||
fun writeTimeoutMillis(): Long = TimeUnit.SECONDS.toMillis(WRITE_TIMEOUT_SECONDS)
|
||||
fun callTimeoutMillis(): Long = TimeUnit.SECONDS.toMillis(CALL_TIMEOUT_SECONDS)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NetworkRequestExecutor @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val json: Json,
|
||||
) {
|
||||
suspend fun execute(request: Request): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
val payload = response.body?.string().orEmpty()
|
||||
if (response.isSuccessful) {
|
||||
ApiResult.Success(payload)
|
||||
} else {
|
||||
ApiResult.Failure(BackendErrorParser.toNetworkError(response.code, payload, json))
|
||||
}
|
||||
}
|
||||
}.getOrElse { throwable ->
|
||||
ApiResult.Failure(throwable.toNetworkError())
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.toNetworkError(): NetworkError = when (this) {
|
||||
is SocketTimeoutException -> TimeoutNetworkError
|
||||
is IOException -> NoNetworkError
|
||||
else -> UnknownNetworkError(message ?: UnknownNetworkError().message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
data class PageRequest(
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 30,
|
||||
)
|
||||
|
||||
data class PageResponse<T>(
|
||||
val items: List<T>,
|
||||
val page: Int,
|
||||
val pageSize: Int,
|
||||
val hasMore: Boolean,
|
||||
)
|
||||
|
||||
sealed interface RefreshState {
|
||||
data object Idle : RefreshState
|
||||
data object Loading : RefreshState
|
||||
data object Error : RefreshState
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
UserProfileCacheEntity::class,
|
||||
FriendSearchCacheEntity::class,
|
||||
FalukantStatusCacheEntity::class,
|
||||
VocabCourseCacheEntity::class,
|
||||
VocabLessonCacheEntity::class,
|
||||
VocabReviewOutboxEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AppCacheDatabase : RoomDatabase() {
|
||||
abstract fun userProfileCacheDao(): UserProfileCacheDao
|
||||
abstract fun friendSearchCacheDao(): FriendSearchCacheDao
|
||||
abstract fun falukantStatusCacheDao(): FalukantStatusCacheDao
|
||||
abstract fun vocabCourseCacheDao(): VocabCourseCacheDao
|
||||
abstract fun vocabLessonCacheDao(): VocabLessonCacheDao
|
||||
abstract fun vocabReviewOutboxDao(): VocabReviewOutboxDao
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
object AppCacheMigrations {
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `vocab_review_outbox` (
|
||||
`requestId` TEXT NOT NULL,
|
||||
`payloadJson` TEXT NOT NULL,
|
||||
`createdAtMillis` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`requestId`)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface UserProfileCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: UserProfileCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM user_profile_cache WHERE userId = :userId LIMIT 1")
|
||||
fun observe(userId: String): Flow<UserProfileCacheEntity?>
|
||||
|
||||
@Query("DELETE FROM user_profile_cache")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface FriendSearchCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: FriendSearchCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM friend_search_cache WHERE query = :query LIMIT 1")
|
||||
fun observe(query: String): Flow<FriendSearchCacheEntity?>
|
||||
|
||||
@Query("DELETE FROM friend_search_cache")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface FalukantStatusCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: FalukantStatusCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM falukant_status_cache WHERE cacheKey = 'current' LIMIT 1")
|
||||
fun observe(): Flow<FalukantStatusCacheEntity?>
|
||||
|
||||
@Query("DELETE FROM falukant_status_cache")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface VocabCourseCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: VocabCourseCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM vocab_course_cache WHERE courseId = :courseId LIMIT 1")
|
||||
fun observe(courseId: String): Flow<VocabCourseCacheEntity?>
|
||||
|
||||
@Query("DELETE FROM vocab_course_cache")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface VocabLessonCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: VocabLessonCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM vocab_lesson_cache WHERE lessonId = :lessonId LIMIT 1")
|
||||
fun observe(lessonId: String): Flow<VocabLessonCacheEntity?>
|
||||
|
||||
@Query("SELECT * FROM vocab_lesson_cache WHERE lessonId = :lessonId LIMIT 1")
|
||||
suspend fun get(lessonId: String): VocabLessonCacheEntity?
|
||||
|
||||
@Query("DELETE FROM vocab_lesson_cache WHERE courseId = :courseId")
|
||||
suspend fun clearForCourse(courseId: String)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface VocabReviewOutboxDao {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun enqueue(entity: VocabReviewOutboxEntity)
|
||||
|
||||
@Query("SELECT * FROM vocab_review_outbox ORDER BY createdAtMillis ASC")
|
||||
suspend fun pending(): List<VocabReviewOutboxEntity>
|
||||
|
||||
@Query("DELETE FROM vocab_review_outbox WHERE requestId = :requestId")
|
||||
suspend fun delete(requestId: String)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "user_profile_cache")
|
||||
data class UserProfileCacheEntity(
|
||||
@PrimaryKey val userId: String,
|
||||
val username: String,
|
||||
val displayName: String? = null,
|
||||
val avatarUrl: String? = null,
|
||||
val profileJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "friend_search_cache")
|
||||
data class FriendSearchCacheEntity(
|
||||
@PrimaryKey val query: String,
|
||||
val resultsJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "falukant_status_cache")
|
||||
data class FalukantStatusCacheEntity(
|
||||
@PrimaryKey val cacheKey: String = "current",
|
||||
val statusJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "vocab_course_cache")
|
||||
data class VocabCourseCacheEntity(
|
||||
@PrimaryKey val courseId: String,
|
||||
val title: String,
|
||||
val summary: String? = null,
|
||||
val courseJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "vocab_lesson_cache")
|
||||
data class VocabLessonCacheEntity(
|
||||
@PrimaryKey val lessonId: String,
|
||||
val courseId: String,
|
||||
val title: String,
|
||||
val lessonJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
/** A review is kept locally until the server acknowledges it. */
|
||||
@Entity(tableName = "vocab_review_outbox")
|
||||
data class VocabReviewOutboxEntity(
|
||||
@PrimaryKey val requestId: String,
|
||||
val payloadJson: String,
|
||||
val createdAtMillis: Long,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object CachePolicy {
|
||||
const val USER_PROFILE_TTL_MILLIS = 24L * 60L * 60L * 1000L
|
||||
const val FRIEND_SEARCH_TTL_MILLIS = 15L * 60L * 1000L
|
||||
const val FALUKANT_STATUS_TTL_MILLIS = 5L * 60L * 1000L
|
||||
const val VOCAB_COURSE_TTL_MILLIS = 12L * 60L * 60L * 1000L
|
||||
const val VOCAB_LESSON_TTL_MILLIS = 12L * 60L * 60L * 1000L
|
||||
|
||||
fun isExpired(updatedAtMillis: Long, ttlMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean {
|
||||
return nowMillis - updatedAtMillis > ttlMillis
|
||||
}
|
||||
|
||||
fun ttlHours(hours: Long): Long = TimeUnit.HOURS.toMillis(hours)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
data class FeatureFlagState(
|
||||
val adminEnabled: Boolean = false,
|
||||
val adultEnabled: Boolean = false,
|
||||
val threeDEnabled: Boolean = false,
|
||||
val minigamesEnabled: Boolean = false,
|
||||
val pushEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class UserPreferencesStore @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
val language: Flow<String> = dataStore.data.map { prefs ->
|
||||
prefs[Keys.LANGUAGE] ?: DEFAULT_LANGUAGE
|
||||
}
|
||||
|
||||
val featureFlags: Flow<FeatureFlagState> = dataStore.data.map { prefs ->
|
||||
FeatureFlagState(
|
||||
adminEnabled = prefs[Keys.FEATURE_ADMIN] ?: false,
|
||||
adultEnabled = prefs[Keys.FEATURE_ADULT] ?: false,
|
||||
threeDEnabled = prefs[Keys.FEATURE_3D] ?: false,
|
||||
minigamesEnabled = prefs[Keys.FEATURE_MINIGAMES] ?: false,
|
||||
pushEnabled = prefs[Keys.FEATURE_PUSH] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setLanguage(language: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[Keys.LANGUAGE] = language.trim().ifBlank { DEFAULT_LANGUAGE }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setFeatureFlags(state: FeatureFlagState) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[Keys.FEATURE_ADMIN] = state.adminEnabled
|
||||
prefs[Keys.FEATURE_ADULT] = state.adultEnabled
|
||||
prefs[Keys.FEATURE_3D] = state.threeDEnabled
|
||||
prefs[Keys.FEATURE_MINIGAMES] = state.minigamesEnabled
|
||||
prefs[Keys.FEATURE_PUSH] = state.pushEnabled
|
||||
}
|
||||
}
|
||||
|
||||
private object Keys {
|
||||
val LANGUAGE = stringPreferencesKey("ui_language")
|
||||
val FEATURE_ADMIN = booleanPreferencesKey("feature_admin")
|
||||
val FEATURE_ADULT = booleanPreferencesKey("feature_adult")
|
||||
val FEATURE_3D = booleanPreferencesKey("feature_3d")
|
||||
val FEATURE_MINIGAMES = booleanPreferencesKey("feature_minigames")
|
||||
val FEATURE_PUSH = booleanPreferencesKey("feature_push")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_LANGUAGE = "de"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
object DaemonMessageParser {
|
||||
fun parse(rawMessage: String, json: Json): DaemonEnvelope? {
|
||||
val trimmed = rawMessage.trim()
|
||||
if (trimmed.isEmpty() || trimmed == "ping" || trimmed == "pong") {
|
||||
return null
|
||||
}
|
||||
|
||||
val element = runCatching { json.parseToJsonElement(trimmed) }.getOrNull() ?: return null
|
||||
val obj = element as? JsonObject ?: return null
|
||||
val event = obj["event"]?.toString()?.trim('"')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
val payload = obj["data"]?.toString()
|
||||
return DaemonEnvelope(event = event, payloadRaw = payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import io.socket.client.IO
|
||||
import io.socket.client.Socket
|
||||
import io.socket.emitter.Emitter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Singleton
|
||||
class DefaultRealtimeManager @Inject constructor(
|
||||
private val appConfig: AppConfig,
|
||||
private val json: Json,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) : RealtimeManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val _connectionSnapshot = MutableStateFlow(RealtimeConnectionSnapshot())
|
||||
override val connectionSnapshot: StateFlow<RealtimeConnectionSnapshot> = _connectionSnapshot.asStateFlow()
|
||||
private val _events = MutableSharedFlow<RealtimeSocketEvent>(extraBufferCapacity = 64)
|
||||
override val events: SharedFlow<RealtimeSocketEvent> = _events.asSharedFlow()
|
||||
|
||||
@Volatile
|
||||
private var session: AuthSession? = null
|
||||
|
||||
@Volatile
|
||||
private var isForeground: Boolean = false
|
||||
|
||||
private var backendSocket: Socket? = null
|
||||
private var daemonWebSocket: WebSocket? = null
|
||||
private val reconnectingDaemon = AtomicBoolean(false)
|
||||
|
||||
override fun sync(context: RealtimeSessionContext) {
|
||||
session = context.session
|
||||
isForeground = context.isForeground
|
||||
reconnectingDaemon.set(false)
|
||||
scope.launch { reconcileConnections() }
|
||||
}
|
||||
|
||||
override fun onAppForeground() {
|
||||
isForeground = true
|
||||
scope.launch { reconcileConnections() }
|
||||
}
|
||||
|
||||
override fun onAppBackground() {
|
||||
isForeground = false
|
||||
scope.launch { disconnectAll() }
|
||||
}
|
||||
|
||||
private suspend fun reconcileConnections() {
|
||||
if (session == null || !isForeground) {
|
||||
disconnectAll()
|
||||
return
|
||||
}
|
||||
|
||||
connectBackend(session)
|
||||
connectDaemon(session)
|
||||
}
|
||||
|
||||
private suspend fun connectBackend(session: AuthSession?) {
|
||||
if (backendSocket?.connected() == true) {
|
||||
backendSocket?.emit("setUserId", session?.user?.id)
|
||||
return
|
||||
}
|
||||
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(backend = RealtimeConnectionStatus.Connecting)
|
||||
val options = IO.Options().apply {
|
||||
forceNew = true
|
||||
reconnection = true
|
||||
reconnectionAttempts = 5
|
||||
reconnectionDelay = 1_000
|
||||
timeout = 10_000
|
||||
transports = arrayOf("websocket")
|
||||
query = "client=native"
|
||||
}
|
||||
|
||||
val socket = IO.socket(appConfig.socketIoUrl, options)
|
||||
backendSocket = socket
|
||||
|
||||
socket.on(Socket.EVENT_CONNECT) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(backend = RealtimeConnectionStatus.Connected)
|
||||
session?.user?.id?.let { userId ->
|
||||
socket.emit("setUserId", userId)
|
||||
}
|
||||
}
|
||||
socket.on(Socket.EVENT_DISCONNECT) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(backend = RealtimeConnectionStatus.Disconnected)
|
||||
}
|
||||
socket.on(Socket.EVENT_CONNECT_ERROR) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(backend = RealtimeConnectionStatus.Error)
|
||||
}
|
||||
|
||||
registerSocketEventHandlers(socket)
|
||||
socket.connect()
|
||||
}
|
||||
|
||||
private fun registerSocketEventHandlers(socket: Socket) {
|
||||
val eventNames = listOf(
|
||||
"forumschanged",
|
||||
"topicschanged",
|
||||
"messageschanged",
|
||||
"friendloginchanged",
|
||||
"reloadmenu",
|
||||
"adultVerificationChanged",
|
||||
"moderationReportChanged",
|
||||
"userAccessChanged",
|
||||
"falukantUpdateStatus",
|
||||
"falukantUpdateFamily",
|
||||
"falukantUpdateChurch",
|
||||
"falukantUpdateDebt",
|
||||
"children_update",
|
||||
"falukantUpdateProductionCertificate",
|
||||
"falukantBranchUpdate",
|
||||
"stock_change",
|
||||
"familychanged",
|
||||
)
|
||||
|
||||
eventNames.forEach { eventName ->
|
||||
socket.on(eventName, socketEventListener(eventName))
|
||||
}
|
||||
}
|
||||
|
||||
private fun socketEventListener(eventName: String): Emitter.Listener = Emitter.Listener { args ->
|
||||
val payloadRaw = args.firstOrNull()?.let { toRawJson(it) }
|
||||
_events.tryEmit(
|
||||
RealtimeSocketEvent(
|
||||
source = RealtimeSource.BackendSocket,
|
||||
eventName = eventName,
|
||||
payloadRaw = payloadRaw,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun connectDaemon(session: AuthSession?) {
|
||||
if (daemonWebSocket != null) return
|
||||
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(daemon = RealtimeConnectionStatus.Connecting)
|
||||
val request = Request.Builder()
|
||||
.url(appConfig.daemonSocketUrl)
|
||||
.build()
|
||||
|
||||
daemonWebSocket = suspendCancellableCoroutine { continuation ->
|
||||
val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(daemon = RealtimeConnectionStatus.Connected)
|
||||
session?.user?.id?.let { userId ->
|
||||
webSocket.send(
|
||||
"""{"event":"setUserId","data":{"userId":"$userId"}}""",
|
||||
)
|
||||
}
|
||||
continuation.resume(webSocket)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (text == "ping") {
|
||||
webSocket.send("pong")
|
||||
return
|
||||
}
|
||||
|
||||
val envelope = DaemonMessageParser.parse(text, json) ?: return
|
||||
_events.tryEmit(
|
||||
RealtimeSocketEvent(
|
||||
source = RealtimeSource.DaemonWebSocket,
|
||||
eventName = envelope.event,
|
||||
payloadRaw = envelope.payloadRaw,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(daemon = RealtimeConnectionStatus.Disconnected)
|
||||
if (shouldReconnect()) {
|
||||
scheduleDaemonReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(daemon = RealtimeConnectionStatus.Error)
|
||||
if (shouldReconnect()) {
|
||||
scheduleDaemonReconnect()
|
||||
}
|
||||
if (!continuation.isCompleted) {
|
||||
continuation.resumeWithException(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
val webSocket = okHttpClient.newWebSocket(request, listener)
|
||||
continuation.invokeOnCancellation {
|
||||
webSocket.close(1000, "cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleDaemonReconnect() {
|
||||
if (!reconnectingDaemon.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
delay(2_000)
|
||||
reconnectingDaemon.set(false)
|
||||
if (shouldReconnect()) {
|
||||
daemonWebSocket = null
|
||||
connectDaemon(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldReconnect(): Boolean = isForeground && session != null
|
||||
|
||||
private suspend fun disconnectAll() {
|
||||
backendSocket?.let { socket ->
|
||||
socket.off()
|
||||
socket.disconnect()
|
||||
socket.close()
|
||||
}
|
||||
backendSocket = null
|
||||
|
||||
daemonWebSocket?.close(1000, "background")
|
||||
daemonWebSocket = null
|
||||
|
||||
_connectionSnapshot.value = RealtimeConnectionSnapshot()
|
||||
}
|
||||
|
||||
private fun toRawJson(value: Any): String? = when (value) {
|
||||
is JSONObject -> value.toString()
|
||||
is String -> value
|
||||
is Number, is Boolean -> value.toString()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import de.yourpart.nativeapp.core.persistence.AppCacheDatabase
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RealtimeCacheInvalidator @Inject constructor(
|
||||
private val cacheDatabase: AppCacheDatabase,
|
||||
) {
|
||||
suspend fun handle(event: RealtimeSocketEvent) {
|
||||
when (event.eventName) {
|
||||
"reloadmenu" -> {
|
||||
// Menu ist aus API-Policy abgeleitet; kein lokaler Cache zu leeren.
|
||||
}
|
||||
"friendloginchanged", "forumschanged" -> {
|
||||
cacheDatabase.friendSearchCacheDao().clear()
|
||||
}
|
||||
"adultVerificationChanged", "moderationReportChanged", "userAccessChanged" -> {
|
||||
cacheDatabase.userProfileCacheDao().clear()
|
||||
}
|
||||
"falukantUpdateStatus", "falukantUpdateFamily", "falukantUpdateChurch", "falukantUpdateDebt",
|
||||
"children_update", "falukantUpdateProductionCertificate", "falukantBranchUpdate", "stock_change", "familychanged" -> {
|
||||
cacheDatabase.falukantStatusCacheDao().clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class RealtimeDebugState(
|
||||
val recentEvents: List<RealtimeSocketEvent> = emptyList(),
|
||||
val invalidationTags: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class RealtimeEventCenter @Inject constructor() {
|
||||
private val _debugState = MutableStateFlow(RealtimeDebugState())
|
||||
val debugState: StateFlow<RealtimeDebugState> = _debugState.asStateFlow()
|
||||
|
||||
fun handle(event: RealtimeSocketEvent) {
|
||||
val updatedTags = when (event.eventName) {
|
||||
"reloadmenu" -> setOf("menu", "dashboard")
|
||||
"friendloginchanged", "forumschanged", "topicschanged", "messageschanged" -> setOf("social", "community", "forum")
|
||||
"adultVerificationChanged", "moderationReportChanged", "userAccessChanged" -> setOf("account", "access")
|
||||
"falukantUpdateStatus", "falukantUpdateFamily", "falukantUpdateChurch", "falukantUpdateDebt",
|
||||
"children_update", "falukantUpdateProductionCertificate", "falukantBranchUpdate", "stock_change", "familychanged" -> setOf("falukant")
|
||||
else -> emptySet()
|
||||
}
|
||||
|
||||
_debugState.value = _debugState.value.copy(
|
||||
recentEvents = (_debugState.value.recentEvents + event).takeLast(20),
|
||||
invalidationTags = _debugState.value.invalidationTags + updatedTags,
|
||||
)
|
||||
}
|
||||
|
||||
fun acknowledgeInvalidations() {
|
||||
_debugState.value = _debugState.value.copy(invalidationTags = emptySet())
|
||||
}
|
||||
|
||||
fun clearHistory() {
|
||||
_debugState.value = RealtimeDebugState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface RealtimeManager {
|
||||
val connectionSnapshot: StateFlow<RealtimeConnectionSnapshot>
|
||||
val events: SharedFlow<RealtimeSocketEvent>
|
||||
|
||||
fun sync(context: RealtimeSessionContext)
|
||||
fun onAppForeground()
|
||||
fun onAppBackground()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
|
||||
enum class RealtimeSource {
|
||||
BackendSocket,
|
||||
DaemonWebSocket,
|
||||
}
|
||||
|
||||
enum class RealtimeConnectionStatus {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class RealtimeSocketEvent(
|
||||
val source: RealtimeSource,
|
||||
val eventName: String,
|
||||
val payloadRaw: String? = null,
|
||||
)
|
||||
|
||||
data class RealtimeConnectionSnapshot(
|
||||
val backend: RealtimeConnectionStatus = RealtimeConnectionStatus.Disconnected,
|
||||
val daemon: RealtimeConnectionStatus = RealtimeConnectionStatus.Disconnected,
|
||||
)
|
||||
|
||||
data class DaemonEnvelope(
|
||||
val event: String,
|
||||
val payloadRaw: String? = null,
|
||||
)
|
||||
|
||||
data class RealtimeSessionContext(
|
||||
val session: AuthSession?,
|
||||
val isForeground: Boolean,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.yourpart.nativeapp.feature.admin
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Request
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class NavigationNodeDto(
|
||||
val path: String? = null,
|
||||
val children: Map<String, NavigationNodeDto> = emptyMap(),
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class AdminAccessRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun loadAdminPaths(userId: String): Result<Set<String>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/navigation/$userId")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val menu = json.decodeFromString<Map<String, NavigationNodeDto>>(result.value)
|
||||
menu["administration"]?.paths().orEmpty()
|
||||
}
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavigationNodeDto.paths(): Set<String> = buildSet {
|
||||
path?.let(::add)
|
||||
children.values.forEach { addAll(it.paths()) }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.yourpart.nativeapp.feature.admin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AdminAccessUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val paths: Set<String> = emptySet(),
|
||||
) {
|
||||
val hasAdminAccess: Boolean get() = paths.isNotEmpty()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AdminAccessViewModel @Inject constructor(
|
||||
private val repository: AdminAccessRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AdminAccessUiState())
|
||||
val state: StateFlow<AdminAccessUiState> = _state.asStateFlow()
|
||||
|
||||
fun refresh(userId: String?) {
|
||||
if (userId == null) {
|
||||
_state.value = AdminAccessUiState()
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true)
|
||||
repository.loadAdminPaths(userId)
|
||||
.onSuccess { paths -> _state.value = AdminAccessUiState(paths = paths) }
|
||||
.onFailure { _state.value = AdminAccessUiState() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.yourpart.nativeapp.feature.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
private val adminAreas = listOf(
|
||||
"/admin/users" to "Benutzer und Rechte",
|
||||
"/admin/rights" to "Berechtigungen",
|
||||
"/admin/moderation/reports" to "Moderationsmeldungen",
|
||||
"/admin/users/adult-verification" to "Altersverifikation",
|
||||
"/admin/users/erotic-moderation" to "Erotikmoderation",
|
||||
"/admin/forum" to "Forumverwaltung",
|
||||
"/admin/falukant" to "Falukant-Verwaltung",
|
||||
"/admin/services/status" to "Service-Status",
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AdminScreen(paths: Set<String>) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val visibleAreas = adminAreas.filter { (path, _) -> paths.any { it == path || it.startsWith("$path/") } }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard(
|
||||
"Administration",
|
||||
"Der Zugriff wird bei jeder Sitzung aus der vom Backend gefilterten Navigation abgeleitet. Das Build-Feature-Flag ist nur eine zusätzliche Schutzschicht.",
|
||||
)
|
||||
if (visibleAreas.isEmpty()) {
|
||||
YpEmptyState("Keine Berechtigung", "Für dieses Konto sind keine nativen Administrationsbereiche freigegeben.")
|
||||
} else {
|
||||
Text("Freigegebene Bereiche", style = MaterialTheme.typography.titleMedium)
|
||||
visibleAreas.forEach { (_, title) -> YpInfoCard(title, "Backend-Vertrag erfasst; serverseitige Rechteprüfung bleibt verbindlich.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.yourpart.nativeapp.feature.adult
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable data class AdultAccountDto(val isAdult: Boolean = false, val adultVerificationStatus: String = "none", val adultAccessEnabled: Boolean = false)
|
||||
@Serializable data class AdultFolderDto(val id: Long, val name: String = "")
|
||||
|
||||
@Singleton
|
||||
class AdultRepository @Inject constructor(
|
||||
private val executor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val sessions: SessionStore,
|
||||
private val config: AppConfig,
|
||||
@ApplicationContext private val context: android.content.Context,
|
||||
) {
|
||||
suspend fun account(): Result<AdultAccountDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val userId = sessions.currentSession?.user?.id ?: error("Keine aktive Sitzung.")
|
||||
val body = json.encodeToString(mapOf("userId" to userId)).toRequestBody("application/json".toMediaTypeOrNull())
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/settings/account").post(body).build()
|
||||
when (val result = executor.execute(request)) { is ApiResult.Success -> json.decodeFromString(result.value); is ApiResult.Failure -> error(result.error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun folders(): Result<List<AdultFolderDto>> = get("/api/socialnetwork/erotic/folders")
|
||||
|
||||
suspend fun submitVerification(uri: Uri, note: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val resolver: ContentResolver = context.contentResolver
|
||||
val type = resolver.getType(uri) ?: "application/octet-stream"
|
||||
require(type in setOf("image/jpeg", "image/png", "image/webp", "application/pdf")) { "Erlaubt sind JPEG, PNG, WebP oder PDF." }
|
||||
val file = File.createTempFile("adult-verification-", ".upload", context.cacheDir)
|
||||
resolver.openInputStream(uri).use { input -> requireNotNull(input) { "Datei konnte nicht gelesen werden." }.copyTo(file.outputStream()) }
|
||||
try {
|
||||
val body = MultipartBody.Builder().setType(MultipartBody.FORM)
|
||||
.addFormDataPart("note", note.trim())
|
||||
.addFormDataPart("document", "verification", file.asRequestBody(type.toMediaTypeOrNull()))
|
||||
.build()
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/settings/adult-verification/request").post(body).build()
|
||||
when (executor.execute(request)) { is ApiResult.Success -> Unit; is ApiResult.Failure -> error("Verifikationsantrag konnte nicht gesendet werden.") }
|
||||
} finally { file.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> get(path: String): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}$path").get().build()
|
||||
when (val result = executor.execute(request)) { is ApiResult.Success -> json.decodeFromString<T>(result.value); is ApiResult.Failure -> error(result.error.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.yourpart.nativeapp.feature.adult
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
@Composable fun AdultScreen(state: AdultUiState, onSubmit: (android.net.Uri, String) -> Unit) {
|
||||
val spacing = LocalYpSpacing.current; var note by remember { mutableStateOf("") }
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri != null) onSubmit(uri, note) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard("Geschützter Bereich", "Die App zeigt Inhalte nur bei aktivem Feature-Flag, bestätigter Volljährigkeit und serverseitig bestätigter Freischaltung.")
|
||||
when {
|
||||
state.loading -> Text("Prüfe Zugriffsstatus …")
|
||||
!state.account.isAdult -> YpEmptyState("Nicht verfügbar", "Dieser Bereich ist ausschließlich für volljährige Nutzer.")
|
||||
!state.account.adultAccessEnabled -> {
|
||||
Text("Freischaltstatus: ${state.account.adultVerificationStatus}", style = MaterialTheme.typography.titleMedium)
|
||||
YpTextField(note, { note = it }, "Optionale Nachricht")
|
||||
YpPrimaryButton("Nachweis auswählen", { picker.launch("application/pdf,image/jpeg,image/png,image/webp") }, Modifier.fillMaxWidth())
|
||||
}
|
||||
else -> {
|
||||
Text("Freigeschaltet", style = MaterialTheme.typography.titleMedium)
|
||||
if (state.folders.isEmpty()) YpEmptyState("Keine Inhalte", "Es sind derzeit keine geschützten Ordner verfügbar.")
|
||||
state.folders.forEach { folder -> YpInfoCard(folder.name.ifBlank { "Ordner ${folder.id}" }, "Geschützter Inhaltsordner") }
|
||||
}
|
||||
}
|
||||
state.message?.let { YpInfoCard("Status", it) }; state.error?.let { YpEmptyState("Fehler", it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.yourpart.nativeapp.feature.adult
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AdultUiState(val loading: Boolean = true, val account: AdultAccountDto = AdultAccountDto(), val folders: List<AdultFolderDto> = emptyList(), val message: String? = null, val error: String? = null)
|
||||
|
||||
@HiltViewModel class AdultViewModel @Inject constructor(private val repository: AdultRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AdultUiState()); val state: StateFlow<AdultUiState> = _state.asStateFlow()
|
||||
init { refresh() }
|
||||
fun refresh() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
repository.account().onSuccess { account ->
|
||||
_state.value = _state.value.copy(loading = false, account = account)
|
||||
if (account.adultAccessEnabled) repository.folders().onSuccess { folders -> _state.value = _state.value.copy(folders = folders) }.onFailure { e -> _state.value = _state.value.copy(error = e.message) }
|
||||
}.onFailure { e -> _state.value = _state.value.copy(loading = false, error = e.message) }
|
||||
}
|
||||
fun submit(uri: Uri, note: String) = viewModelScope.launch { repository.submitVerification(uri, note).onSuccess { _state.value = _state.value.copy(message = "Antrag eingereicht."); refresh() }.onFailure { e -> _state.value = _state.value.copy(error = e.message) } }
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.core.auth.model.ForgotPasswordRequest
|
||||
import de.yourpart.nativeapp.core.auth.model.LoginRequest
|
||||
import de.yourpart.nativeapp.core.auth.model.OAuthProviderDto
|
||||
import de.yourpart.nativeapp.core.auth.model.RegisterRequest
|
||||
import de.yourpart.nativeapp.core.auth.model.UserDto
|
||||
import de.yourpart.nativeapp.core.auth.storage.AuthSessionStore
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val sessionStore: AuthSessionStore,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
rememberMe: Boolean,
|
||||
): Result<UserDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(LoginRequest(username, password))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/login")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val user = json.decodeFromString<UserDto>(result.value)
|
||||
if (!user.active) {
|
||||
throw AuthException("Dieses Konto ist deaktiviert.")
|
||||
}
|
||||
sessionStore.saveSession(
|
||||
AuthSession(
|
||||
user = user,
|
||||
rememberMe = rememberMe,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
user
|
||||
}
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(
|
||||
email: String,
|
||||
username: String,
|
||||
password: String,
|
||||
language: String,
|
||||
): Result<UserDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(
|
||||
RegisterRequest(
|
||||
email = email,
|
||||
username = username,
|
||||
password = password,
|
||||
language = language,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/register")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<UserDto>(result.value)
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestPasswordReset(email: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(ForgotPasswordRequest(email))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/forgot-password")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val payload = json.decodeFromString<ForgotPasswordResponse>(result.value)
|
||||
payload.message.ifBlank { "Password reset email sent" }
|
||||
}
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadOAuthProviders(): Result<List<OAuthProviderDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/oauth/providers")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<OAuthProvidersResponse>(result.value).providers
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun oauthStartUrl(provider: OAuthProviderDto): String =
|
||||
"${appConfig.apiBaseUrl}/api/auth/oauth/${provider.slug}/start?client=android"
|
||||
|
||||
suspend fun exchangeOAuth(code: String, state: String, issuer: String?): Result<UserDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = OAuthExchangeRequest(code = code, state = state, iss = issuer)
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/oauth/exchange")
|
||||
.post(json.encodeToString(payload).toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val user = json.decodeFromString<UserDto>(result.value)
|
||||
if (!user.active) throw AuthException("Dieses Konto ist deaktiviert.")
|
||||
sessionStore.saveSession(AuthSession(user = user, rememberMe = true, createdAt = System.currentTimeMillis()))
|
||||
user
|
||||
}
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout() = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/logout")
|
||||
.get()
|
||||
.build()
|
||||
requestExecutor.execute(request)
|
||||
}
|
||||
sessionStore.clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
class AuthException(message: String) : IllegalStateException(message)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class OAuthProvidersResponse(
|
||||
val providers: List<OAuthProviderDto> = emptyList(),
|
||||
)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class ForgotPasswordResponse(
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class OAuthExchangeRequest(
|
||||
val code: String,
|
||||
val state: String,
|
||||
val iss: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
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 de.yourpart.nativeapp.core.auth.model.OAuthProviderDto
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
uiState: LoginUiState,
|
||||
oauthProviders: List<OAuthProviderDto>,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onRememberMeChange: (Boolean) -> Unit,
|
||||
onLogin: () -> Unit,
|
||||
onRegisterClick: () -> Unit,
|
||||
onForgotPasswordClick: () -> Unit,
|
||||
onOAuthLogin: (OAuthProviderDto) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
YpInfoCard(
|
||||
title = "YourPart",
|
||||
body = "Die native App startet hier fuer nicht eingeloggte Nutzer und fuehrt danach in die Dashboard- und Modulwelt.",
|
||||
)
|
||||
Text("Anmeldung", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Die native App nutzt denselben Backend-Login wie die Web-App: `/api/auth/login` mit Rueckgabe eines Users inklusive `authCode`.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
YpTextField(
|
||||
value = uiState.username,
|
||||
onValueChange = onUsernameChange,
|
||||
label = "Benutzername",
|
||||
)
|
||||
YpTextField(
|
||||
value = uiState.password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = "Passwort",
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = uiState.rememberMe,
|
||||
onCheckedChange = onRememberMeChange,
|
||||
)
|
||||
Text("Sitzung merken", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
if (!uiState.errorMessage.isNullOrBlank()) {
|
||||
YpEmptyState(
|
||||
title = "Anmeldung fehlgeschlagen",
|
||||
body = uiState.errorMessage,
|
||||
)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.isSubmitting) "Anmeldung..." else "Anmelden",
|
||||
onClick = onLogin,
|
||||
enabled = !uiState.isSubmitting,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
YpSecondaryButton(
|
||||
label = "Registrieren",
|
||||
onClick = onRegisterClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Passwort vergessen",
|
||||
onClick = onForgotPasswordClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
if (oauthProviders.isNotEmpty()) {
|
||||
YpInfoCard(
|
||||
title = "OAuth",
|
||||
body = "Konfigurierte Provider im Backend:",
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
oauthProviders.take(3).forEach { provider ->
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = { onOAuthLogin(provider) },
|
||||
label = { Text(provider.label) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
labelColor = YpColors.TextPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
YpInfoCard(
|
||||
title = "OAuth",
|
||||
body = "Im aktuellen Backend sind keine OAuth-Provider aktiv.",
|
||||
)
|
||||
}
|
||||
|
||||
YpInfoCard(
|
||||
title = "Auth-Header",
|
||||
body = "Nach erfolgreichem Login setzt der native Client automatisch `userid` und `authcode` fuer weitere API-Requests.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.core.auth.model.OAuthProviderDto
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class LoginUiState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val rememberMe: Boolean = true,
|
||||
val isSubmitting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
data class RegisterUiState(
|
||||
val email: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val language: String = "de",
|
||||
val isSubmitting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val successMessage: String? = null,
|
||||
)
|
||||
|
||||
data class PasswordResetUiState(
|
||||
val email: String = "",
|
||||
val isSubmitting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val successMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionStore: SessionStore,
|
||||
) : ViewModel() {
|
||||
private val _loginUiState = MutableStateFlow(LoginUiState())
|
||||
val loginUiState: StateFlow<LoginUiState> = _loginUiState.asStateFlow()
|
||||
|
||||
val session: StateFlow<AuthSession?> = sessionStore.session.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = sessionStore.currentSession,
|
||||
)
|
||||
|
||||
val sessionExpiredMessage: StateFlow<String?> = sessionStore.sessionExpiredMessage.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
private val _registerUiState = MutableStateFlow(RegisterUiState())
|
||||
val registerUiState: StateFlow<RegisterUiState> = _registerUiState.asStateFlow()
|
||||
|
||||
private val _passwordResetUiState = MutableStateFlow(PasswordResetUiState())
|
||||
val passwordResetUiState: StateFlow<PasswordResetUiState> = _passwordResetUiState.asStateFlow()
|
||||
|
||||
private val _oauthProviders = MutableStateFlow<List<OAuthProviderDto>>(emptyList())
|
||||
val oauthProviders: StateFlow<List<OAuthProviderDto>> = _oauthProviders.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
authRepository.loadOAuthProviders().onSuccess { providers ->
|
||||
_oauthProviders.value = providers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUsername(value: String) {
|
||||
_loginUiState.value = _loginUiState.value.copy(username = value, errorMessage = null)
|
||||
}
|
||||
|
||||
fun updatePassword(value: String) {
|
||||
_loginUiState.value = _loginUiState.value.copy(password = value, errorMessage = null)
|
||||
}
|
||||
|
||||
fun updateRememberMe(value: Boolean) {
|
||||
_loginUiState.value = _loginUiState.value.copy(rememberMe = value)
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val state = _loginUiState.value
|
||||
if (state.username.isBlank() || state.password.isBlank()) {
|
||||
_loginUiState.value = state.copy(errorMessage = "Bitte Benutzername und Passwort eingeben.")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_loginUiState.value = state.copy(isSubmitting = true, errorMessage = null)
|
||||
authRepository.login(
|
||||
username = state.username.trim(),
|
||||
password = state.password,
|
||||
rememberMe = state.rememberMe,
|
||||
).onSuccess {
|
||||
_loginUiState.value = LoginUiState(rememberMe = state.rememberMe)
|
||||
}.onFailure { error ->
|
||||
_loginUiState.value = state.copy(
|
||||
isSubmitting = false,
|
||||
errorMessage = error.message ?: "Die Anmeldung ist fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun oauthStartUrl(provider: OAuthProviderDto): String = authRepository.oauthStartUrl(provider)
|
||||
|
||||
fun handleOAuthCallback(code: String?, state: String?, issuer: String?, error: String?) {
|
||||
if (!error.isNullOrBlank()) {
|
||||
_loginUiState.value = _loginUiState.value.copy(errorMessage = "OAuth-Anmeldung wurde abgebrochen: $error")
|
||||
return
|
||||
}
|
||||
if (code.isNullOrBlank() || state.isNullOrBlank()) {
|
||||
_loginUiState.value = _loginUiState.value.copy(errorMessage = "OAuth-Callback enthält keinen gültigen Code oder Status.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_loginUiState.value = _loginUiState.value.copy(isSubmitting = true, errorMessage = null)
|
||||
authRepository.exchangeOAuth(code, state, issuer)
|
||||
.onSuccess { _loginUiState.value = LoginUiState(rememberMe = true) }
|
||||
.onFailure { callbackError ->
|
||||
_loginUiState.value = LoginUiState(
|
||||
rememberMe = true,
|
||||
errorMessage = callbackError.message ?: "OAuth-Anmeldung ist fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRegisterEmail(value: String) {
|
||||
_registerUiState.value = _registerUiState.value.copy(email = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun updateRegisterUsername(value: String) {
|
||||
_registerUiState.value = _registerUiState.value.copy(username = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun updateRegisterPassword(value: String) {
|
||||
_registerUiState.value = _registerUiState.value.copy(password = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun updateRegisterLanguage(value: String) {
|
||||
_registerUiState.value = _registerUiState.value.copy(language = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun register() {
|
||||
val state = _registerUiState.value
|
||||
if (state.email.isBlank() || state.username.isBlank() || state.password.isBlank()) {
|
||||
_registerUiState.value = state.copy(errorMessage = "Bitte E-Mail, Benutzername und Passwort eingeben.")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_registerUiState.value = state.copy(isSubmitting = true, errorMessage = null, successMessage = null)
|
||||
authRepository.register(
|
||||
email = state.email.trim(),
|
||||
username = state.username.trim(),
|
||||
password = state.password,
|
||||
language = state.language.trim().ifBlank { "de" },
|
||||
).onSuccess {
|
||||
_registerUiState.value = RegisterUiState(
|
||||
language = state.language.trim().ifBlank { "de" },
|
||||
successMessage = "Registrierung angelegt. Bitte prüfe Dein Postfach für die Aktivierung.",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_registerUiState.value = state.copy(
|
||||
isSubmitting = false,
|
||||
errorMessage = error.message ?: "Die Registrierung ist fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePasswordResetEmail(value: String) {
|
||||
_passwordResetUiState.value = _passwordResetUiState.value.copy(email = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun requestPasswordReset() {
|
||||
val state = _passwordResetUiState.value
|
||||
if (state.email.isBlank()) {
|
||||
_passwordResetUiState.value = state.copy(errorMessage = "Bitte eine E-Mail-Adresse eingeben.")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_passwordResetUiState.value = state.copy(isSubmitting = true, errorMessage = null, successMessage = null)
|
||||
authRepository.requestPasswordReset(state.email.trim()).onSuccess {
|
||||
_passwordResetUiState.value = PasswordResetUiState(
|
||||
successMessage = "E-Mail zum Zurücksetzen wurde angefordert.",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_passwordResetUiState.value = state.copy(
|
||||
isSubmitting = false,
|
||||
errorMessage = error.message ?: "Der Passwort-Reset ist fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout()
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeSessionExpiredMessage() {
|
||||
sessionStore.consumeSessionExpiredMessage()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun ForgotPasswordScreen(
|
||||
uiState: PasswordResetUiState,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onRequestReset: () -> Unit,
|
||||
onBackToLogin: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text("Passwort zurücksetzen", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Das Backend verschickt einen Reset-Link an die hinterlegte E-Mail-Adresse.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
YpTextField(value = uiState.email, onValueChange = onEmailChange, label = "E-Mail")
|
||||
|
||||
if (!uiState.errorMessage.isNullOrBlank()) {
|
||||
YpEmptyState(title = "Reset fehlgeschlagen", body = uiState.errorMessage)
|
||||
}
|
||||
if (!uiState.successMessage.isNullOrBlank()) {
|
||||
YpInfoCard(title = "Reset angefordert", body = uiState.successMessage)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.isSubmitting) "Sende..." else "Reset anfordern",
|
||||
onClick = onRequestReset,
|
||||
enabled = !uiState.isSubmitting,
|
||||
)
|
||||
|
||||
YpSecondaryButton(
|
||||
label = "Zurück zum Login",
|
||||
onClick = onBackToLogin,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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 de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
uiState: RegisterUiState,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLanguageChange: (String) -> Unit,
|
||||
onRegister: () -> Unit,
|
||||
onBackToLogin: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text("Registrierung", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Der Backend-Flow legt ein neues Konto an und verschickt eine Aktivierungs-E-Mail.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
YpTextField(value = uiState.email, onValueChange = onEmailChange, label = "E-Mail")
|
||||
YpTextField(value = uiState.username, onValueChange = onUsernameChange, label = "Benutzername")
|
||||
YpTextField(value = uiState.password, onValueChange = onPasswordChange, label = "Passwort")
|
||||
YpTextField(value = uiState.language, onValueChange = onLanguageChange, label = "Sprache")
|
||||
|
||||
if (!uiState.errorMessage.isNullOrBlank()) {
|
||||
YpEmptyState(title = "Registrierung fehlgeschlagen", body = uiState.errorMessage)
|
||||
}
|
||||
if (!uiState.successMessage.isNullOrBlank()) {
|
||||
YpInfoCard(title = "Registrierung", body = uiState.successMessage)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.isSubmitting) "Registriere..." else "Registrieren",
|
||||
onClick = onRegister,
|
||||
enabled = !uiState.isSubmitting,
|
||||
)
|
||||
|
||||
YpSecondaryButton(
|
||||
label = "Zurück zum Login",
|
||||
onClick = onBackToLogin,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.yourpart.nativeapp.feature.chat
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomTypeDto(
|
||||
val id: Long = 0,
|
||||
val tr: String? = null,
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
val roomTypeId: Long? = null,
|
||||
val isPublic: Boolean = false,
|
||||
val isAdultOnly: Boolean = false,
|
||||
val genderRestrictionId: Long? = null,
|
||||
val minAge: Int? = null,
|
||||
val maxAge: Int? = null,
|
||||
val friendsOfOwnerOnly: Boolean? = null,
|
||||
val requiredUserRightId: Long? = null,
|
||||
val roomType: ChatRoomTypeDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomCreateOptionsDto(
|
||||
val rights: List<ChatRoomRightDto> = emptyList(),
|
||||
val roomTypes: List<ChatRoomTypeOptionDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomRightDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomTypeOptionDto(
|
||||
val id: Long,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OneToOneMessageDto(
|
||||
val timestamp: Long = 0,
|
||||
val sender: String = "",
|
||||
val recipient: String = "",
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatRegisterRequestDto(
|
||||
val gender: String,
|
||||
val age: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatAgeRangeDto(
|
||||
val min: Int,
|
||||
val max: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatMatchRequestDto(
|
||||
val genders: List<String>,
|
||||
val age: RandomChatAgeRangeDto,
|
||||
val id: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatPartnerDto(
|
||||
val id: String = "",
|
||||
val gender: String? = null,
|
||||
val age: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatMatchResponseDto(
|
||||
val status: String = "",
|
||||
val user: RandomChatPartnerDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatSendMessageRequestDto(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatMessageDto(
|
||||
val from: String = "",
|
||||
val to: String = "",
|
||||
val text: String = "",
|
||||
val activity: String? = null,
|
||||
val timestamp: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatIncidentReportDto(
|
||||
val context: String,
|
||||
val reporterHashedId: String? = null,
|
||||
val reporterRandomId: String? = null,
|
||||
val reporterUsername: String? = null,
|
||||
val offenderHashedId: String? = null,
|
||||
val offenderRandomId: String? = null,
|
||||
val offenderUsername: String? = null,
|
||||
val incidentAt: String,
|
||||
val chatHistory: List<ChatHistoryEntryDto>,
|
||||
val metadata: Map<String, JsonElement>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatHistoryEntryDto(
|
||||
val type: String = "",
|
||||
val user: String = "",
|
||||
val text: String = "",
|
||||
val timestamp: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,279 @@
|
||||
package de.yourpart.nativeapp.feature.chat
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ChatRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val sessionStore: SessionStore,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
private val userId: String
|
||||
get() = sessionStore.currentSession?.user?.id.orEmpty()
|
||||
|
||||
suspend fun loadPublicRooms(): Result<List<ChatRoomDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/rooms")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<ChatRoomDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadOwnRooms(): Result<List<ChatRoomDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/my-rooms")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<ChatRoomDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteOwnRoom(roomId: Long): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/my-rooms/$roomId")
|
||||
.delete()
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Raum konnte nicht geloescht werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCreateOptions(): Result<ChatRoomCreateOptionsDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/room-create-options")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<ChatRoomCreateOptionsDto>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun initOneToOne(partnerHashId: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(mapOf("partnerHashId" to partnerHashId))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/initOneToOne")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("One-to-one Chat konnte nicht initialisiert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun registerRandomUser(gender: String, age: Int): Result<String> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(RandomChatRegisterRequestDto(gender = gender, age = age))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/register")
|
||||
.post(body)
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val payload = json.decodeFromString<Map<String, String>>(result.value)
|
||||
payload["id"].orEmpty().takeIf { it.isNotBlank() }
|
||||
?: throw IllegalStateException("Random-Chat-Registrierung lieferte keine ID.")
|
||||
}
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findRandomMatch(
|
||||
genders: List<String>,
|
||||
ageFrom: Int,
|
||||
ageTo: Int,
|
||||
id: String,
|
||||
): Result<RandomChatMatchResponseDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(
|
||||
RandomChatMatchRequestDto(
|
||||
genders = genders,
|
||||
age = RandomChatAgeRangeDto(min = ageFrom, max = ageTo),
|
||||
id = id,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/findMatch")
|
||||
.post(body)
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<RandomChatMatchResponseDto>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendRandomMessage(from: String, to: String, text: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(
|
||||
RandomChatSendMessageRequestDto(
|
||||
from = from,
|
||||
to = to,
|
||||
text = text,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/sendMessage")
|
||||
.post(body)
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val payload = json.decodeFromString<Map<String, String>>(result.value)
|
||||
payload["text"].orEmpty()
|
||||
}
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadRandomMessages(to: String, from: String): Result<List<RandomChatMessageDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/getMessages")
|
||||
.post(
|
||||
json.encodeToString(
|
||||
mapOf(
|
||||
"to" to to,
|
||||
"from" to from,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType()),
|
||||
)
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<RandomChatMessageDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun leaveRandomChat(id: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/leave")
|
||||
.post(json.encodeToString(mapOf("id" to id)).toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Random-Chat konnte nicht verlassen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exitRandomChat(id: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/exit")
|
||||
.post(json.encodeToString(mapOf("id" to id)).toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Random-Chat konnte nicht beendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadOneToOneHistory(partnerHashId: String): Result<List<OneToOneMessageDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/oneToOne/messageHistory?user1HashId=$userId&user2HashId=$partnerHashId")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val payload = json.decodeFromString<OneToOneHistoryResponse>(result.value)
|
||||
payload.history
|
||||
}
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendOneToOneMessage(partnerHashId: String, message: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(
|
||||
mapOf(
|
||||
"user1HashId" to userId,
|
||||
"user2HashId" to partnerHashId,
|
||||
"message" to message,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/oneToOne/sendMessage")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Nachricht konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportIncident(
|
||||
context: String,
|
||||
offenderUsername: String? = null,
|
||||
chatHistory: List<ChatHistoryEntryDto>,
|
||||
metadata: Map<String, String> = emptyMap(),
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val session = sessionStore.currentSession
|
||||
val body = json.encodeToString(
|
||||
ChatIncidentReportDto(
|
||||
context = context,
|
||||
reporterHashedId = session?.user?.id,
|
||||
reporterUsername = session?.user?.username,
|
||||
offenderUsername = offenderUsername,
|
||||
incidentAt = java.time.Instant.now().toString(),
|
||||
chatHistory = chatHistory,
|
||||
metadata = metadata.mapValues { kotlinx.serialization.json.JsonPrimitive(it.value) },
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/report")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Meldung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class OneToOneHistoryResponse(
|
||||
val history: List<OneToOneMessageDto> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,414 @@
|
||||
package de.yourpart.nativeapp.feature.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
uiState: ChatUiState,
|
||||
onSelectTab: (ChatTab) -> Unit,
|
||||
onUpdatePartnerHashId: (String) -> Unit,
|
||||
onUpdateDirectMessage: (String) -> Unit,
|
||||
onLoadDirectHistory: () -> Unit,
|
||||
onSendDirectMessage: () -> Unit,
|
||||
onReportDirectChat: () -> Unit,
|
||||
onDeleteRoom: (Long) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onUpdateRandomAge: (String) -> Unit,
|
||||
onUpdateRandomGender: (String) -> Unit,
|
||||
onUpdateRandomAgeFrom: (String) -> Unit,
|
||||
onUpdateRandomAgeTo: (String) -> Unit,
|
||||
onToggleRandomSearchGender: (String) -> Unit,
|
||||
onToggleRandomCamOnly: () -> Unit,
|
||||
onToggleRandomShowCam: () -> Unit,
|
||||
onToggleRandomAutosearch: () -> Unit,
|
||||
onUpdateRandomInput: (String) -> Unit,
|
||||
onStartRandomChat: () -> Unit,
|
||||
onStopRandomChat: () -> Unit,
|
||||
onNextRandomUser: () -> Unit,
|
||||
onSendRandomMessage: () -> Unit,
|
||||
onReportRandomChat: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Chat",
|
||||
body = "Nativer Chat mit Räumen, Direktnachrichten und Random-Chat. Die Web-UI wird dafür nicht mehr benötigt.",
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
ChatTab.entries.forEach { tab ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedTab == tab,
|
||||
onClick = { onSelectTab(tab) },
|
||||
label = { Text(tab.title) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
selectedContainerColor = YpColors.PrimarySoft,
|
||||
labelColor = YpColors.TextPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.errorMessage?.let { error ->
|
||||
item { YpEmptyState(title = "Chat-Fehler", body = error) }
|
||||
}
|
||||
|
||||
uiState.statusMessage?.let { message ->
|
||||
item { YpInfoCard(title = "Hinweis", body = message, accentColor = YpColors.SurfaceAccent) }
|
||||
}
|
||||
|
||||
when (uiState.selectedTab) {
|
||||
ChatTab.ROOMS -> {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Öffentliche Räume",
|
||||
body = "${uiState.publicRooms.size} Raum/Räume verfügbar",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
if (uiState.isLoading) {
|
||||
item { YpInfoCard(title = "Lade Räume", body = "Die Raumliste wird geladen.") }
|
||||
} else if (uiState.publicRooms.isEmpty()) {
|
||||
item { YpEmptyState(title = "Keine Räume", body = "Es sind aktuell keine öffentlichen Räume sichtbar.") }
|
||||
} else {
|
||||
items(uiState.publicRooms, key = { it.id }) { room ->
|
||||
RoomCard(room = room, showDelete = false, onDelete = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatTab.OWN -> {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Eigene Räume",
|
||||
body = "${uiState.ownRooms.size} eigener Raum/eigene Räume",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
if (uiState.isLoading) {
|
||||
item { YpInfoCard(title = "Lade Räume", body = "Deine Räume werden geladen.") }
|
||||
} else if (uiState.ownRooms.isEmpty()) {
|
||||
item { YpEmptyState(title = "Keine eigenen Räume", body = "Du hast noch keine Räume angelegt.") }
|
||||
} else {
|
||||
items(uiState.ownRooms, key = { it.id }) { room ->
|
||||
RoomCard(room = room, showDelete = true, onDelete = onDeleteRoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatTab.DIRECT -> {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.partnerHashId,
|
||||
onValueChange = onUpdatePartnerHashId,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Partner Hash-ID") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.directMessage,
|
||||
onValueChange = onUpdateDirectMessage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Nachricht") },
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Verlauf laden",
|
||||
onClick = onLoadDirectHistory,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
YpPrimaryButton(
|
||||
label = "Senden",
|
||||
onClick = onSendDirectMessage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.partnerHashId.isNotBlank() && uiState.directMessage.isNotBlank(),
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Melden",
|
||||
onClick = onReportDirectChat,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.directHistory.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Verlauf",
|
||||
body = "${uiState.directHistory.size} Nachricht(en)",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.isLoading) {
|
||||
item { YpInfoCard(title = "Lade Verlauf", body = "Der Chatverlauf wird geladen.") }
|
||||
} else if (uiState.directHistory.isEmpty()) {
|
||||
item { YpEmptyState(title = "Kein Verlauf", body = "Lade zuerst den Verlauf oder sende eine Nachricht.") }
|
||||
} else {
|
||||
items(uiState.directHistory) { message ->
|
||||
DirectMessageCard(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatTab.RANDOM -> {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard(
|
||||
title = "Random Chat",
|
||||
body = if (uiState.randomChatRunning) {
|
||||
if (uiState.randomSearching) "Suche nach einem Chatpartner." else "Verbunden mit einem Chatpartner."
|
||||
} else {
|
||||
"Startet einen zufälligen 1:1-Chat über das Backend."
|
||||
},
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.randomAge.toString(),
|
||||
onValueChange = onUpdateRandomAge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Alter") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.randomGender,
|
||||
onValueChange = onUpdateRandomGender,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Geschlecht") },
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.randomAgeFrom.toString(),
|
||||
onValueChange = onUpdateRandomAgeFrom,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Suche ab") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.randomAgeTo.toString(),
|
||||
onValueChange = onUpdateRandomAgeTo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Suche bis") },
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
FilterChip(
|
||||
selected = uiState.randomSearchMale,
|
||||
onClick = { onToggleRandomSearchGender("m") },
|
||||
label = { Text("Männer") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = uiState.randomSearchFemale,
|
||||
onClick = { onToggleRandomSearchGender("f") },
|
||||
label = { Text("Frauen") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = uiState.randomCamOnly,
|
||||
onClick = onToggleRandomCamOnly,
|
||||
label = { Text("Cam only") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = uiState.randomShowCam,
|
||||
onClick = onToggleRandomShowCam,
|
||||
label = { Text("Cam zeigen") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = uiState.randomAutosearch,
|
||||
onClick = onToggleRandomAutosearch,
|
||||
label = { Text("Auto") },
|
||||
)
|
||||
}
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.randomChatRunning) "Neu suchen" else "Random Chat starten",
|
||||
onClick = onStartRandomChat,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Beenden",
|
||||
onClick = onStopRandomChat,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.randomChatRunning || uiState.randomUserId.isNotBlank(),
|
||||
)
|
||||
if (uiState.randomChatRunning) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpSecondaryButton(
|
||||
label = "Nächster",
|
||||
onClick = onNextRandomUser,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.randomPartner != null,
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Melden",
|
||||
onClick = onReportRandomChat,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.randomLastPartner != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Random Verlauf",
|
||||
body = "${uiState.randomMessages.size} Nachricht(en)",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.randomChatRunning && uiState.randomPartner != null) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.randomInput,
|
||||
onValueChange = onUpdateRandomInput,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Nachricht") },
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
)
|
||||
YpPrimaryButton(
|
||||
label = "Senden",
|
||||
onClick = onSendRandomMessage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.randomInput.isNotBlank(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.randomMessages.isEmpty()) {
|
||||
item { YpEmptyState(title = "Kein Verlauf", body = "Starte die Suche oder warte auf neue Nachrichten.") }
|
||||
} else {
|
||||
items(uiState.randomMessages) { message ->
|
||||
RandomMessageCard(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpSecondaryButton(label = "Aktualisieren", onClick = onRefresh, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomCard(room: ChatRoomDto, showDelete: Boolean, onDelete: ((Long) -> Unit)?) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Text(room.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Typ: ${room.roomType?.name ?: room.roomType?.tr ?: room.roomTypeId?.toString() ?: "-"}",
|
||||
color = YpColors.TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
buildString {
|
||||
append(if (room.isPublic) "öffentlich" else "privat")
|
||||
if (room.isAdultOnly) append(" · 18+")
|
||||
room.minAge?.let { append(" · ab $it") }
|
||||
room.maxAge?.let { append(" · bis $it") }
|
||||
},
|
||||
color = YpColors.TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (showDelete && onDelete != null) {
|
||||
YpSecondaryButton(
|
||||
label = "Löschen",
|
||||
onClick = { onDelete(room.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectMessageCard(message: OneToOneMessageDto) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Text(message.sender, style = MaterialTheme.typography.titleSmall)
|
||||
Text(message.message, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(message.timestamp.toString(), style = MaterialTheme.typography.bodySmall, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RandomMessageCard(message: RandomChatMessageDto) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Text(
|
||||
message.from.ifBlank { "System" },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
message.activity ?: message.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 6,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
message.timestamp?.let {
|
||||
Text(it.toString(), style = MaterialTheme.typography.bodySmall, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package de.yourpart.nativeapp.feature.chat
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class ChatTab(val title: String) {
|
||||
ROOMS("Räume"),
|
||||
OWN("Eigene Räume"),
|
||||
DIRECT("1:1"),
|
||||
RANDOM("Random"),
|
||||
}
|
||||
|
||||
data class ChatUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val errorMessage: String? = null,
|
||||
val statusMessage: String? = null,
|
||||
val selectedTab: ChatTab = ChatTab.ROOMS,
|
||||
val publicRooms: List<ChatRoomDto> = emptyList(),
|
||||
val ownRooms: List<ChatRoomDto> = emptyList(),
|
||||
val createOptions: ChatRoomCreateOptionsDto = ChatRoomCreateOptionsDto(),
|
||||
val partnerHashId: String = "",
|
||||
val directHistory: List<OneToOneMessageDto> = emptyList(),
|
||||
val directMessage: String = "",
|
||||
val randomChatRunning: Boolean = false,
|
||||
val randomSearching: Boolean = false,
|
||||
val randomUserId: String = "",
|
||||
val randomAge: Int = 18,
|
||||
val randomGender: String = "f",
|
||||
val randomAgeFrom: Int = 18,
|
||||
val randomAgeTo: Int = 150,
|
||||
val randomSearchMale: Boolean = true,
|
||||
val randomSearchFemale: Boolean = true,
|
||||
val randomCamOnly: Boolean = false,
|
||||
val randomShowCam: Boolean = false,
|
||||
val randomAutosearch: Boolean = false,
|
||||
val randomInput: String = "",
|
||||
val randomMessages: List<RandomChatMessageDto> = emptyList(),
|
||||
val randomPartner: RandomChatPartnerDto? = null,
|
||||
val randomLastPartner: RandomChatPartnerDto? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
private val sessionStore: SessionStore,
|
||||
) : ViewModel() {
|
||||
private val _uiState = kotlinx.coroutines.flow.MutableStateFlow(ChatUiState())
|
||||
val uiState: kotlinx.coroutines.flow.StateFlow<ChatUiState> = _uiState
|
||||
|
||||
private var directPollingJob: Job? = null
|
||||
private var randomSearchJob: Job? = null
|
||||
private var randomPollingJob: Job? = null
|
||||
|
||||
init {
|
||||
applySessionDefaults()
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stopDirectPolling()
|
||||
stopRandomPolling()
|
||||
stopRandomSearch()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun applySessionDefaults() {
|
||||
val user = sessionStore.currentSession?.user ?: return
|
||||
val genderParam = user.param.firstOrNull { it.name == "gender" }?.value
|
||||
val birthdateParam = user.param.firstOrNull { it.name == "birthdate" }?.value
|
||||
val age = birthdateParam?.let { parseAge(it) } ?: 18
|
||||
val gender = when (genderParam) {
|
||||
"1", "m", "male", "männlich", "maennlich" -> "m"
|
||||
else -> "f"
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomAge = age.coerceAtLeast(18),
|
||||
randomGender = gender,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAge(birthdate: String): Int {
|
||||
val birth = runCatching { java.time.LocalDate.parse(birthdate) }.getOrNull() ?: return 18
|
||||
return java.time.Period.between(birth, java.time.LocalDate.now()).years.coerceAtLeast(18)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, statusMessage = null)
|
||||
val publicRooms = repository.loadPublicRooms()
|
||||
val ownRooms = repository.loadOwnRooms()
|
||||
val createOptions = repository.loadCreateOptions()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
publicRooms = publicRooms.getOrDefault(emptyList()),
|
||||
ownRooms = ownRooms.getOrDefault(emptyList()),
|
||||
createOptions = createOptions.getOrDefault(ChatRoomCreateOptionsDto()),
|
||||
errorMessage = publicRooms.exceptionOrNull()?.message
|
||||
?: ownRooms.exceptionOrNull()?.message
|
||||
?: createOptions.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTab(tab: ChatTab) {
|
||||
_uiState.value = _uiState.value.copy(selectedTab = tab)
|
||||
if (tab == ChatTab.DIRECT) {
|
||||
startDirectPolling()
|
||||
} else {
|
||||
stopDirectPolling()
|
||||
}
|
||||
if (tab != ChatTab.RANDOM) {
|
||||
stopRandomPolling()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePartnerHashId(value: String) {
|
||||
_uiState.value = _uiState.value.copy(partnerHashId = value.trim())
|
||||
stopDirectPolling()
|
||||
}
|
||||
|
||||
fun updateDirectMessage(value: String) {
|
||||
_uiState.value = _uiState.value.copy(directMessage = value)
|
||||
}
|
||||
|
||||
fun loadDirectHistory() {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
if (partner.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = "Partner-Hash fehlt.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.initOneToOne(partner)
|
||||
repository.loadOneToOneHistory(partner)
|
||||
.onSuccess { history ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
directHistory = history,
|
||||
statusMessage = "Verlauf geladen.",
|
||||
)
|
||||
startDirectPolling()
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Verlauf konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendDirectMessage() {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
val message = _uiState.value.directMessage.trim()
|
||||
if (partner.isBlank() || message.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = "Partner und Nachricht sind erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, statusMessage = null)
|
||||
repository.sendOneToOneMessage(partner, message)
|
||||
.onSuccess {
|
||||
_uiState.value = _uiState.value.copy(directMessage = "", isLoading = false, statusMessage = "Nachricht gesendet.")
|
||||
refreshDirectHistory()
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Nachricht konnte nicht gesendet werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshDirectHistory() {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
if (partner.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
repository.loadOneToOneHistory(partner)
|
||||
.onSuccess { history ->
|
||||
_uiState.value = _uiState.value.copy(directHistory = history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDirectPolling() {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
if (partner.isBlank()) return
|
||||
directPollingJob?.cancel()
|
||||
directPollingJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
delay(4000)
|
||||
val currentPartner = _uiState.value.partnerHashId.trim()
|
||||
if (currentPartner.isBlank()) continue
|
||||
repository.loadOneToOneHistory(currentPartner)
|
||||
.onSuccess { history ->
|
||||
_uiState.value = _uiState.value.copy(directHistory = history)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopDirectPolling() {
|
||||
directPollingJob?.cancel()
|
||||
directPollingJob = null
|
||||
}
|
||||
|
||||
fun updateRandomAge(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomAge = value.toIntOrNull()?.coerceAtLeast(18) ?: _uiState.value.randomAge)
|
||||
}
|
||||
|
||||
fun updateRandomGender(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomGender = value)
|
||||
}
|
||||
|
||||
fun updateRandomAgeFrom(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomAgeFrom = value.toIntOrNull()?.coerceAtLeast(18) ?: _uiState.value.randomAgeFrom)
|
||||
}
|
||||
|
||||
fun updateRandomAgeTo(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomAgeTo = value.toIntOrNull()?.coerceAtLeast(18) ?: _uiState.value.randomAgeTo)
|
||||
}
|
||||
|
||||
fun toggleRandomSearchGender(value: String) {
|
||||
val state = _uiState.value
|
||||
when (value) {
|
||||
"m" -> _uiState.value = state.copy(randomSearchMale = !state.randomSearchMale)
|
||||
"f" -> _uiState.value = state.copy(randomSearchFemale = !state.randomSearchFemale)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRandomCamOnly() {
|
||||
_uiState.value = _uiState.value.copy(randomCamOnly = !_uiState.value.randomCamOnly)
|
||||
}
|
||||
|
||||
fun toggleRandomShowCam() {
|
||||
_uiState.value = _uiState.value.copy(randomShowCam = !_uiState.value.randomShowCam)
|
||||
}
|
||||
|
||||
fun toggleRandomAutosearch() {
|
||||
_uiState.value = _uiState.value.copy(randomAutosearch = !_uiState.value.randomAutosearch)
|
||||
}
|
||||
|
||||
fun updateRandomInput(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomInput = value)
|
||||
}
|
||||
|
||||
fun startRandomChat() {
|
||||
viewModelScope.launch {
|
||||
if (_uiState.value.randomChatRunning || _uiState.value.randomUserId.isNotBlank()) {
|
||||
val previousId = _uiState.value.randomUserId.trim()
|
||||
stopRandomPolling()
|
||||
stopRandomSearch()
|
||||
if (previousId.isNotBlank()) {
|
||||
repository.leaveRandomChat(previousId)
|
||||
repository.exitRandomChat(previousId)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomChatRunning = false,
|
||||
randomSearching = false,
|
||||
randomUserId = "",
|
||||
randomPartner = null,
|
||||
randomMessages = emptyList(),
|
||||
randomInput = "",
|
||||
)
|
||||
}
|
||||
val state = _uiState.value
|
||||
val age = state.randomAge.coerceAtLeast(18)
|
||||
val gender = if (state.randomGender.isBlank()) "f" else state.randomGender
|
||||
repository.registerRandomUser(gender, age)
|
||||
.onSuccess { id ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomUserId = id,
|
||||
randomChatRunning = true,
|
||||
randomSearching = true,
|
||||
randomMessages = emptyList(),
|
||||
randomPartner = null,
|
||||
randomLastPartner = null,
|
||||
statusMessage = "Random-Chat gestartet.",
|
||||
errorMessage = null,
|
||||
)
|
||||
startRandomSearchLoop()
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Random-Chat konnte nicht gestartet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRandomChat() {
|
||||
viewModelScope.launch {
|
||||
val userId = _uiState.value.randomUserId.trim()
|
||||
if (userId.isNotBlank()) {
|
||||
repository.leaveRandomChat(userId)
|
||||
repository.exitRandomChat(userId)
|
||||
}
|
||||
stopRandomPolling()
|
||||
stopRandomSearch()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomChatRunning = false,
|
||||
randomSearching = false,
|
||||
randomUserId = "",
|
||||
randomPartner = null,
|
||||
randomMessages = emptyList(),
|
||||
randomInput = "",
|
||||
statusMessage = "Random-Chat beendet.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRandomSearchLoop() {
|
||||
stopRandomSearch()
|
||||
randomSearchJob = viewModelScope.launch {
|
||||
while (isActive && _uiState.value.randomSearching) {
|
||||
val state = _uiState.value
|
||||
val userId = state.randomUserId.trim()
|
||||
if (userId.isBlank()) {
|
||||
_uiState.value = state.copy(randomSearching = false, errorMessage = "Random-Chat-ID fehlt.")
|
||||
return@launch
|
||||
}
|
||||
val genders = buildList {
|
||||
if (state.randomSearchMale) add("m")
|
||||
if (state.randomSearchFemale) add("f")
|
||||
}.ifEmpty { listOf("m", "f") }
|
||||
repository.findRandomMatch(
|
||||
genders = genders,
|
||||
ageFrom = state.randomAgeFrom.coerceAtLeast(18),
|
||||
ageTo = state.randomAgeTo.coerceAtLeast(state.randomAgeFrom.coerceAtLeast(18)),
|
||||
id = userId,
|
||||
).onSuccess { result ->
|
||||
if (result.status == "matched" && result.user != null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomSearching = false,
|
||||
randomPartner = result.user,
|
||||
randomLastPartner = result.user,
|
||||
randomMessages = listOf(
|
||||
RandomChatMessageDto(
|
||||
from = "system",
|
||||
text = "Chatpartner gefunden: ${result.user.gender ?: "?"}, ${result.user.age ?: "?"}",
|
||||
),
|
||||
),
|
||||
statusMessage = "Chatpartner gefunden.",
|
||||
)
|
||||
refreshRandomMessages()
|
||||
startRandomPolling()
|
||||
return@launch
|
||||
}
|
||||
if (!_uiState.value.randomAutosearch) {
|
||||
_uiState.value = _uiState.value.copy(statusMessage = "Warte auf Chatpartner...")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomSearching = false,
|
||||
errorMessage = error.message ?: "Random-Chat-Suche fehlgeschlagen.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRandomSearch() {
|
||||
randomSearchJob?.cancel()
|
||||
randomSearchJob = null
|
||||
}
|
||||
|
||||
private fun startRandomPolling() {
|
||||
stopRandomPolling()
|
||||
randomPollingJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
delay(2500)
|
||||
refreshRandomMessages()
|
||||
if (_uiState.value.randomPartner == null) {
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRandomPolling() {
|
||||
randomPollingJob?.cancel()
|
||||
randomPollingJob = null
|
||||
}
|
||||
|
||||
private fun refreshRandomMessages() {
|
||||
val partner = _uiState.value.randomPartner ?: return
|
||||
val userId = _uiState.value.randomUserId.trim()
|
||||
if (userId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
repository.loadRandomMessages(userId, partner.id)
|
||||
.onSuccess { messages ->
|
||||
val activity = messages.firstOrNull { it.activity?.isNotBlank() == true }?.activity
|
||||
val cleanMessages = messages.filter { it.activity.isNullOrBlank() }
|
||||
val statusMessage = when (activity) {
|
||||
"otheruserleft" -> "Der andere Nutzer hat den Random-Chat verlassen."
|
||||
else -> null
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomMessages = cleanMessages,
|
||||
randomPartner = if (activity == "otheruserleft") null else partner,
|
||||
statusMessage = statusMessage ?: _uiState.value.statusMessage,
|
||||
)
|
||||
if (activity == "otheruserleft") {
|
||||
stopRandomPolling()
|
||||
if (_uiState.value.randomAutosearch) {
|
||||
_uiState.value = _uiState.value.copy(randomSearching = true)
|
||||
startRandomSearchLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendRandomMessage() {
|
||||
val state = _uiState.value
|
||||
val partner = state.randomPartner ?: return
|
||||
val message = state.randomInput.trim()
|
||||
val userId = state.randomUserId.trim()
|
||||
if (userId.isBlank() || message.isBlank()) {
|
||||
_uiState.value = state.copy(errorMessage = "Nachricht und Partner sind erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.sendRandomMessage(userId, partner.id, message)
|
||||
.onSuccess {
|
||||
_uiState.value = _uiState.value.copy(randomInput = "")
|
||||
refreshRandomMessages()
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Nachricht konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun nextRandomUser() {
|
||||
viewModelScope.launch {
|
||||
val userId = _uiState.value.randomUserId.trim()
|
||||
if (userId.isBlank()) return@launch
|
||||
repository.leaveRandomChat(userId)
|
||||
repository.exitRandomChat(userId)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomPartner = null,
|
||||
randomMessages = listOf(
|
||||
RandomChatMessageDto(
|
||||
from = "system",
|
||||
text = "Auf der Suche nach dem nächsten Partner.",
|
||||
),
|
||||
),
|
||||
randomSearching = true,
|
||||
)
|
||||
startRandomSearchLoop()
|
||||
}
|
||||
}
|
||||
|
||||
fun reportRandomChat() {
|
||||
val partner = _uiState.value.randomLastPartner ?: return
|
||||
val history = _uiState.value.randomMessages.map {
|
||||
ChatHistoryEntryDto(
|
||||
type = if (it.activity.isNullOrBlank()) "message" else "system",
|
||||
user = if (it.from == _uiState.value.randomUserId) "self" else it.from,
|
||||
text = it.activity ?: it.text,
|
||||
timestamp = it.timestamp,
|
||||
)
|
||||
}.filter { it.text.isNotBlank() }
|
||||
viewModelScope.launch {
|
||||
repository.reportIncident(
|
||||
context = "random_chat",
|
||||
offenderUsername = "Random-${partner.id.take(8)}",
|
||||
chatHistory = history,
|
||||
metadata = mapOf(
|
||||
"randomPartnerId" to partner.id,
|
||||
"randomPartnerGender" to (partner.gender ?: ""),
|
||||
"randomPartnerAge" to (partner.age?.toString() ?: ""),
|
||||
).filterValues { it.isNotBlank() },
|
||||
).onSuccess {
|
||||
_uiState.value = _uiState.value.copy(statusMessage = "Meldung wurde gesendet.")
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Meldung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRoom(roomId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteOwnRoom(roomId)
|
||||
.onSuccess { refresh() }
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Raum konnte nicht geloescht werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reportDirectChat(reason: String) {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
val history = _uiState.value.directHistory.map {
|
||||
ChatHistoryEntryDto(
|
||||
type = "message",
|
||||
user = it.sender,
|
||||
text = it.message,
|
||||
timestamp = it.timestamp,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.reportIncident(
|
||||
context = "one_to_one",
|
||||
offenderUsername = partner,
|
||||
chatHistory = history,
|
||||
metadata = mapOf("partnerHashId" to partner, "reason" to reason),
|
||||
).onSuccess {
|
||||
_uiState.value = _uiState.value.copy(statusMessage = "Meldung wurde gesendet.")
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Meldung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package de.yourpart.nativeapp.feature.falukant
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
data class FalukantStatusDto(
|
||||
val characterName: String = "",
|
||||
val age: Int? = null,
|
||||
val health: Double? = null,
|
||||
val money: Double? = null,
|
||||
val relationship: String? = null,
|
||||
val childrenCount: Int = 0,
|
||||
val unreadNotifications: Int = 0,
|
||||
val debtorsPrisonActive: Boolean = false,
|
||||
val inDebtorsPrison: Boolean = false,
|
||||
)
|
||||
|
||||
data class FalukantBranchDto(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val cityName: String = "",
|
||||
val typeName: String = "",
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class FalukantNotificationDto(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val createdAt: String = "",
|
||||
val shown: Boolean = false,
|
||||
)
|
||||
|
||||
data class FalukantFamilyDto(
|
||||
val partnerName: String = "",
|
||||
val relationship: String = "",
|
||||
val children: List<String> = emptyList(),
|
||||
val lovers: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class FalukantBankDto(
|
||||
val money: Double? = null,
|
||||
val totalDebt: Double? = null,
|
||||
val maxCredit: Double? = null,
|
||||
val availableCredit: Double? = null,
|
||||
val activeCredits: Int = 0,
|
||||
val debtorsPrisonActive: Boolean = false,
|
||||
val inDebtorsPrison: Boolean = false,
|
||||
)
|
||||
|
||||
internal fun JsonObject.toFalukantStatus(): FalukantStatusDto {
|
||||
val character = objectValue("character")
|
||||
val relationship = character["relationshipsAsCharacter1"].firstObjectOrNull()?.stringValue("relationshipType")
|
||||
?: character["relationshipsAsCharacter2"].firstObjectOrNull()?.stringValue("relationshipType")
|
||||
val prison = objectValue("debtorsPrison")
|
||||
val firstName = character.stringValue("displayName")
|
||||
?: character.stringValue("name")
|
||||
?: listOfNotNull(
|
||||
character.objectValue("definedFirstName")?.stringValue("name") ?: character.stringValue("firstName"),
|
||||
character.objectValue("definedLastName")?.stringValue("name") ?: character.stringValue("lastName"),
|
||||
).joinToString(" ")
|
||||
return FalukantStatusDto(
|
||||
characterName = firstName.orEmpty(),
|
||||
age = character.intValue("age"),
|
||||
health = character.doubleValue("health"),
|
||||
money = doubleValue("money"),
|
||||
relationship = relationship,
|
||||
childrenCount = intValue("childrenCount") ?: 0,
|
||||
unreadNotifications = intValue("unreadNotifications") ?: 0,
|
||||
debtorsPrisonActive = prison.booleanValue("active") ?: false,
|
||||
inDebtorsPrison = prison.booleanValue("inDebtorsPrison") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun JsonObject.toFalukantBranch(): FalukantBranchDto? {
|
||||
val id = longValue("id") ?: return null
|
||||
val city = objectValue("city")
|
||||
val type = objectValue("branchType") ?: objectValue("type")
|
||||
return FalukantBranchDto(
|
||||
id = id,
|
||||
name = stringValue("name") ?: stringValue("title") ?: "Filiale $id",
|
||||
cityName = city.stringValue("name") ?: stringValue("cityName").orEmpty(),
|
||||
typeName = type.stringValue("name") ?: type.stringValue("tr") ?: stringValue("branchTypeName").orEmpty(),
|
||||
raw = this,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun JsonObject.toFalukantBank(): FalukantBankDto {
|
||||
val prison = objectValue("debtorsPrison")
|
||||
val credits = this["activeCredits"]
|
||||
return FalukantBankDto(
|
||||
money = doubleValue("money"),
|
||||
totalDebt = doubleValue("totalDebt"),
|
||||
maxCredit = doubleValue("maxCredit"),
|
||||
availableCredit = doubleValue("availableCredit"),
|
||||
activeCredits = (credits as? kotlinx.serialization.json.JsonArray)?.size ?: intValue("activeCredits") ?: 0,
|
||||
debtorsPrisonActive = prison.booleanValue("active") ?: false,
|
||||
inDebtorsPrison = prison.booleanValue("inDebtorsPrison") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun JsonObject.toFalukantFamily(): FalukantFamilyDto {
|
||||
val relationships = objectValue("relationships")
|
||||
val partner = relationships["relationships"]?.firstObjectOrNull()
|
||||
?: this["relationships"]?.firstObjectOrNull()
|
||||
val partnerCharacter = partner?.objectValue("character2")
|
||||
val children = (this["children"] as? kotlinx.serialization.json.JsonArray).orEmpty().mapNotNull { element ->
|
||||
val child = element as? JsonObject ?: return@mapNotNull null
|
||||
child.stringValue("name") ?: listOfNotNull(child.stringValue("firstName"), child.stringValue("lastName")).joinToString(" ").ifBlank { null }
|
||||
}
|
||||
val lovers = (this["lovers"] as? kotlinx.serialization.json.JsonArray).orEmpty().mapNotNull { element ->
|
||||
val lover = element as? JsonObject ?: return@mapNotNull null
|
||||
lover.stringValue("name") ?: lover.objectValue("character2")?.stringValue("firstName")
|
||||
}
|
||||
return FalukantFamilyDto(
|
||||
partnerName = partnerCharacter?.stringValue("name")
|
||||
?: partnerCharacter?.stringValue("firstName")
|
||||
?: "",
|
||||
relationship = partner?.stringValue("relationshipType").orEmpty(),
|
||||
children = children,
|
||||
lovers = lovers,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun JsonObject.toFalukantNotification(): FalukantNotificationDto? {
|
||||
val id = longValue("id") ?: return null
|
||||
return FalukantNotificationDto(
|
||||
id = id,
|
||||
title = stringValue("title") ?: stringValue("tr") ?: "Ereignis",
|
||||
description = stringValue("description") ?: stringValue("message") ?: "",
|
||||
createdAt = stringValue("createdAt").orEmpty(),
|
||||
shown = booleanValue("shown") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.objectValue(key: String): JsonObject = this[key] as? JsonObject ?: JsonObject(emptyMap())
|
||||
private fun JsonObject.stringValue(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
|
||||
private fun JsonObject.longValue(key: String): Long? = stringValue(key)?.toLongOrNull()
|
||||
private fun JsonObject.intValue(key: String): Int? = stringValue(key)?.toIntOrNull()
|
||||
private fun JsonObject.doubleValue(key: String): Double? = stringValue(key)?.toDoubleOrNull()
|
||||
private fun JsonObject.booleanValue(key: String): Boolean? = stringValue(key)?.toBooleanStrictOrNull()
|
||||
private fun JsonElement?.firstObjectOrNull(): JsonObject? = (this as? kotlinx.serialization.json.JsonArray)?.firstOrNull() as? JsonObject
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.yourpart.nativeapp.feature.falukant
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
@Singleton
|
||||
class FalukantRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
data class FullAction(val id: String, val label: String, val method: String, val path: String, val examplePayload: String)
|
||||
|
||||
val fullActions = listOf(
|
||||
FullAction("family-heir", "Erben setzen", "POST", "/api/falukant/family/set-heir", "{\"childCharacterId\": 1}"),
|
||||
FullAction("family-gift", "Familiengeschenk", "POST", "/api/falukant/family/gift", "{\"childId\": 1, \"giftId\": 1}"),
|
||||
FullAction("health", "Gesundheitsaktion", "POST", "/api/falukant/health", "{\"measureTr\": \"walk\"}"),
|
||||
FullAction("reputation", "Reputationsaktion", "POST", "/api/falukant/reputation/actions", "{\"actionTypeId\": 1}"),
|
||||
FullAction("church-baptise", "Taufe", "POST", "/api/falukant/church/baptise", "{\"childId\": 1, \"firstName\": \"Name\"}"),
|
||||
FullAction("church-apply", "Kirchenamt bewerben", "POST", "/api/falukant/church/positions/apply", "{\"positionId\": 1}"),
|
||||
FullAction("nobility", "Adelsaufstieg", "POST", "/api/falukant/nobility", "{}"),
|
||||
FullAction("house-buy", "Haus kaufen", "POST", "/api/falukant/houses", "{\"houseId\": 1}"),
|
||||
FullAction("house-renovate", "Haus renovieren", "POST", "/api/falukant/houses/renovate", "{\"element\": \"roof\"}"),
|
||||
FullAction("education", "Bildung starten", "POST", "/api/falukant/education", "{\"item\": \"all\", \"student\": \"child\", \"studentId\": 1}"),
|
||||
FullAction("politics-tax", "Regionalsteuer setzen", "PUT", "/api/falukant/politics/region/{regionId}/tax", "{\"percent\": 5}"),
|
||||
FullAction("politics-appointment", "Politisches Amt ernennen", "POST", "/api/falukant/politics/appointments", "{\"targetCharacterId\": 1, \"officeTypeId\": 1, \"regionId\": 1}"),
|
||||
FullAction("politics-vote", "Wahl abstimmen", "POST", "/api/falukant/politics/elections", "{\"votes\": []}"),
|
||||
FullAction("underground", "Untergrundaktion", "POST", "/api/falukant/underground/activities", "{\"typeId\": \"sabotage\", \"target\": 1}"),
|
||||
)
|
||||
suspend fun loadStatus(): Result<FalukantStatusDto> = loadObject("/api/falukant/info").map { it.toFalukantStatus() }
|
||||
suspend fun loadUser(): Result<JsonObject> = loadObject("/api/falukant/user")
|
||||
suspend fun loadBranches(): Result<List<FalukantBranchDto>> = loadElement("/api/falukant/branches").map { element ->
|
||||
element.jsonArray.mapNotNull { (it as? JsonObject)?.toFalukantBranch() }
|
||||
}
|
||||
suspend fun loadBranch(id: Long): Result<JsonObject> = loadObject("/api/falukant/branches/$id")
|
||||
suspend fun loadProduction(id: Long): Result<JsonElement> = loadElement("/api/falukant/production/$id")
|
||||
suspend fun loadStorage(id: Long): Result<JsonObject> = loadObject("/api/falukant/storage/$id")
|
||||
suspend fun loadInventory(id: Long): Result<JsonElement> = loadElement("/api/falukant/inventory/$id")
|
||||
suspend fun loadDirector(id: Long): Result<JsonObject> = loadObject("/api/falukant/director/$id")
|
||||
suspend fun loadTransports(id: Long): Result<JsonElement> = loadElement("/api/falukant/transports/branch/$id")
|
||||
suspend fun loadVehicles(): Result<JsonElement> = loadElement("/api/falukant/vehicles")
|
||||
suspend fun loadProducts(): Result<JsonElement> = loadElement("/api/falukant/products")
|
||||
suspend fun loadStockTypes(): Result<JsonElement> = loadElement("/api/falukant/stocktypes")
|
||||
suspend fun loadBranchTaxes(id: Long): Result<JsonObject> = loadObject("/api/falukant/branches/$id/taxes")
|
||||
suspend fun loadDirectorProposals(id: Long): Result<JsonElement> = postElement("/api/falukant/director/proposal", buildJsonObject { put("branchId", id) })
|
||||
suspend fun loadBank(): Result<FalukantBankDto> = loadObject("/api/falukant/bank/overview").map { it.toFalukantBank() }
|
||||
suspend fun loadBankCredits(): Result<JsonElement> = loadElement("/api/falukant/bank/credits")
|
||||
suspend fun loadFullData(): Result<Map<String, JsonElement>> = withContext(Dispatchers.IO) {
|
||||
val paths = mapOf(
|
||||
"Gesundheit" to "/api/falukant/health", "Reputation" to "/api/falukant/reputation/actions",
|
||||
"Kirche" to "/api/falukant/church/overview", "Adel" to "/api/falukant/nobility",
|
||||
"Haus" to "/api/falukant/houses", "Bildung" to "/api/falukant/education",
|
||||
"Politik" to "/api/falukant/politics/overview", "Untergrund" to "/api/falukant/underground/activities",
|
||||
)
|
||||
runCatching { paths.mapValues { (_, path) -> loadElement(path).getOrElse { throw it } } }
|
||||
}
|
||||
|
||||
suspend fun executeFullAction(action: FullAction, payload: JsonObject, regionId: String): Result<Unit> {
|
||||
val path = action.path.replace("{regionId}", regionId)
|
||||
return when (action.method) {
|
||||
"PUT" -> putUnit(path, payload)
|
||||
else -> postUnit(path, payload)
|
||||
}
|
||||
}
|
||||
suspend fun loadFamily(): Result<FalukantFamilyDto> = loadObject("/api/falukant/family").map { it.toFalukantFamily() }
|
||||
suspend fun loadNotifications(): Result<List<FalukantNotificationDto>> = loadElement("/api/falukant/notifications/all?page=1&size=20").map { element ->
|
||||
val items = (element as? JsonObject)?.get("items")?.jsonArray ?: element.jsonArray
|
||||
items.mapNotNull { (it as? JsonObject)?.toFalukantNotification() }
|
||||
}
|
||||
|
||||
suspend fun startProduction(branchId: Long, productId: Long, quantity: Int): Result<Unit> = postUnit(
|
||||
"/api/falukant/production",
|
||||
buildJsonObject {
|
||||
put("branchId", branchId)
|
||||
put("productId", productId)
|
||||
put("quantity", quantity)
|
||||
},
|
||||
)
|
||||
suspend fun upgradeBranch(branchId: Long): Result<Unit> = postUnit("/api/falukant/branches/upgrade", buildJsonObject { put("branchId", branchId) })
|
||||
suspend fun hireDirector(proposalId: Long): Result<Unit> = postUnit("/api/falukant/director/convertproposal", buildJsonObject { put("proposalId", proposalId) })
|
||||
|
||||
suspend fun buyStorage(branchId: Long, stockTypeId: Long, amount: Int): Result<Unit> = postUnit(
|
||||
"/api/falukant/storage",
|
||||
buildJsonObject {
|
||||
put("branchId", branchId)
|
||||
put("stockTypeId", stockTypeId)
|
||||
put("amount", amount)
|
||||
},
|
||||
)
|
||||
suspend fun sellStorage(branchId: Long, stockTypeId: Long, amount: Int): Result<Unit> = deleteUnit(
|
||||
"/api/falukant/storage",
|
||||
buildJsonObject { put("branchId", branchId); put("stockTypeId", stockTypeId); put("amount", amount) },
|
||||
)
|
||||
|
||||
suspend fun sellAll(branchId: Long): Result<Unit> = postUnit(
|
||||
"/api/falukant/sell/all",
|
||||
buildJsonObject { put("branchId", branchId) },
|
||||
)
|
||||
|
||||
suspend fun sellProduct(branchId: Long, productId: Long, quality: Int, quantity: Int): Result<Unit> = postUnit(
|
||||
"/api/falukant/sell",
|
||||
buildJsonObject {
|
||||
put("branchId", branchId)
|
||||
put("productId", productId)
|
||||
put("quality", quality)
|
||||
put("quantity", quantity)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun createTransport(
|
||||
branchId: Long,
|
||||
vehicleTypeId: Long,
|
||||
productId: Long,
|
||||
quantity: Int,
|
||||
targetBranchId: Long,
|
||||
guardCount: Int,
|
||||
): Result<Unit> = postUnit(
|
||||
"/api/falukant/transports",
|
||||
buildJsonObject {
|
||||
put("branchId", branchId)
|
||||
put("vehicleTypeId", vehicleTypeId)
|
||||
put("productId", productId)
|
||||
put("quantity", quantity)
|
||||
put("targetBranchId", targetBranchId)
|
||||
put("guardCount", guardCount)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun takeOrPayCredit(height: Double): Result<Unit> = postUnit(
|
||||
"/api/falukant/bank/credits",
|
||||
buildJsonObject { put("height", height) },
|
||||
)
|
||||
|
||||
suspend fun repairAllVehicles(branchId: Long): Result<Unit> = postUnit(
|
||||
"/api/falukant/vehicles/repair-all",
|
||||
buildJsonObject { put("branchId", branchId) },
|
||||
)
|
||||
|
||||
suspend fun updateDirectorIncome(directorId: Long, income: Double): Result<Unit> = postUnit(
|
||||
"/api/falukant/directors",
|
||||
buildJsonObject {
|
||||
put("directorId", directorId)
|
||||
put("income", income)
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun loadObject(path: String): Result<JsonObject> = loadElement(path).map { it.jsonObject }
|
||||
|
||||
private suspend fun loadElement(path: String): Result<JsonElement> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path").get().build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.parseToJsonElement(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postUnit(path: String, payload: JsonObject): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}$path")
|
||||
.post(payload.toString().toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Aktion konnte nicht ausgeführt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postElement(path: String, payload: JsonObject): Result<JsonElement> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path")
|
||||
.post(payload.toString().toRequestBody("application/json".toMediaType())).build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.parseToJsonElement(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteUnit(path: String, payload: JsonObject): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path")
|
||||
.delete(payload.toString().toRequestBody("application/json".toMediaType())).build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Aktion konnte nicht ausgeführt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun putUnit(path: String, payload: JsonObject): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path")
|
||||
.put(payload.toString().toRequestBody("application/json".toMediaType())).build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Aktion konnte nicht ausgeführt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package de.yourpart.nativeapp.feature.falukant
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
@Composable
|
||||
fun FalukantScreen(
|
||||
uiState: FalukantUiState,
|
||||
onSelectSection: (FalukantSection) -> Unit,
|
||||
onSelectBranch: (Long) -> Unit,
|
||||
onUpdateProductionProductId: (String) -> Unit,
|
||||
onUpdateProductionQuantity: (String) -> Unit,
|
||||
onUpdateStorageTypeId: (String) -> Unit,
|
||||
onUpdateStorageAmount: (String) -> Unit,
|
||||
onUpdateDirectorIncome: (String) -> Unit,
|
||||
onStartProduction: () -> Unit,
|
||||
onBuyStorage: () -> Unit,
|
||||
onSellStorage: () -> Unit,
|
||||
onSellAll: () -> Unit,
|
||||
onRepairAllVehicles: () -> Unit,
|
||||
onSaveDirectorIncome: () -> Unit,
|
||||
onUpgradeBranch: () -> Unit,
|
||||
onUpdateDirectorProposalId: (String) -> Unit,
|
||||
onHireDirector: () -> Unit,
|
||||
onUpdateSellProductId: (String) -> Unit,
|
||||
onUpdateSellQuality: (String) -> Unit,
|
||||
onUpdateSellQuantity: (String) -> Unit,
|
||||
onSellProduct: () -> Unit,
|
||||
onUpdateTransportVehicleTypeId: (String) -> Unit,
|
||||
onUpdateTransportTargetBranchId: (String) -> Unit,
|
||||
onUpdateTransportProductId: (String) -> Unit,
|
||||
onUpdateTransportQuantity: (String) -> Unit,
|
||||
onUpdateTransportGuardCount: (String) -> Unit,
|
||||
onCreateTransport: () -> Unit,
|
||||
onUpdateCreditHeight: (String) -> Unit,
|
||||
onTakeOrPayCredit: () -> Unit,
|
||||
fullActions: List<FalukantRepository.FullAction>,
|
||||
onRefreshFullData: () -> Unit,
|
||||
onUpdateFullActionId: (String) -> Unit,
|
||||
onUpdateFullActionPayload: (String) -> Unit,
|
||||
onUpdateFullActionRegionId: (String) -> Unit,
|
||||
onExecuteFullAction: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Falukant",
|
||||
body = uiState.status?.characterName?.takeIf { it.isNotBlank() }
|
||||
?.let { "Spielstand von $it" }
|
||||
?: "Falukant-Spielstand und wichtige Entscheidungen.",
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
FalukantSection.entries.take(3).forEach { section ->
|
||||
SectionChip(section, uiState.section, onSelectSection)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
FalukantSection.entries.drop(3).forEach { section ->
|
||||
SectionChip(section, uiState.section, onSelectSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { YpSecondaryButton("Aktualisieren", onRefresh, Modifier.fillMaxWidth()) }
|
||||
uiState.errorMessage?.let { error -> item { YpEmptyState("Falukant-Fehler", error) } }
|
||||
|
||||
when (uiState.section) {
|
||||
FalukantSection.Overview -> overviewContent(uiState)
|
||||
FalukantSection.Branches -> branchContent(
|
||||
state = uiState,
|
||||
onSelectBranch = onSelectBranch,
|
||||
onUpdateProductionProductId = onUpdateProductionProductId,
|
||||
onUpdateProductionQuantity = onUpdateProductionQuantity,
|
||||
onUpdateStorageTypeId = onUpdateStorageTypeId,
|
||||
onUpdateStorageAmount = onUpdateStorageAmount,
|
||||
onUpdateDirectorIncome = onUpdateDirectorIncome,
|
||||
onStartProduction = onStartProduction,
|
||||
onBuyStorage = onBuyStorage,
|
||||
onSellStorage = onSellStorage,
|
||||
onSellAll = onSellAll,
|
||||
onRepairAllVehicles = onRepairAllVehicles,
|
||||
onSaveDirectorIncome = onSaveDirectorIncome,
|
||||
onUpgradeBranch = onUpgradeBranch,
|
||||
onUpdateDirectorProposalId = onUpdateDirectorProposalId,
|
||||
onHireDirector = onHireDirector,
|
||||
onUpdateSellProductId = onUpdateSellProductId,
|
||||
onUpdateSellQuality = onUpdateSellQuality,
|
||||
onUpdateSellQuantity = onUpdateSellQuantity,
|
||||
onSellProduct = onSellProduct,
|
||||
onUpdateTransportVehicleTypeId = onUpdateTransportVehicleTypeId,
|
||||
onUpdateTransportTargetBranchId = onUpdateTransportTargetBranchId,
|
||||
onUpdateTransportProductId = onUpdateTransportProductId,
|
||||
onUpdateTransportQuantity = onUpdateTransportQuantity,
|
||||
onUpdateTransportGuardCount = onUpdateTransportGuardCount,
|
||||
onCreateTransport = onCreateTransport,
|
||||
)
|
||||
FalukantSection.Bank -> bankContent(uiState, onUpdateCreditHeight, onTakeOrPayCredit)
|
||||
FalukantSection.Family -> familyContent(uiState)
|
||||
FalukantSection.Notifications -> notificationContent(uiState)
|
||||
FalukantSection.Full -> fullContent(uiState, fullActions, onRefreshFullData, onUpdateFullActionId, onUpdateFullActionPayload, onUpdateFullActionRegionId, onExecuteFullAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.fullContent(
|
||||
state: FalukantUiState,
|
||||
actions: List<FalukantRepository.FullAction>,
|
||||
onRefresh: () -> Unit,
|
||||
onAction: (String) -> Unit,
|
||||
onPayload: (String) -> Unit,
|
||||
onRegion: (String) -> Unit,
|
||||
onExecute: () -> Unit,
|
||||
) {
|
||||
item { YpSecondaryButton("Vollausbau-Daten laden", onRefresh, Modifier.fillMaxWidth()) }
|
||||
state.fullData.forEach { (title, data) -> item { YpInfoCard(title, data.toString().take(900), accentColor = YpColors.SurfaceAccent) } }
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(LocalYpSpacing.current.sm)) {
|
||||
actions.forEach { action ->
|
||||
FilterChip(selected = state.selectedFullActionId == action.id, onClick = { onAction(action.id) }, label = { Text(action.label) })
|
||||
}
|
||||
OutlinedTextField(state.fullActionRegionId, onRegion, Modifier.fillMaxWidth(), label = { Text("Region-ID, falls erforderlich") })
|
||||
OutlinedTextField(state.fullActionPayload, onPayload, Modifier.fillMaxWidth(), label = { Text("Aktionsdaten (JSON)") }, minLines = 4)
|
||||
YpPrimaryButton("Aktion ausführen", onExecute, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionChip(section: FalukantSection, selected: FalukantSection, onSelect: (FalukantSection) -> Unit) {
|
||||
FilterChip(
|
||||
selected = section == selected,
|
||||
onClick = { onSelect(section) },
|
||||
label = { Text(section.label) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
selectedContainerColor = YpColors.PrimarySoft,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.overviewContent(state: FalukantUiState) {
|
||||
val status = state.status
|
||||
item {
|
||||
InfoGrid(
|
||||
title = "Status",
|
||||
rows = listOfNotNull(
|
||||
status?.age?.let { "Alter" to it.toString() },
|
||||
status?.health?.let { "Gesundheit" to it.toString() },
|
||||
status?.money?.let { "Vermögen" to it.toString() },
|
||||
status?.relationship?.let { "Beziehung" to it },
|
||||
status?.childrenCount?.let { "Kinder" to it.toString() },
|
||||
status?.unreadNotifications?.let { "Ungelesen" to it.toString() },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (status?.debtorsPrisonActive == true) {
|
||||
item {
|
||||
YpEmptyState(
|
||||
title = if (status.inDebtorsPrison) "Schuldgefängnis" else "Schuldenwarnung",
|
||||
body = "Bank- und Spielaktionen können eingeschränkt sein.",
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
InfoGrid(
|
||||
title = "Spielstand",
|
||||
rows = listOf(
|
||||
"Filialen" to state.branches.size.toString(),
|
||||
"Benachrichtigungen" to state.notifications.size.toString(),
|
||||
"Zertifikat" to state.user?.primitiveValue("certificate").orEmpty(),
|
||||
).filter { it.second.isNotBlank() },
|
||||
)
|
||||
}
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "MVP-Grenze",
|
||||
body = "3D-Charaktere, Karten, Produktion, Lager, Verkauf und Director bleiben Teil des Falukant-Vollausbaus.",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.branchContent(
|
||||
state: FalukantUiState,
|
||||
onSelectBranch: (Long) -> Unit,
|
||||
onUpdateProductionProductId: (String) -> Unit,
|
||||
onUpdateProductionQuantity: (String) -> Unit,
|
||||
onUpdateStorageTypeId: (String) -> Unit,
|
||||
onUpdateStorageAmount: (String) -> Unit,
|
||||
onUpdateDirectorIncome: (String) -> Unit,
|
||||
onStartProduction: () -> Unit,
|
||||
onBuyStorage: () -> Unit,
|
||||
onSellStorage: () -> Unit,
|
||||
onSellAll: () -> Unit,
|
||||
onRepairAllVehicles: () -> Unit,
|
||||
onSaveDirectorIncome: () -> Unit,
|
||||
onUpgradeBranch: () -> Unit,
|
||||
onUpdateDirectorProposalId: (String) -> Unit,
|
||||
onHireDirector: () -> Unit,
|
||||
onUpdateSellProductId: (String) -> Unit,
|
||||
onUpdateSellQuality: (String) -> Unit,
|
||||
onUpdateSellQuantity: (String) -> Unit,
|
||||
onSellProduct: () -> Unit,
|
||||
onUpdateTransportVehicleTypeId: (String) -> Unit,
|
||||
onUpdateTransportTargetBranchId: (String) -> Unit,
|
||||
onUpdateTransportProductId: (String) -> Unit,
|
||||
onUpdateTransportQuantity: (String) -> Unit,
|
||||
onUpdateTransportGuardCount: (String) -> Unit,
|
||||
onCreateTransport: () -> Unit,
|
||||
) {
|
||||
if (!state.isLoading && state.branches.isEmpty()) {
|
||||
item { YpEmptyState("Keine Filialen", "Die Erstellung eines Falukant-Spielstands und neuer Filialen folgt erst nach dem MVP.") }
|
||||
}
|
||||
items(state.branches, key = { it.id }) { branch ->
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
onClick = { onSelectBranch(branch.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(branch.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(listOf(branch.typeName, branch.cityName).filter { it.isNotBlank() }.joinToString(" · ").ifBlank { "Details anzeigen" }, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
state.selectedBranch?.let { branch ->
|
||||
item { InfoGrid("Filialdetails", branch.scalarEntries()) }
|
||||
item {
|
||||
BranchOperations(
|
||||
state = state,
|
||||
onUpdateProductionProductId = onUpdateProductionProductId,
|
||||
onUpdateProductionQuantity = onUpdateProductionQuantity,
|
||||
onUpdateStorageTypeId = onUpdateStorageTypeId,
|
||||
onUpdateStorageAmount = onUpdateStorageAmount,
|
||||
onUpdateDirectorIncome = onUpdateDirectorIncome,
|
||||
onStartProduction = onStartProduction,
|
||||
onBuyStorage = onBuyStorage,
|
||||
onSellStorage = onSellStorage,
|
||||
onSellAll = onSellAll,
|
||||
onRepairAllVehicles = onRepairAllVehicles,
|
||||
onSaveDirectorIncome = onSaveDirectorIncome,
|
||||
onUpgradeBranch = onUpgradeBranch,
|
||||
onUpdateDirectorProposalId = onUpdateDirectorProposalId,
|
||||
onHireDirector = onHireDirector,
|
||||
onUpdateSellProductId = onUpdateSellProductId,
|
||||
onUpdateSellQuality = onUpdateSellQuality,
|
||||
onUpdateSellQuantity = onUpdateSellQuantity,
|
||||
onSellProduct = onSellProduct,
|
||||
onUpdateTransportVehicleTypeId = onUpdateTransportVehicleTypeId,
|
||||
onUpdateTransportTargetBranchId = onUpdateTransportTargetBranchId,
|
||||
onUpdateTransportProductId = onUpdateTransportProductId,
|
||||
onUpdateTransportQuantity = onUpdateTransportQuantity,
|
||||
onUpdateTransportGuardCount = onUpdateTransportGuardCount,
|
||||
onCreateTransport = onCreateTransport,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BranchOperations(
|
||||
state: FalukantUiState,
|
||||
onUpdateProductionProductId: (String) -> Unit,
|
||||
onUpdateProductionQuantity: (String) -> Unit,
|
||||
onUpdateStorageTypeId: (String) -> Unit,
|
||||
onUpdateStorageAmount: (String) -> Unit,
|
||||
onUpdateDirectorIncome: (String) -> Unit,
|
||||
onStartProduction: () -> Unit,
|
||||
onBuyStorage: () -> Unit,
|
||||
onSellStorage: () -> Unit,
|
||||
onSellAll: () -> Unit,
|
||||
onRepairAllVehicles: () -> Unit,
|
||||
onSaveDirectorIncome: () -> Unit,
|
||||
onUpgradeBranch: () -> Unit,
|
||||
onUpdateDirectorProposalId: (String) -> Unit,
|
||||
onHireDirector: () -> Unit,
|
||||
onUpdateSellProductId: (String) -> Unit,
|
||||
onUpdateSellQuality: (String) -> Unit,
|
||||
onUpdateSellQuantity: (String) -> Unit,
|
||||
onSellProduct: () -> Unit,
|
||||
onUpdateTransportVehicleTypeId: (String) -> Unit,
|
||||
onUpdateTransportTargetBranchId: (String) -> Unit,
|
||||
onUpdateTransportProductId: (String) -> Unit,
|
||||
onUpdateTransportQuantity: (String) -> Unit,
|
||||
onUpdateTransportGuardCount: (String) -> Unit,
|
||||
onCreateTransport: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
state.branchProduction?.asObjectOrNull()?.let { InfoGrid("Laufende Produktion", it.scalarEntries()) }
|
||||
state.branchTaxes?.let { taxes -> InfoGrid("Steuern", taxes.scalarEntries()) }
|
||||
YpPrimaryButton("Filiale aufwerten", onUpgradeBranch, Modifier.fillMaxWidth())
|
||||
state.branchStorage?.let { storage ->
|
||||
InfoGrid("Lager", storage.scalarEntries())
|
||||
StorageUsageCard(storage)
|
||||
}
|
||||
state.branchInventory?.asArrayOrNull()?.let { inventory ->
|
||||
YpInfoCard("Inventar", "${inventory.size} Produktposition(en) verfügbar.", accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
state.branchDirector?.let { director -> InfoGrid("Director", director.scalarEntries()) }
|
||||
state.branchTransports?.asArrayOrNull()?.let { transports ->
|
||||
YpInfoCard("Transporte", "${transports.size} laufende(r) Transport(e).", accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
state.vehicles?.asArrayOrNull()?.let { vehicles ->
|
||||
YpInfoCard("Fahrzeuge", "${vehicles.size} Fahrzeug(e) im Spielstand.", accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
|
||||
YpInfoCard("Produktion starten", listLabels(state.products).ifBlank { "Produkt-ID aus dem Backend verwenden." }, accentColor = YpColors.SurfaceAccent)
|
||||
OutlinedTextField(state.productionProductId, onUpdateProductionProductId, Modifier.fillMaxWidth(), label = { Text("Produkt-ID") })
|
||||
OutlinedTextField(state.productionQuantity, onUpdateProductionQuantity, Modifier.fillMaxWidth(), label = { Text("Menge (1-200)") })
|
||||
YpPrimaryButton("Produktion starten", onStartProduction, Modifier.fillMaxWidth())
|
||||
|
||||
YpInfoCard("Lager erweitern", listLabels(state.stockTypes).ifBlank { "Lagertyp-ID aus dem Backend verwenden." }, accentColor = YpColors.SurfaceAccent)
|
||||
OutlinedTextField(state.storageTypeId, onUpdateStorageTypeId, Modifier.fillMaxWidth(), label = { Text("Lagertyp-ID") })
|
||||
OutlinedTextField(state.storageAmount, onUpdateStorageAmount, Modifier.fillMaxWidth(), label = { Text("Kapazität") })
|
||||
YpPrimaryButton("Lager kaufen", onBuyStorage, Modifier.fillMaxWidth())
|
||||
YpSecondaryButton("Lager verkaufen", onSellStorage, Modifier.fillMaxWidth())
|
||||
YpSecondaryButton("Gesamtes Inventar verkaufen", onSellAll, Modifier.fillMaxWidth())
|
||||
YpSecondaryButton("Alle Fahrzeuge reparieren", onRepairAllVehicles, Modifier.fillMaxWidth())
|
||||
|
||||
YpInfoCard("Einzelverkauf", "Produkt-ID, Qualitätsstufe und Menge aus dem Inventar verwenden.", accentColor = YpColors.SurfaceAccent)
|
||||
OutlinedTextField(state.sellProductId, onUpdateSellProductId, Modifier.fillMaxWidth(), label = { Text("Produkt-ID") })
|
||||
OutlinedTextField(state.sellQuality, onUpdateSellQuality, Modifier.fillMaxWidth(), label = { Text("Qualität") })
|
||||
OutlinedTextField(state.sellQuantity, onUpdateSellQuantity, Modifier.fillMaxWidth(), label = { Text("Menge") })
|
||||
YpPrimaryButton("Einzelverkauf ausführen", onSellProduct, Modifier.fillMaxWidth())
|
||||
|
||||
YpInfoCard("Transport erstellen", "Fahrzeugtyp, Produkt und Ziel-Filiale anhand der geladenen Daten auswählen.", accentColor = YpColors.SurfaceAccent)
|
||||
OutlinedTextField(state.transportVehicleTypeId, onUpdateTransportVehicleTypeId, Modifier.fillMaxWidth(), label = { Text("Fahrzeugtyp-ID") })
|
||||
OutlinedTextField(state.transportTargetBranchId, onUpdateTransportTargetBranchId, Modifier.fillMaxWidth(), label = { Text("Ziel-Filial-ID") })
|
||||
OutlinedTextField(state.transportProductId, onUpdateTransportProductId, Modifier.fillMaxWidth(), label = { Text("Produkt-ID") })
|
||||
OutlinedTextField(state.transportQuantity, onUpdateTransportQuantity, Modifier.fillMaxWidth(), label = { Text("Menge") })
|
||||
OutlinedTextField(state.transportGuardCount, onUpdateTransportGuardCount, Modifier.fillMaxWidth(), label = { Text("Wachen") })
|
||||
YpPrimaryButton("Transport starten", onCreateTransport, Modifier.fillMaxWidth())
|
||||
|
||||
state.branchDirector?.directorIdOrNull()?.let { _ ->
|
||||
OutlinedTextField(state.directorIncome, onUpdateDirectorIncome, Modifier.fillMaxWidth(), label = { Text("Director-Einkommen") })
|
||||
YpPrimaryButton("Director-Einkommen speichern", onSaveDirectorIncome, Modifier.fillMaxWidth())
|
||||
}
|
||||
state.directorProposals?.asArrayOrNull()?.let { proposals ->
|
||||
YpInfoCard("Director-Proposals", proposals.take(5).joinToString(" · ") { proposal ->
|
||||
val item = proposal as? JsonObject
|
||||
"${item?.primitiveValue("id").orEmpty()}: ${item?.primitiveValue("name").orEmpty()}"
|
||||
}, accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
OutlinedTextField(state.directorProposalId, onUpdateDirectorProposalId, Modifier.fillMaxWidth(), label = { Text("Director-Proposal-ID") })
|
||||
YpPrimaryButton("Director einstellen", onHireDirector, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StorageUsageCard(storage: JsonObject) {
|
||||
val usage = storage["usageByType"] as? JsonArray ?: return
|
||||
YpInfoCard("Lagernutzung", usage.joinToString(" · ") { item ->
|
||||
val value = item as? JsonObject ?: return@joinToString ""
|
||||
"${value.primitiveValue("stockTypeLabelTr").orEmpty()}: ${value.primitiveValue("used").orEmpty()}/${value.primitiveValue("totalCapacity").orEmpty()}"
|
||||
}, accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.bankContent(
|
||||
state: FalukantUiState,
|
||||
onUpdateCreditHeight: (String) -> Unit,
|
||||
onTakeOrPayCredit: () -> Unit,
|
||||
) {
|
||||
val bank = state.bank
|
||||
item {
|
||||
InfoGrid(
|
||||
title = "Bank",
|
||||
rows = listOfNotNull(
|
||||
bank?.money?.let { "Guthaben" to it.toString() },
|
||||
bank?.totalDebt?.let { "Schulden" to it.toString() },
|
||||
bank?.maxCredit?.let { "Maximaler Kredit" to it.toString() },
|
||||
bank?.availableCredit?.let { "Verfügbarer Kredit" to it.toString() },
|
||||
bank?.activeCredits?.let { "Aktive Kredite" to it.toString() },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (bank?.debtorsPrisonActive == true) {
|
||||
item { YpEmptyState(if (bank.inDebtorsPrison) "Schuldgefängnis" else "Schuldenwarnung", "Kreditaktionen sind im nativen MVP nur lesbar.") }
|
||||
}
|
||||
state.bankCredits?.asArrayOrNull()?.let { credits ->
|
||||
item { YpInfoCard("Aktive Kredite", "${credits.size} Kredit(e) aktiv.", accentColor = YpColors.SurfaceAccent) }
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(LocalYpSpacing.current.sm)) {
|
||||
OutlinedTextField(state.creditHeight, onUpdateCreditHeight, Modifier.fillMaxWidth(), label = { Text("Kreditbetrag") })
|
||||
YpPrimaryButton("Kredit aufnehmen", onTakeOrPayCredit, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.familyContent(state: FalukantUiState) {
|
||||
val family = state.family
|
||||
item {
|
||||
InfoGrid(
|
||||
title = "Familie",
|
||||
rows = listOf(
|
||||
"Partner" to family?.partnerName.orEmpty().ifBlank { "Keine Angabe" },
|
||||
"Beziehungsstatus" to family?.relationship.orEmpty().ifBlank { "Keine Angabe" },
|
||||
"Kinder" to family?.children?.size?.toString().orEmpty(),
|
||||
"Liebesbeziehungen" to family?.lovers?.size?.toString().orEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
family?.children?.takeIf { it.isNotEmpty() }?.let { children -> item { YpInfoCard("Kinder", children.joinToString(", "), accentColor = YpColors.SurfaceAccent) } }
|
||||
family?.lovers?.takeIf { it.isNotEmpty() }?.let { lovers -> item { YpInfoCard("Liebesbeziehungen", lovers.joinToString(", "), accentColor = YpColors.SurfaceAccent) } }
|
||||
item { YpInfoCard("MVP-Grenze", "Beziehungs-, Erb- und Geschenkeaktionen bleiben im Falukant-Vollausbau.", accentColor = YpColors.SurfaceAccent) }
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.notificationContent(state: FalukantUiState) {
|
||||
if (!state.isLoading && state.notifications.isEmpty()) {
|
||||
item { YpEmptyState("Keine Nachrichten", "Es liegen keine Falukant-Benachrichtigungen vor.") }
|
||||
}
|
||||
items(state.notifications, key = { it.id }) { notification ->
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(notification.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
if (notification.description.isNotBlank()) Text(notification.description)
|
||||
if (notification.createdAt.isNotBlank()) Text(notification.createdAt, style = MaterialTheme.typography.bodySmall, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoGrid(title: String, rows: List<Pair<String, String>>) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
rows.forEach { (label, value) ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, color = YpColors.TextSecondary)
|
||||
Text(value, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.primitiveValue(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
|
||||
private fun JsonObject.directorIdOrNull(): Long? = (this["director"] as? JsonObject)?.primitiveValue("id")?.toLongOrNull()
|
||||
private fun JsonObject.scalarEntries(): List<Pair<String, String>> = entries.mapNotNull { (key, value) ->
|
||||
(value as? JsonPrimitive)?.contentOrNull?.let { key to it }
|
||||
}.take(12)
|
||||
private fun JsonElement.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
private fun JsonElement.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
private fun listLabels(element: JsonElement?): String = element?.asArrayOrNull()?.take(5)?.joinToString(" · ") { item ->
|
||||
val value = item as? JsonObject ?: return@joinToString ""
|
||||
"${value.primitiveValue("id").orEmpty()}: ${value.primitiveValue("labelTr") ?: value.primitiveValue("name").orEmpty()}"
|
||||
}.orEmpty()
|
||||
@@ -0,0 +1,287 @@
|
||||
package de.yourpart.nativeapp.feature.falukant
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class FalukantSection(val label: String) {
|
||||
Overview("Übersicht"),
|
||||
Branches("Filialen"),
|
||||
Bank("Bank"),
|
||||
Family("Familie"),
|
||||
Notifications("Nachrichten"),
|
||||
Full("Vollausbau"),
|
||||
}
|
||||
|
||||
data class FalukantUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val section: FalukantSection = FalukantSection.Overview,
|
||||
val status: FalukantStatusDto? = null,
|
||||
val user: JsonObject? = null,
|
||||
val branches: List<FalukantBranchDto> = emptyList(),
|
||||
val selectedBranch: JsonObject? = null,
|
||||
val selectedBranchId: Long? = null,
|
||||
val branchProduction: JsonElement? = null,
|
||||
val branchStorage: JsonObject? = null,
|
||||
val branchInventory: JsonElement? = null,
|
||||
val branchDirector: JsonObject? = null,
|
||||
val branchTransports: JsonElement? = null,
|
||||
val vehicles: JsonElement? = null,
|
||||
val products: JsonElement? = null,
|
||||
val stockTypes: JsonElement? = null,
|
||||
val branchTaxes: JsonObject? = null,
|
||||
val directorProposals: JsonElement? = null,
|
||||
val directorProposalId: String = "",
|
||||
val bankCredits: JsonElement? = null,
|
||||
val productionProductId: String = "",
|
||||
val productionQuantity: String = "1",
|
||||
val storageTypeId: String = "",
|
||||
val storageAmount: String = "1",
|
||||
val directorIncome: String = "",
|
||||
val sellProductId: String = "",
|
||||
val sellQuality: String = "50",
|
||||
val sellQuantity: String = "1",
|
||||
val transportVehicleTypeId: String = "",
|
||||
val transportTargetBranchId: String = "",
|
||||
val transportProductId: String = "",
|
||||
val transportQuantity: String = "1",
|
||||
val transportGuardCount: String = "0",
|
||||
val creditHeight: String = "",
|
||||
val fullData: Map<String, JsonElement> = emptyMap(),
|
||||
val selectedFullActionId: String = "family-heir",
|
||||
val fullActionPayload: String = "{\"childCharacterId\": 1}",
|
||||
val fullActionRegionId: String = "",
|
||||
val bank: FalukantBankDto? = null,
|
||||
val family: FalukantFamilyDto? = null,
|
||||
val notifications: List<FalukantNotificationDto> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FalukantViewModel @Inject constructor(
|
||||
private val repository: FalukantRepository,
|
||||
realtimeManager: RealtimeManager,
|
||||
) : ViewModel() {
|
||||
val fullActions: List<FalukantRepository.FullAction> = repository.fullActions
|
||||
private val _uiState = MutableStateFlow(FalukantUiState())
|
||||
val uiState: StateFlow<FalukantUiState> = _uiState
|
||||
|
||||
init {
|
||||
refresh()
|
||||
viewModelScope.launch {
|
||||
realtimeManager.events.collect { event ->
|
||||
if (event.eventName in falukantRealtimeEvents) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSection(section: FalukantSection) {
|
||||
_uiState.value = _uiState.value.copy(section = section)
|
||||
}
|
||||
|
||||
fun selectBranch(id: Long) {
|
||||
viewModelScope.launch {
|
||||
val branch = repository.loadBranch(id)
|
||||
val production = repository.loadProduction(id)
|
||||
val storage = repository.loadStorage(id)
|
||||
val inventory = repository.loadInventory(id)
|
||||
val director = repository.loadDirector(id)
|
||||
val transports = repository.loadTransports(id)
|
||||
val vehicles = repository.loadVehicles()
|
||||
val products = repository.loadProducts()
|
||||
val stockTypes = repository.loadStockTypes()
|
||||
val taxes = repository.loadBranchTaxes(id)
|
||||
val proposals = repository.loadDirectorProposals(id)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedBranchId = id,
|
||||
selectedBranch = branch.getOrNull(),
|
||||
branchProduction = production.getOrNull(),
|
||||
branchStorage = storage.getOrNull(),
|
||||
branchInventory = inventory.getOrNull(),
|
||||
branchDirector = director.getOrNull(),
|
||||
branchTransports = transports.getOrNull(),
|
||||
vehicles = vehicles.getOrNull(),
|
||||
products = products.getOrNull(),
|
||||
stockTypes = stockTypes.getOrNull(),
|
||||
branchTaxes = taxes.getOrNull(),
|
||||
directorProposals = proposals.getOrNull(),
|
||||
errorMessage = listOf(branch, production, storage, inventory, director, transports, vehicles, products, stockTypes)
|
||||
.firstNotNullOfOrNull { it.exceptionOrNull()?.message },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProductionProductId(value: String) { _uiState.value = _uiState.value.copy(productionProductId = value) }
|
||||
fun updateProductionQuantity(value: String) { _uiState.value = _uiState.value.copy(productionQuantity = value) }
|
||||
fun updateStorageTypeId(value: String) { _uiState.value = _uiState.value.copy(storageTypeId = value) }
|
||||
fun updateStorageAmount(value: String) { _uiState.value = _uiState.value.copy(storageAmount = value) }
|
||||
fun updateDirectorIncome(value: String) { _uiState.value = _uiState.value.copy(directorIncome = value) }
|
||||
fun updateDirectorProposalId(value: String) { _uiState.value = _uiState.value.copy(directorProposalId = value) }
|
||||
fun updateSellProductId(value: String) { _uiState.value = _uiState.value.copy(sellProductId = value) }
|
||||
fun updateSellQuality(value: String) { _uiState.value = _uiState.value.copy(sellQuality = value) }
|
||||
fun updateSellQuantity(value: String) { _uiState.value = _uiState.value.copy(sellQuantity = value) }
|
||||
fun updateTransportVehicleTypeId(value: String) { _uiState.value = _uiState.value.copy(transportVehicleTypeId = value) }
|
||||
fun updateTransportTargetBranchId(value: String) { _uiState.value = _uiState.value.copy(transportTargetBranchId = value) }
|
||||
fun updateTransportProductId(value: String) { _uiState.value = _uiState.value.copy(transportProductId = value) }
|
||||
fun updateTransportQuantity(value: String) { _uiState.value = _uiState.value.copy(transportQuantity = value) }
|
||||
fun updateTransportGuardCount(value: String) { _uiState.value = _uiState.value.copy(transportGuardCount = value) }
|
||||
fun updateCreditHeight(value: String) { _uiState.value = _uiState.value.copy(creditHeight = value) }
|
||||
fun updateFullActionId(value: String) {
|
||||
val action = repository.fullActions.firstOrNull { it.id == value } ?: return
|
||||
_uiState.value = _uiState.value.copy(selectedFullActionId = value, fullActionPayload = action.examplePayload)
|
||||
}
|
||||
fun updateFullActionPayload(value: String) { _uiState.value = _uiState.value.copy(fullActionPayload = value) }
|
||||
fun updateFullActionRegionId(value: String) { _uiState.value = _uiState.value.copy(fullActionRegionId = value) }
|
||||
fun refreshFullData() {
|
||||
viewModelScope.launch {
|
||||
repository.loadFullData().onSuccess { _uiState.value = _uiState.value.copy(fullData = it) }
|
||||
.onFailure { fail(it.message ?: "Falukant-Vollausbau konnte nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
fun executeFullAction() {
|
||||
val state = _uiState.value
|
||||
val action = repository.fullActions.firstOrNull { it.id == state.selectedFullActionId } ?: return
|
||||
val payload = runCatching { kotlinx.serialization.json.Json.parseToJsonElement(state.fullActionPayload).jsonObject }
|
||||
.getOrElse { return fail("Payload muss ein gültiges JSON-Objekt sein.") }
|
||||
if (action.path.contains("{regionId}") && state.fullActionRegionId.toLongOrNull() == null) return fail("Region-ID ist erforderlich.")
|
||||
viewModelScope.launch {
|
||||
repository.executeFullAction(action, payload, state.fullActionRegionId).onSuccess { refreshFullData(); refresh() }
|
||||
.onFailure { fail(it.message ?: "Aktion konnte nicht ausgeführt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun startProduction() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val productId = _uiState.value.productionProductId.toLongOrNull()
|
||||
val quantity = _uiState.value.productionQuantity.toIntOrNull()
|
||||
if (productId == null || quantity == null || quantity !in 1..200) return fail("Produkt-ID und Menge von 1 bis 200 sind erforderlich.")
|
||||
runBranchAction { repository.startProduction(branchId, productId, quantity) }
|
||||
}
|
||||
fun upgradeBranch() { val id = _uiState.value.selectedBranchId ?: return; runBranchAction { repository.upgradeBranch(id) } }
|
||||
fun hireDirector() {
|
||||
val proposalId = _uiState.value.directorProposalId.toLongOrNull() ?: return fail("Director-Proposal-ID eingeben.")
|
||||
runBranchAction { repository.hireDirector(proposalId) }
|
||||
}
|
||||
|
||||
fun buyStorage() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val typeId = _uiState.value.storageTypeId.toLongOrNull()
|
||||
val amount = _uiState.value.storageAmount.toIntOrNull()
|
||||
if (typeId == null || amount == null || amount < 1) return fail("Lagertyp-ID und Menge sind erforderlich.")
|
||||
runBranchAction { repository.buyStorage(branchId, typeId, amount) }
|
||||
}
|
||||
fun sellStorage() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val typeId = _uiState.value.storageTypeId.toLongOrNull()
|
||||
val amount = _uiState.value.storageAmount.toIntOrNull()
|
||||
if (typeId == null || amount == null || amount < 1) return fail("Lagertyp-ID und Menge sind erforderlich.")
|
||||
runBranchAction { repository.sellStorage(branchId, typeId, amount) }
|
||||
}
|
||||
|
||||
fun sellAll() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
runBranchAction { repository.sellAll(branchId) }
|
||||
}
|
||||
|
||||
fun repairAllVehicles() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
runBranchAction { repository.repairAllVehicles(branchId) }
|
||||
}
|
||||
|
||||
fun saveDirectorIncome() {
|
||||
val directorId = _uiState.value.branchDirector?.primitiveLong("director", "id")
|
||||
?: _uiState.value.branchDirector?.primitiveLong("id")
|
||||
?: return fail("Für diese Filiale ist kein Director verfügbar.")
|
||||
val income = _uiState.value.directorIncome.toDoubleOrNull() ?: return fail("Ein gültiges Director-Einkommen ist erforderlich.")
|
||||
runBranchAction { repository.updateDirectorIncome(directorId, income) }
|
||||
}
|
||||
|
||||
fun sellProduct() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val productId = _uiState.value.sellProductId.toLongOrNull()
|
||||
val quality = _uiState.value.sellQuality.toIntOrNull()
|
||||
val quantity = _uiState.value.sellQuantity.toIntOrNull()
|
||||
if (productId == null || quality == null || quantity == null || quantity < 1) return fail("Produkt-ID, Qualität und Menge sind erforderlich.")
|
||||
runBranchAction { repository.sellProduct(branchId, productId, quality, quantity) }
|
||||
}
|
||||
|
||||
fun createTransport() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val vehicleTypeId = _uiState.value.transportVehicleTypeId.toLongOrNull()
|
||||
val targetBranchId = _uiState.value.transportTargetBranchId.toLongOrNull()
|
||||
val productId = _uiState.value.transportProductId.toLongOrNull()
|
||||
val quantity = _uiState.value.transportQuantity.toIntOrNull()
|
||||
val guardCount = _uiState.value.transportGuardCount.toIntOrNull() ?: 0
|
||||
if (vehicleTypeId == null || targetBranchId == null || productId == null || quantity == null || quantity < 1 || guardCount < 0) return fail("Transportdaten sind unvollständig.")
|
||||
runBranchAction { repository.createTransport(branchId, vehicleTypeId, productId, quantity, targetBranchId, guardCount) }
|
||||
}
|
||||
|
||||
fun takeOrPayCredit() {
|
||||
val height = _uiState.value.creditHeight.toDoubleOrNull() ?: return fail("Gültigen Kreditbetrag eingeben.")
|
||||
viewModelScope.launch {
|
||||
repository.takeOrPayCredit(height).onSuccess { refresh() }
|
||||
.onFailure { fail(it.message ?: "Kreditaktion konnte nicht ausgeführt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
val status = repository.loadStatus()
|
||||
val user = repository.loadUser()
|
||||
val branches = repository.loadBranches()
|
||||
val bank = repository.loadBank()
|
||||
val bankCredits = repository.loadBankCredits()
|
||||
val family = repository.loadFamily()
|
||||
val notifications = repository.loadNotifications()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
status = status.getOrNull(),
|
||||
user = user.getOrNull(),
|
||||
branches = branches.getOrDefault(emptyList()),
|
||||
bank = bank.getOrNull(),
|
||||
bankCredits = bankCredits.getOrNull(),
|
||||
family = family.getOrNull(),
|
||||
notifications = notifications.getOrDefault(emptyList()),
|
||||
errorMessage = listOf(status, user, branches, bank, bankCredits, family, notifications)
|
||||
.firstNotNullOfOrNull { it.exceptionOrNull()?.message },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runBranchAction(action: suspend () -> Result<Unit>) {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
viewModelScope.launch {
|
||||
action().onSuccess { selectBranch(branchId); refresh() }
|
||||
.onFailure { fail(it.message ?: "Aktion konnte nicht ausgeführt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(message: String) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.primitiveLong(parentKey: String, key: String): Long? = (this[parentKey] as? JsonObject)?.primitiveLong(key)
|
||||
private fun JsonObject.primitiveLong(key: String): Long? = (this[key] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.toLongOrNull()
|
||||
|
||||
private val falukantRealtimeEvents = setOf(
|
||||
"falukantUpdateStatus",
|
||||
"falukantUpdateFamily",
|
||||
"falukantUpdateChurch",
|
||||
"falukantUpdateDebt",
|
||||
"children_update",
|
||||
"falukantUpdateProductionCertificate",
|
||||
"falukantBranchUpdate",
|
||||
"stock_change",
|
||||
"familychanged",
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.yourpart.nativeapp.feature.forum
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ForumDto(
|
||||
val id: Long,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumTopicSummaryDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
val createdBy: String = "",
|
||||
val createdByHash: String = "",
|
||||
val createdAt: String? = null,
|
||||
val numberOfItems: Int = 0,
|
||||
val lastMessageDate: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumPageDto(
|
||||
val name: String = "",
|
||||
val titles: List<ForumTopicSummaryDto> = emptyList(),
|
||||
val page: Int = 1,
|
||||
val totalTopics: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumAuthorDto(
|
||||
val hashedId: String = "",
|
||||
val username: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumMessageDto(
|
||||
val id: Long,
|
||||
val text: String = "",
|
||||
val createdAt: String? = null,
|
||||
val lastMessageUser: ForumAuthorDto = ForumAuthorDto(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumTopicDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
val forum: ForumDto,
|
||||
val messages: List<ForumMessageDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateForumTopicRequest(
|
||||
val forumId: Long,
|
||||
val title: String,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateForumMessageRequest(
|
||||
val content: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumModerationReportRequest(
|
||||
val targetType: String = "forum_message",
|
||||
val targetId: Long,
|
||||
val reason: String,
|
||||
val details: String = "",
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.yourpart.nativeapp.feature.forum
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ForumRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun loadForums(): Result<List<ForumDto>> = get("/api/forum/")
|
||||
|
||||
suspend fun loadForum(forumId: Long, page: Int): Result<ForumPageDto> =
|
||||
get("/api/forum/$forumId/$page")
|
||||
|
||||
suspend fun loadTopic(topicId: Long): Result<ForumTopicDto> = get("/api/forum/topic/$topicId")
|
||||
|
||||
suspend fun createTopic(
|
||||
forumId: Long,
|
||||
title: String,
|
||||
content: String,
|
||||
): Result<ForumPageDto> = post(
|
||||
path = "/api/forum/topic",
|
||||
payload = CreateForumTopicRequest(forumId, title, content),
|
||||
)
|
||||
|
||||
suspend fun createMessage(topicId: Long, content: String): Result<ForumTopicDto> = post(
|
||||
path = "/api/forum/topic/$topicId/message",
|
||||
payload = CreateForumMessageRequest(content),
|
||||
)
|
||||
|
||||
suspend fun reportMessage(messageId: Long, reason: String, details: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(
|
||||
ForumModerationReportRequest(
|
||||
targetId = messageId,
|
||||
reason = reason,
|
||||
details = details,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/moderation/reports")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Meldung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> get(path: String): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path").get().build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<T>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T, reified P> post(path: String, payload: P): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(payload).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path").post(body).build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<T>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package de.yourpart.nativeapp.feature.forum
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun ForumListScreen(
|
||||
uiState: ForumListUiState,
|
||||
onRefresh: () -> Unit,
|
||||
onOpenForum: (Long) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Forum",
|
||||
body = "Themen, Antworten und Meldungen laufen nativ gegen das Community-Backend.",
|
||||
)
|
||||
}
|
||||
item { YpSecondaryButton("Aktualisieren", onRefresh, Modifier.fillMaxWidth()) }
|
||||
uiState.errorMessage?.let { error -> item { YpEmptyState("Forum-Fehler", error) } }
|
||||
if (!uiState.isLoading && uiState.forums.isEmpty()) {
|
||||
item { YpEmptyState("Keine Foren", "Für dein Konto sind derzeit keine Foren zugänglich.") }
|
||||
}
|
||||
items(uiState.forums, key = { it.id }) { forum ->
|
||||
Card(
|
||||
onClick = { onOpenForum(forum.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(forum.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text("Themen anzeigen", style = MaterialTheme.typography.bodySmall, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ForumTopicsScreen(
|
||||
forumId: Long,
|
||||
uiState: ForumTopicsUiState,
|
||||
onLoad: (Long, Int) -> Unit,
|
||||
onToggleCreateTopic: () -> Unit,
|
||||
onUpdateTitle: (String) -> Unit,
|
||||
onUpdateContent: (String) -> Unit,
|
||||
onCreateTopic: () -> Unit,
|
||||
onOpenTopic: (Long) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
LaunchedEffect(forumId) { onLoad(forumId, 1) }
|
||||
val totalPages = maxOf(1, (uiState.totalTopics + 24) / 25)
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = uiState.forumName.ifBlank { "Forum" },
|
||||
body = "${uiState.totalTopics} Thema/Themen. Änderungen werden bei Forum-Updates neu geladen.",
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpSecondaryButton("Zurück", onBack)
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.showCreateTopic) "Erstellen schließen" else "Neues Thema",
|
||||
onClick = onToggleCreateTopic,
|
||||
)
|
||||
}
|
||||
}
|
||||
uiState.errorMessage?.let { error -> item { YpEmptyState("Forum-Fehler", error) } }
|
||||
uiState.statusMessage?.let { message -> item { YpInfoCard("Hinweis", message, accentColor = YpColors.SurfaceAccent) } }
|
||||
if (uiState.showCreateTopic) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.newTopicTitle,
|
||||
onValueChange = onUpdateTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Titel") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.newTopicContent,
|
||||
onValueChange = onUpdateContent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Erster Beitrag") },
|
||||
minLines = 5,
|
||||
)
|
||||
YpPrimaryButton("Thema veröffentlichen", onCreateTopic, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uiState.isLoading && uiState.topics.isEmpty()) {
|
||||
item { YpEmptyState("Noch keine Themen", "Eröffne das erste Thema in diesem Forum.") }
|
||||
}
|
||||
items(uiState.topics, key = { it.id }) { topic ->
|
||||
Card(
|
||||
onClick = { onOpenTopic(topic.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(topic.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Von ${topic.createdBy.ifBlank { "Community" }} · ${topic.numberOfItems} Beitrag/Beiträge",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpSecondaryButton("Vorherige", { onLoad(forumId, uiState.page - 1) }, enabled = uiState.page > 1)
|
||||
Text("Seite ${uiState.page} von $totalPages", modifier = Modifier.padding(top = spacing.sm))
|
||||
YpSecondaryButton("Nächste", { onLoad(forumId, uiState.page + 1) }, enabled = uiState.page < totalPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ForumTopicScreen(
|
||||
topicId: Long,
|
||||
uiState: ForumTopicUiState,
|
||||
onLoad: (Long) -> Unit,
|
||||
onUpdateReply: (String) -> Unit,
|
||||
onSendReply: () -> Unit,
|
||||
onReportMessage: (Long, String) -> Unit,
|
||||
onBack: (Long) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
var reportMessageId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
var reportReason by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(topicId) { onLoad(topicId) }
|
||||
val topic = uiState.topic
|
||||
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = topic?.title ?: "Thema",
|
||||
body = topic?.forum?.name ?: "Thema wird geladen.",
|
||||
)
|
||||
}
|
||||
topic?.let { loadedTopic ->
|
||||
item { YpSecondaryButton("Zurück zum Forum", { onBack(loadedTopic.forum.id) }, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
uiState.errorMessage?.let { error -> item { YpEmptyState("Thema-Fehler", error) } }
|
||||
uiState.statusMessage?.let { message -> item { YpInfoCard("Hinweis", message, accentColor = YpColors.SurfaceAccent) } }
|
||||
items(topic?.messages.orEmpty(), key = { it.id }) { message ->
|
||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
Text(message.text.asPlainText(), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"${message.lastMessageUser.username.ifBlank { "Community" }}${message.createdAt?.let { " · $it" }.orEmpty()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
YpSecondaryButton("Beitrag melden", { reportMessageId = message.id }, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uiState.isLoading && topic?.messages.isNullOrEmpty()) {
|
||||
item { YpEmptyState("Keine Beiträge", "Dieses Thema enthält noch keine Beiträge.") }
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.reply,
|
||||
onValueChange = onUpdateReply,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Antwort") },
|
||||
minLines = 4,
|
||||
)
|
||||
YpPrimaryButton("Antwort senden", onSendReply, Modifier.fillMaxWidth(), enabled = uiState.reply.isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportMessageId?.let { messageId ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { reportMessageId = null },
|
||||
title = { Text("Beitrag melden") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = reportReason,
|
||||
onValueChange = { reportReason = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Grund") },
|
||||
minLines = 3,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onReportMessage(messageId, reportReason)
|
||||
reportReason = ""
|
||||
reportMessageId = null
|
||||
},
|
||||
enabled = reportReason.trim().length >= 3,
|
||||
) { Text("Melden") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { reportMessageId = null }) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.asPlainText(): String = replace(Regex("<[^>]*>"), " ")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
@@ -0,0 +1,201 @@
|
||||
package de.yourpart.nativeapp.feature.forum
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ForumListUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val forums: List<ForumDto> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ForumListViewModel @Inject constructor(
|
||||
private val repository: ForumRepository,
|
||||
realtimeManager: RealtimeManager,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ForumListUiState())
|
||||
val uiState: StateFlow<ForumListUiState> = _uiState
|
||||
|
||||
init {
|
||||
refresh()
|
||||
viewModelScope.launch {
|
||||
realtimeManager.events.collect { event ->
|
||||
if (event.eventName in forumRealtimeEvents) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadForums()
|
||||
.onSuccess { _uiState.value = ForumListUiState(isLoading = false, forums = it) }
|
||||
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = it.message ?: "Foren konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ForumTopicsUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val forumName: String = "",
|
||||
val topics: List<ForumTopicSummaryDto> = emptyList(),
|
||||
val page: Int = 1,
|
||||
val totalTopics: Int = 0,
|
||||
val showCreateTopic: Boolean = false,
|
||||
val newTopicTitle: String = "",
|
||||
val newTopicContent: String = "",
|
||||
val errorMessage: String? = null,
|
||||
val statusMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ForumTopicsViewModel @Inject constructor(
|
||||
private val repository: ForumRepository,
|
||||
realtimeManager: RealtimeManager,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ForumTopicsUiState())
|
||||
val uiState: StateFlow<ForumTopicsUiState> = _uiState
|
||||
private var forumId: Long? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
realtimeManager.events.collect { event ->
|
||||
if (event.eventName in forumRealtimeEvents) forumId?.let { load(it, _uiState.value.page) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(id: Long, page: Int = 1) {
|
||||
forumId = id
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadForum(id, page)
|
||||
.onSuccess { result ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
forumName = result.name,
|
||||
topics = result.titles,
|
||||
page = result.page,
|
||||
totalTopics = result.totalTopics,
|
||||
)
|
||||
}
|
||||
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = it.message ?: "Themen konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleCreateTopic() {
|
||||
_uiState.value = _uiState.value.copy(showCreateTopic = !_uiState.value.showCreateTopic)
|
||||
}
|
||||
|
||||
fun updateNewTopicTitle(value: String) { _uiState.value = _uiState.value.copy(newTopicTitle = value) }
|
||||
fun updateNewTopicContent(value: String) { _uiState.value = _uiState.value.copy(newTopicContent = value) }
|
||||
|
||||
fun createTopic() {
|
||||
val id = forumId ?: return
|
||||
val state = _uiState.value
|
||||
val title = state.newTopicTitle.trim()
|
||||
val content = state.newTopicContent.trim()
|
||||
if (title.length < 3 || content.isBlank()) {
|
||||
_uiState.value = state.copy(errorMessage = "Titel und Beitrag sind erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.createTopic(id, title, content)
|
||||
.onSuccess { page ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
forumName = page.name,
|
||||
topics = page.titles,
|
||||
page = page.page,
|
||||
totalTopics = page.totalTopics,
|
||||
showCreateTopic = false,
|
||||
newTopicTitle = "",
|
||||
newTopicContent = "",
|
||||
statusMessage = "Thema erstellt.",
|
||||
)
|
||||
}
|
||||
.onFailure { _uiState.value = _uiState.value.copy(errorMessage = it.message ?: "Thema konnte nicht erstellt werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ForumTopicUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val topic: ForumTopicDto? = null,
|
||||
val reply: String = "",
|
||||
val errorMessage: String? = null,
|
||||
val statusMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ForumTopicViewModel @Inject constructor(
|
||||
private val repository: ForumRepository,
|
||||
realtimeManager: RealtimeManager,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ForumTopicUiState())
|
||||
val uiState: StateFlow<ForumTopicUiState> = _uiState
|
||||
private var topicId: Long? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
realtimeManager.events.collect { event ->
|
||||
if (event.eventName in forumRealtimeEvents) topicId?.let(::load)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(id: Long) {
|
||||
topicId = id
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadTopic(id)
|
||||
.onSuccess { _uiState.value = _uiState.value.copy(isLoading = false, topic = it) }
|
||||
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = it.message ?: "Thema konnte nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateReply(value: String) { _uiState.value = _uiState.value.copy(reply = value) }
|
||||
|
||||
fun sendReply() {
|
||||
val id = topicId ?: return
|
||||
val content = _uiState.value.reply.trim()
|
||||
if (content.isBlank()) return
|
||||
val messageCountBeforeRequest = _uiState.value.topic?.messages?.size ?: 0
|
||||
viewModelScope.launch {
|
||||
repository.createMessage(id, content)
|
||||
.onSuccess { _uiState.value = _uiState.value.copy(topic = it, reply = "", statusMessage = "Antwort gesendet.") }
|
||||
.onFailure { error ->
|
||||
// The current backend can persist a message before its realtime notification fails.
|
||||
repository.loadTopic(id).onSuccess { refreshedTopic ->
|
||||
if (refreshedTopic.messages.size > messageCountBeforeRequest) {
|
||||
_uiState.value = _uiState.value.copy(topic = refreshedTopic, reply = "", statusMessage = "Antwort gesendet.")
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Antwort konnte nicht gesendet werden.")
|
||||
}
|
||||
}.onFailure {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Antwort konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reportMessage(messageId: Long, reason: String) {
|
||||
if (reason.trim().length < 3) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = "Bitte nenne einen Grund mit mindestens drei Zeichen.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.reportMessage(messageId, reason.trim(), "")
|
||||
.onSuccess { _uiState.value = _uiState.value.copy(statusMessage = "Meldung wurde gesendet.") }
|
||||
.onFailure { _uiState.value = _uiState.value.copy(errorMessage = it.message ?: "Meldung konnte nicht gesendet werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val forumRealtimeEvents = setOf("forumschanged", "topicschanged", "messageschanged")
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GalleryVisibilityDto(
|
||||
val id: Long,
|
||||
val description: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryFolderDto(
|
||||
val id: Long,
|
||||
val name: String = "",
|
||||
val children: List<GalleryFolderDto> = emptyList(),
|
||||
val visibilityTypeIds: List<Long> = emptyList(),
|
||||
val selectedUsers: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryImageDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
val hash: String = "",
|
||||
val folderId: Long = 0,
|
||||
val visibilities: List<GalleryVisibilityDto> = emptyList(),
|
||||
val selectedUsers: List<String> = emptyList(),
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryImageUpdateDto(
|
||||
val title: String,
|
||||
val visibilities: List<GalleryVisibilityDto>,
|
||||
val selectedUsers: List<String>,
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.MultipartUploadHelper
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Request
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GalleryRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
private val uploadHelper = MultipartUploadHelper(context.contentResolver)
|
||||
|
||||
suspend fun loadFolders(): Result<GalleryFolderDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/folders")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<GalleryFolderDto>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadImages(folderId: Long): Result<List<GalleryImageDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/folder/$folderId")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<GalleryImageDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadVisibilities(): Result<List<GalleryVisibilityDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/imagevisibilities")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<GalleryVisibilityDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createFolder(
|
||||
parentFolderId: Long,
|
||||
name: String,
|
||||
visibilities: List<Long>,
|
||||
selectedUsers: List<String> = emptyList(),
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = buildJsonObject {
|
||||
put("name", JsonPrimitive(name))
|
||||
put("parentId", JsonPrimitive(parentFolderId))
|
||||
put("visibilities", buildJsonArray { visibilities.forEach { add(JsonPrimitive(it)) } })
|
||||
put("selectedUsers", buildJsonArray { selectedUsers.forEach { add(JsonPrimitive(it)) } })
|
||||
}.toString()
|
||||
val body = payload.toRequestBody(
|
||||
"application/json".toMediaType(),
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/folders/$parentFolderId")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Ordner konnte nicht erstellt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
folderId: Long,
|
||||
title: String,
|
||||
visibilityIds: List<Long>,
|
||||
selectedUsers: List<String>,
|
||||
uri: Uri,
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val part = uploadHelper.toMultipartPart(uri, "image", fileName = fileNameFromUri(uri))
|
||||
val request = GalleryUploadRequestFactory.create(
|
||||
apiBaseUrl = appConfig.apiBaseUrl,
|
||||
imagePart = part,
|
||||
folderId = folderId,
|
||||
title = title,
|
||||
visibilityJson = json.encodeToString(visibilityIds),
|
||||
selectedUsersJson = json.encodeToString(selectedUsers),
|
||||
)
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Bild konnte nicht hochgeladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateImage(
|
||||
imageId: Long,
|
||||
title: String,
|
||||
visibilities: List<GalleryVisibilityDto>,
|
||||
selectedUsers: List<String>,
|
||||
): Result<List<GalleryImageDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = json.encodeToString(
|
||||
GalleryImageUpdateDto(
|
||||
title = title,
|
||||
visibilities = visibilities,
|
||||
selectedUsers = selectedUsers,
|
||||
),
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/images/$imageId")
|
||||
.put(payload.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<GalleryImageDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException("Bild konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun imageUrl(hash: String): String = "${appConfig.apiBaseUrl}/api/socialnetwork/image/$hash"
|
||||
|
||||
suspend fun reportImage(imageId: Long): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = "{\"targetType\":\"image\",\"targetId\":$imageId,\"reason\":\"inappropriate_content\",\"details\":\"Meldung aus der nativen Galerie\"}"
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}/api/moderation/reports").post(body).build()
|
||||
when (requestExecutor.execute(request)) { is ApiResult.Success -> Unit; is ApiResult.Failure -> throw IllegalStateException("Bild konnte nicht gemeldet werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileNameFromUri(uri: Uri): String {
|
||||
return uri.lastPathSegment?.substringAfterLast('/')?.ifBlank { null } ?: "upload.jpg"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun GalleryScreen(
|
||||
uiState: GalleryUiState,
|
||||
session: AuthSession?,
|
||||
onSelectFolder: (GalleryFolderDto) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onUpdateUploadTitle: (String) -> Unit,
|
||||
onSetUploadUri: (android.net.Uri?) -> Unit,
|
||||
onToggleVisibility: (Long) -> Unit,
|
||||
onUpdateCreateFolderName: (String) -> Unit,
|
||||
onToggleCreateFolderExpanded: () -> Unit,
|
||||
onUpdateSelectedUsersText: (String) -> Unit,
|
||||
onCreateFolder: () -> Unit,
|
||||
onUploadImage: () -> Unit,
|
||||
onOpenImageDetail: (GalleryImageDto) -> Unit,
|
||||
onCloseImageDetail: () -> Unit,
|
||||
onUpdateDetailTitle: (String) -> Unit,
|
||||
onToggleDetailVisibility: (Long) -> Unit,
|
||||
onUpdateDetailSelectedUsersText: (String) -> Unit,
|
||||
onSaveImageDetail: () -> Unit,
|
||||
onReportImage: () -> Unit,
|
||||
imageUrlResolver: (String) -> String,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val pickImageLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia(),
|
||||
) { uri ->
|
||||
onSetUploadUri(uri)
|
||||
}
|
||||
|
||||
val selectedFolder = uiState.rootFolder?.let { root ->
|
||||
findFolder(root, uiState.selectedFolderId) ?: root
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Galerie",
|
||||
body = "Ordner, Bilder und Uploads laufen jetzt nativ gegen das Socialnetwork-Backend.",
|
||||
)
|
||||
}
|
||||
|
||||
uiState.errorMessage?.let { error ->
|
||||
item { YpEmptyState(title = "Galerie-Fehler", body = error) }
|
||||
}
|
||||
|
||||
uiState.statusMessage?.let { message ->
|
||||
item { YpInfoCard(title = "Hinweis", body = message, accentColor = YpColors.SurfaceAccent) }
|
||||
}
|
||||
|
||||
item {
|
||||
YpSecondaryButton(label = "Aktualisieren", onClick = onRefresh, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Ordner",
|
||||
body = selectedFolder?.name?.takeIf { it.isNotBlank() } ?: "Root",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
FolderTree(
|
||||
folder = uiState.rootFolder,
|
||||
selectedFolderId = uiState.selectedFolderId,
|
||||
onSelectFolder = onSelectFolder,
|
||||
)
|
||||
|
||||
YpSecondaryButton(
|
||||
label = if (uiState.createFolderExpanded) "Ordnerformular schließen" else "Ordner anlegen",
|
||||
onClick = onToggleCreateFolderExpanded,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (uiState.createFolderExpanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.createFolderName,
|
||||
onValueChange = onUpdateCreateFolderName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Ordnername") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.selectedUsersText,
|
||||
onValueChange = onUpdateSelectedUsersText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Sichtbar für Nutzer") },
|
||||
placeholder = { Text("anna, bert, clara") },
|
||||
)
|
||||
VisibilitySelector(
|
||||
visibilities = uiState.visibilities,
|
||||
selectedVisibilityIds = uiState.selectedVisibilityIds,
|
||||
onToggleVisibility = onToggleVisibility,
|
||||
)
|
||||
YpPrimaryButton(
|
||||
label = "Ordner speichern",
|
||||
onClick = onCreateFolder,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Upload",
|
||||
body = if (uiState.uploadUri != null) {
|
||||
"Datei ausgewählt: ${uiState.uploadUri.lastPathSegment ?: uiState.uploadUri}"
|
||||
} else {
|
||||
"Noch keine Bilddatei gewählt."
|
||||
},
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.uploadTitle,
|
||||
onValueChange = onUpdateUploadTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Bildtitel") },
|
||||
)
|
||||
VisibilitySelector(
|
||||
visibilities = uiState.visibilities,
|
||||
selectedVisibilityIds = uiState.selectedVisibilityIds,
|
||||
onToggleVisibility = onToggleVisibility,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpSecondaryButton(
|
||||
label = "Bild wählen",
|
||||
onClick = {
|
||||
pickImageLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
YpPrimaryButton(
|
||||
label = "Hochladen",
|
||||
onClick = onUploadImage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.uploadTitle.isNotBlank() && uiState.uploadUri != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Bilder",
|
||||
body = "${uiState.images.size} Bild(er) im aktuellen Ordner",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.images.isEmpty()) {
|
||||
item { YpEmptyState(title = "Keine Bilder", body = "In diesem Ordner liegen noch keine Bilder.") }
|
||||
} else {
|
||||
items(uiState.images, key = { it.id }) { image ->
|
||||
GalleryImageCard(
|
||||
image = image,
|
||||
session = session,
|
||||
imageUrl = imageUrlResolver(image.hash),
|
||||
onOpenDetail = { onOpenImageDetail(image) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.detailImage?.let { image ->
|
||||
GalleryImageDetailDialog(
|
||||
image = image,
|
||||
imageUrl = imageUrlResolver(image.hash),
|
||||
session = session,
|
||||
title = uiState.detailTitle,
|
||||
visibilities = uiState.visibilities,
|
||||
selectedVisibilityIds = uiState.detailVisibilityIds,
|
||||
selectedUsersText = uiState.detailSelectedUsersText,
|
||||
onDismiss = onCloseImageDetail,
|
||||
onUpdateTitle = onUpdateDetailTitle,
|
||||
onToggleVisibility = onToggleDetailVisibility,
|
||||
onUpdateSelectedUsers = onUpdateDetailSelectedUsersText,
|
||||
onSave = onSaveImageDetail,
|
||||
onReport = onReportImage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryImageCard(
|
||||
image: GalleryImageDto,
|
||||
session: AuthSession?,
|
||||
imageUrl: String?,
|
||||
onOpenDetail: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text(image.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
if (!imageUrl.isNullOrBlank() && session != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(),
|
||||
contentDescription = image.title,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
YpEmptyState(title = "Vorschau nicht verfügbar", body = "Es fehlen Bild- oder Session-Daten.")
|
||||
}
|
||||
Text(
|
||||
"Datei: ${image.hash}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
if (image.visibilities.isNotEmpty()) {
|
||||
Text(
|
||||
"Sichtbarkeiten: ${image.visibilities.joinToString { it.description }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
YpSecondaryButton(
|
||||
label = "Details bearbeiten",
|
||||
onClick = onOpenDetail,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryImageDetailDialog(
|
||||
image: GalleryImageDto,
|
||||
imageUrl: String,
|
||||
session: AuthSession?,
|
||||
title: String,
|
||||
visibilities: List<GalleryVisibilityDto>,
|
||||
selectedVisibilityIds: Set<Long>,
|
||||
selectedUsersText: String,
|
||||
onDismiss: () -> Unit,
|
||||
onUpdateTitle: (String) -> Unit,
|
||||
onToggleVisibility: (Long) -> Unit,
|
||||
onUpdateSelectedUsers: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onReport: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Bilddetails") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
if (session != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(),
|
||||
contentDescription = image.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = onUpdateTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Bildtitel") },
|
||||
)
|
||||
VisibilitySelector(
|
||||
visibilities = visibilities,
|
||||
selectedVisibilityIds = selectedVisibilityIds,
|
||||
onToggleVisibility = onToggleVisibility,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = selectedUsersText,
|
||||
onValueChange = onUpdateSelectedUsers,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Sichtbar für Nutzer") },
|
||||
placeholder = { Text("anna, bert, clara") },
|
||||
)
|
||||
Text(
|
||||
"Die Bilddatei bleibt unverändert; der MVP bearbeitet nur Titel und Sichtbarkeit.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row { TextButton(onClick = onReport) { Text("Melden") }; TextButton(onClick = onSave) { Text("Speichern") } }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Schließen") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderTree(
|
||||
folder: GalleryFolderDto?,
|
||||
selectedFolderId: Long?,
|
||||
onSelectFolder: (GalleryFolderDto) -> Unit,
|
||||
depth: Int = 0,
|
||||
) {
|
||||
if (folder == null) return
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
FolderRow(
|
||||
folder = folder,
|
||||
selected = folder.id == selectedFolderId,
|
||||
depth = depth,
|
||||
onSelectFolder = onSelectFolder,
|
||||
)
|
||||
folder.children.forEach { child ->
|
||||
FolderTree(
|
||||
folder = child,
|
||||
selectedFolderId = selectedFolderId,
|
||||
onSelectFolder = onSelectFolder,
|
||||
depth = depth + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderRow(
|
||||
folder: GalleryFolderDto,
|
||||
selected: Boolean,
|
||||
depth: Int,
|
||||
onSelectFolder: (GalleryFolderDto) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
onClick = { onSelectFolder(folder) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) YpColors.PrimarySoft else YpColors.SurfaceStrong,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = spacing.md + (spacing.sm * depth), top = spacing.sm, end = spacing.md, bottom = spacing.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Text("▸", fontWeight = FontWeight.Bold)
|
||||
Text(folder.name, fontWeight = FontWeight.SemiBold)
|
||||
if (folder.visibilityTypeIds.isNotEmpty()) {
|
||||
Text(
|
||||
folder.visibilityTypeIds.joinToString(prefix = "[", postfix = "]"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisibilitySelector(
|
||||
visibilities: List<GalleryVisibilityDto>,
|
||||
selectedVisibilityIds: Set<Long>,
|
||||
onToggleVisibility: (Long) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text("Sichtbarkeit")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
visibilities.forEach { visibility ->
|
||||
FilterChip(
|
||||
selected = selectedVisibilityIds.contains(visibility.id),
|
||||
onClick = { onToggleVisibility(visibility.id) },
|
||||
label = { Text(visibility.description) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
selectedContainerColor = YpColors.PrimarySoft,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFolder(folder: GalleryFolderDto, id: Long?): GalleryFolderDto? {
|
||||
if (id == null) return folder
|
||||
if (folder.id == id) return folder
|
||||
for (child in folder.children) {
|
||||
val found = findFolder(child, id)
|
||||
if (found != null) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
|
||||
internal object GalleryUploadRequestFactory {
|
||||
fun create(
|
||||
apiBaseUrl: String,
|
||||
imagePart: MultipartBody.Part,
|
||||
folderId: Long,
|
||||
title: String,
|
||||
visibilityJson: String,
|
||||
selectedUsersJson: String,
|
||||
): Request {
|
||||
val body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addPart(imagePart)
|
||||
.addFormDataPart("folderId", folderId.toString())
|
||||
.addFormDataPart("title", title)
|
||||
.addFormDataPart("visibility", visibilityJson)
|
||||
.addFormDataPart("selectedUsers", selectedUsersJson)
|
||||
.build()
|
||||
|
||||
return Request.Builder()
|
||||
.url("$apiBaseUrl/api/socialnetwork/images")
|
||||
.post(body)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class GalleryUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val errorMessage: String? = null,
|
||||
val statusMessage: String? = null,
|
||||
val rootFolder: GalleryFolderDto? = null,
|
||||
val selectedFolderId: Long? = null,
|
||||
val selectedFolderName: String = "",
|
||||
val images: List<GalleryImageDto> = emptyList(),
|
||||
val visibilities: List<GalleryVisibilityDto> = emptyList(),
|
||||
val selectedVisibilityIds: Set<Long> = emptySet(),
|
||||
val uploadTitle: String = "",
|
||||
val uploadUri: Uri? = null,
|
||||
val createFolderName: String = "",
|
||||
val createFolderExpanded: Boolean = false,
|
||||
val selectedUsersText: String = "",
|
||||
val detailImage: GalleryImageDto? = null,
|
||||
val detailTitle: String = "",
|
||||
val detailVisibilityIds: Set<Long> = emptySet(),
|
||||
val detailSelectedUsersText: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class GalleryViewModel @Inject constructor(
|
||||
private val repository: GalleryRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = kotlinx.coroutines.flow.MutableStateFlow(GalleryUiState())
|
||||
val uiState: kotlinx.coroutines.flow.StateFlow<GalleryUiState> = _uiState
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
val folders = repository.loadFolders()
|
||||
val visibilities = repository.loadVisibilities()
|
||||
val root = folders.getOrNull()
|
||||
val selectedFolderId = _uiState.value.selectedFolderId ?: root?.id
|
||||
val selectedFolder = findFolder(root, selectedFolderId)
|
||||
val folderName = selectedFolder?.name ?: root?.name.orEmpty()
|
||||
val images = if (selectedFolderId != null) {
|
||||
repository.loadImages(selectedFolderId).getOrDefault(emptyList())
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
rootFolder = root,
|
||||
selectedFolderId = selectedFolderId,
|
||||
selectedFolderName = folderName,
|
||||
images = images,
|
||||
visibilities = visibilities.getOrDefault(emptyList()),
|
||||
selectedVisibilityIds = _uiState.value.selectedVisibilityIds.ifEmpty {
|
||||
visibilities.getOrDefault(emptyList()).map { it.id }.toSet()
|
||||
},
|
||||
errorMessage = folders.exceptionOrNull()?.message
|
||||
?: visibilities.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun imageUrl(hash: String): String = repository.imageUrl(hash)
|
||||
|
||||
fun selectFolder(folder: GalleryFolderDto) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedFolderId = folder.id,
|
||||
selectedFolderName = folder.name,
|
||||
selectedVisibilityIds = folder.visibilityTypeIds.toSet().ifEmpty { _uiState.value.selectedVisibilityIds },
|
||||
)
|
||||
loadImages(folder.id)
|
||||
}
|
||||
|
||||
fun updateUploadTitle(value: String) {
|
||||
_uiState.value = _uiState.value.copy(uploadTitle = value)
|
||||
}
|
||||
|
||||
fun setUploadUri(uri: Uri?) {
|
||||
_uiState.value = _uiState.value.copy(uploadUri = uri)
|
||||
}
|
||||
|
||||
fun toggleVisibility(id: Long) {
|
||||
val current = _uiState.value.selectedVisibilityIds.toMutableSet()
|
||||
if (!current.add(id)) {
|
||||
current.remove(id)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(selectedVisibilityIds = current)
|
||||
}
|
||||
|
||||
fun updateCreateFolderName(value: String) {
|
||||
_uiState.value = _uiState.value.copy(createFolderName = value)
|
||||
}
|
||||
|
||||
fun toggleCreateFolderExpanded() {
|
||||
_uiState.value = _uiState.value.copy(createFolderExpanded = !_uiState.value.createFolderExpanded)
|
||||
}
|
||||
|
||||
fun updateSelectedUsersText(value: String) {
|
||||
_uiState.value = _uiState.value.copy(selectedUsersText = value)
|
||||
}
|
||||
|
||||
fun openImageDetail(image: GalleryImageDto) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
detailImage = image,
|
||||
detailTitle = image.title,
|
||||
detailVisibilityIds = image.visibilities.map { it.id }.toSet(),
|
||||
detailSelectedUsersText = image.selectedUsers.joinToString(", "),
|
||||
)
|
||||
}
|
||||
|
||||
fun closeImageDetail() {
|
||||
_uiState.value = _uiState.value.copy(detailImage = null)
|
||||
}
|
||||
|
||||
fun updateDetailTitle(value: String) {
|
||||
_uiState.value = _uiState.value.copy(detailTitle = value)
|
||||
}
|
||||
|
||||
fun updateDetailSelectedUsersText(value: String) {
|
||||
_uiState.value = _uiState.value.copy(detailSelectedUsersText = value)
|
||||
}
|
||||
|
||||
fun toggleDetailVisibility(id: Long) {
|
||||
val current = _uiState.value.detailVisibilityIds.toMutableSet()
|
||||
if (!current.add(id)) current.remove(id)
|
||||
_uiState.value = _uiState.value.copy(detailVisibilityIds = current)
|
||||
}
|
||||
|
||||
fun saveImageDetail() {
|
||||
val state = _uiState.value
|
||||
val image = state.detailImage ?: return
|
||||
val title = state.detailTitle.trim()
|
||||
val visibilityIds = state.detailVisibilityIds
|
||||
if (title.isBlank() || visibilityIds.isEmpty()) {
|
||||
_uiState.value = state.copy(errorMessage = "Titel und mindestens eine Sichtbarkeit sind erforderlich.")
|
||||
return
|
||||
}
|
||||
val visibilities = state.visibilities.filter { it.id in visibilityIds }
|
||||
viewModelScope.launch {
|
||||
repository.updateImage(
|
||||
imageId = image.id,
|
||||
title = title,
|
||||
visibilities = visibilities,
|
||||
selectedUsers = parseUsers(state.detailSelectedUsersText),
|
||||
).onSuccess { images ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
images = images,
|
||||
detailImage = null,
|
||||
statusMessage = "Bilddetails gespeichert.",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Bild konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reportDetailImage() {
|
||||
val image = _uiState.value.detailImage ?: return
|
||||
viewModelScope.launch { repository.reportImage(image.id).onSuccess { _uiState.value = _uiState.value.copy(statusMessage = "Bild wurde gemeldet.", detailImage = null) }.onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } }
|
||||
}
|
||||
|
||||
fun createFolder() {
|
||||
val state = _uiState.value
|
||||
val folderName = state.createFolderName.trim()
|
||||
val parentId = state.selectedFolderId ?: state.rootFolder?.id
|
||||
if (folderName.isBlank() || parentId == null) {
|
||||
_uiState.value = state.copy(errorMessage = "Ordnername oder Elternordner fehlt.")
|
||||
return
|
||||
}
|
||||
val visibilities = state.selectedVisibilityIds.toList()
|
||||
if (visibilities.isEmpty()) {
|
||||
_uiState.value = state.copy(errorMessage = "Mindestens eine Sichtbarkeit ist erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.createFolder(
|
||||
parentFolderId = parentId,
|
||||
name = folderName,
|
||||
visibilities = visibilities,
|
||||
selectedUsers = parseUsers(state.selectedUsersText),
|
||||
).onSuccess {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
createFolderName = "",
|
||||
selectedUsersText = "",
|
||||
statusMessage = "Ordner erstellt.",
|
||||
)
|
||||
refresh()
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Ordner konnte nicht erstellt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadImage() {
|
||||
val state = _uiState.value
|
||||
val folderId = state.selectedFolderId ?: state.rootFolder?.id
|
||||
val uri = state.uploadUri
|
||||
val title = state.uploadTitle.trim()
|
||||
if (folderId == null || uri == null || title.isBlank()) {
|
||||
_uiState.value = state.copy(errorMessage = "Titel, Ordner und Bild sind erforderlich.")
|
||||
return
|
||||
}
|
||||
val visibilities = state.selectedVisibilityIds.toList()
|
||||
if (visibilities.isEmpty()) {
|
||||
_uiState.value = state.copy(errorMessage = "Mindestens eine Sichtbarkeit ist erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.uploadImage(
|
||||
folderId = folderId,
|
||||
title = title,
|
||||
visibilityIds = visibilities,
|
||||
selectedUsers = parseUsers(state.selectedUsersText),
|
||||
uri = uri,
|
||||
).onSuccess {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
uploadTitle = "",
|
||||
uploadUri = null,
|
||||
statusMessage = "Bild hochgeladen.",
|
||||
)
|
||||
loadImages(folderId)
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Bild konnte nicht hochgeladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadImages(folderId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.loadImages(folderId)
|
||||
.onSuccess { images ->
|
||||
_uiState.value = _uiState.value.copy(images = images)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Bilder konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFolder(folder: GalleryFolderDto?, id: Long?): GalleryFolderDto? {
|
||||
if (folder == null || id == null) return folder
|
||||
if (folder.id == id) return folder
|
||||
for (child in folder.children) {
|
||||
val found = findFolder(child, id)
|
||||
if (found != null) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseUsers(text: String): List<String> {
|
||||
return text.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.yourpart.nativeapp.feature.home
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DashboardWidgetTypeDto(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val endpoint: String,
|
||||
val description: String? = null,
|
||||
val orderId: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardConfigDto(
|
||||
val widgets: List<DashboardConfigWidgetDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardConfigWidgetDto(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val endpoint: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CalendarUpcomingEventDto(
|
||||
val id: Int,
|
||||
val titel: String,
|
||||
val datum: String,
|
||||
val beschreibung: String? = null,
|
||||
val categoryId: String? = null,
|
||||
val allDay: Boolean = false,
|
||||
val startTime: String? = null,
|
||||
val endDate: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CalendarBirthdayDto(
|
||||
val username: String,
|
||||
val hashedId: String,
|
||||
val date: String,
|
||||
val nextDate: String,
|
||||
val daysUntil: Int,
|
||||
val turningAge: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FalukantDashboardDto(
|
||||
val characterName: String = "",
|
||||
val titleLabelTr: String? = null,
|
||||
val nameWithoutTitle: String = "",
|
||||
val gender: String? = null,
|
||||
val age: Int? = null,
|
||||
val money: Double = 0.0,
|
||||
val unreadNotificationsCount: Int = 0,
|
||||
val childrenCount: Int = 0,
|
||||
val debtorsPrison: FalukantDebtorsPrisonDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FalukantDebtorsPrisonDto(
|
||||
val active: Boolean = false,
|
||||
val inDebtorsPrison: Boolean = false,
|
||||
val daysOverdue: Int? = null,
|
||||
val nextForcedAction: String? = null,
|
||||
val creditworthiness: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VocabDashboardDto(
|
||||
val courses: List<VocabDashboardCourseDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VocabDashboardCourseDto(
|
||||
val courseId: Int,
|
||||
val title: String,
|
||||
val currentLesson: VocabDashboardLessonDto? = null,
|
||||
val allLessonsCompleted: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VocabDashboardLessonDto(
|
||||
val id: Int,
|
||||
val lessonNumber: Int,
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.yourpart.nativeapp.feature.home
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Request
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class HomeDashboardData(
|
||||
val widgetTypes: List<DashboardWidgetTypeDto> = emptyList(),
|
||||
val config: DashboardConfigDto = DashboardConfigDto(),
|
||||
val upcomingEvents: List<CalendarUpcomingEventDto> = emptyList(),
|
||||
val birthdays: List<CalendarBirthdayDto> = emptyList(),
|
||||
val falukant: FalukantDashboardDto? = null,
|
||||
val vocab: VocabDashboardDto? = null,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun loadDashboard(): Result<HomeDashboardData> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val widgetTypes = loadWidgetTypes()
|
||||
val config = loadDashboardConfig()
|
||||
val upcomingEvents = loadUpcomingEvents()
|
||||
val birthdays = loadBirthdays()
|
||||
val falukant = loadFalukantDashboard()
|
||||
val vocab = loadVocabDashboard()
|
||||
|
||||
HomeDashboardData(
|
||||
widgetTypes = widgetTypes,
|
||||
config = config,
|
||||
upcomingEvents = upcomingEvents,
|
||||
birthdays = birthdays,
|
||||
falukant = falukant,
|
||||
vocab = vocab,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadWidgetTypes(): List<DashboardWidgetTypeDto> {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/dashboard/widgets")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<DashboardWidgetTypeDto>>(result.value)
|
||||
is ApiResult.Failure -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadDashboardConfig(): DashboardConfigDto {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/dashboard/config")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<DashboardConfigDto>(result.value)
|
||||
is ApiResult.Failure -> DashboardConfigDto()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadUpcomingEvents(): List<CalendarUpcomingEventDto> {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/calendar/widget/upcoming?limit=5")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<CalendarUpcomingEventDto>>(result.value)
|
||||
is ApiResult.Failure -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBirthdays(): List<CalendarBirthdayDto> {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/calendar/widget/birthdays?limit=5")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<CalendarBirthdayDto>>(result.value)
|
||||
is ApiResult.Failure -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadFalukantDashboard(): FalukantDashboardDto? {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/falukant/dashboard-widget")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<FalukantDashboardDto>(result.value)
|
||||
is ApiResult.Failure -> null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadVocabDashboard(): VocabDashboardDto? {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/vocab/dashboard-widget")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<VocabDashboardDto>(result.value)
|
||||
is ApiResult.Failure -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package de.yourpart.nativeapp.feature.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpServiceStatus
|
||||
import de.yourpart.nativeapp.ui.components.YpStatusChip
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
uiState: HomeUiState,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val dashboard = uiState.dashboard
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
YpInfoCard(
|
||||
title = "Dashboard",
|
||||
body = "Die native Startseite fasst Termine, Geburtstage, Falukant und Vokabeln zusammen.",
|
||||
)
|
||||
|
||||
if (uiState.errorMessage != null) {
|
||||
YpEmptyState(
|
||||
title = "Dashboard konnte nicht geladen werden",
|
||||
body = uiState.errorMessage,
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
YpStatusChip("Backend", YpServiceStatus.Connected)
|
||||
YpStatusChip("Daemon", YpServiceStatus.Connected)
|
||||
}
|
||||
|
||||
if (uiState.isLoading) {
|
||||
YpInfoCard(
|
||||
title = "Lade Daten",
|
||||
body = "Dashboard-Widgets werden aus dem Backend geladen.",
|
||||
)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = "Aktualisieren",
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
YpInfoCard(
|
||||
title = "Widgets",
|
||||
body = "Aktive Widgets: ${dashboard.config.widgets.size}, verfügbare Module: ${dashboard.widgetTypes.size}",
|
||||
)
|
||||
|
||||
if (dashboard.falukant != null) {
|
||||
YpInfoCard(
|
||||
title = "Falukant",
|
||||
body = buildString {
|
||||
append(dashboard.falukant.characterName)
|
||||
dashboard.falukant.age?.let { append(" · ").append(it).append(" Jahre") }
|
||||
append("\n")
|
||||
append("Geld: ").append(dashboard.falukant.money.toInt())
|
||||
append(" · Nachrichten: ").append(dashboard.falukant.unreadNotificationsCount)
|
||||
append(" · Kinder: ").append(dashboard.falukant.childrenCount)
|
||||
dashboard.falukant.debtorsPrison?.let {
|
||||
append("\n")
|
||||
append(if (it.inDebtorsPrison) "Schuldgefängnis aktiv" else "Kein Schuldgefängnis")
|
||||
it.daysOverdue?.let { days -> append(" · Überfällig: ").append(days).append(" Tage") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (dashboard.upcomingEvents.isNotEmpty()) {
|
||||
YpInfoCard(
|
||||
title = "Termine",
|
||||
body = "Nächste Termine aus dem Kalender.",
|
||||
)
|
||||
dashboard.upcomingEvents.take(3).forEach { event ->
|
||||
YpInfoCard(
|
||||
title = event.titel,
|
||||
body = listOfNotNull(
|
||||
event.datum,
|
||||
event.startTime?.let { "Start $it" },
|
||||
event.beschreibung,
|
||||
).joinToString(" · "),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
YpInfoCard(
|
||||
title = "Termine",
|
||||
body = "Keine bevorstehenden Termine gefunden.",
|
||||
)
|
||||
}
|
||||
|
||||
if (dashboard.birthdays.isNotEmpty()) {
|
||||
YpInfoCard(
|
||||
title = "Geburtstage",
|
||||
body = "Nächste Geburtstage aus dem Freundeskreis.",
|
||||
)
|
||||
dashboard.birthdays.take(3).forEach { birthday ->
|
||||
YpInfoCard(
|
||||
title = birthday.username,
|
||||
body = "${birthday.daysUntil} Tage bis ${birthday.nextDate} · wird ${birthday.turningAge}",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
YpInfoCard(
|
||||
title = "Geburtstage",
|
||||
body = "Keine sichtbaren Geburtstage gefunden.",
|
||||
)
|
||||
}
|
||||
|
||||
if (dashboard.vocab?.courses.orEmpty().isNotEmpty()) {
|
||||
YpInfoCard(
|
||||
title = "Vokabeln",
|
||||
body = "Aktive Kurse und die naechste Lektion.",
|
||||
)
|
||||
dashboard.vocab?.courses.orEmpty().take(3).forEach { course ->
|
||||
YpInfoCard(
|
||||
title = course.title,
|
||||
body = course.currentLesson?.let {
|
||||
"Nächste Lektion ${it.lessonNumber}: ${it.title}" + if (course.allLessonsCompleted) " · abgeschlossen" else ""
|
||||
} ?: "Keine Lektion gefunden",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
YpInfoCard(
|
||||
title = "Vokabeln",
|
||||
body = "Keine eingeschriebenen Kurse gefunden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.yourpart.nativeapp.feature.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class HomeUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val errorMessage: String? = null,
|
||||
val dashboard: HomeDashboardData = HomeDashboardData(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val homeRepository: HomeRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
homeRepository.loadDashboard().onSuccess { dashboard ->
|
||||
_uiState.value = HomeUiState(isLoading = false, dashboard = dashboard)
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Dashboard konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.yourpart.nativeapp.feature.legal
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton class LegalRepository @Inject constructor(private val executor: NetworkRequestExecutor, private val json: Json, private val config: AppConfig) {
|
||||
suspend fun sendContact(email: String, name: String, message: String): Result<Unit> = withContext(Dispatchers.IO) { runCatching {
|
||||
val body = json.encodeToString(mapOf("email" to email, "name" to name, "message" to message, "acceptDataSave" to true)).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/contact").post(body).build()
|
||||
when (executor.execute(request)) { is ApiResult.Success -> Unit; is ApiResult.Failure -> error("Kontaktanfrage konnte nicht gesendet werden.") }
|
||||
} }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.yourpart.nativeapp.feature.legal
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
@Composable fun LegalScreen(kind: String, onContact: (String, String, String) -> Unit) {
|
||||
val spacing = LocalYpSpacing.current; var email by remember { mutableStateOf("") }; var name by remember { mutableStateOf("") }; var message by remember { mutableStateOf("") }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { when (kind) {
|
||||
"imprint" -> YpInfoCard("Impressum", "Diensteanbieter: Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt, Deutschland. Kontakt: kontakt@your-part.de.")
|
||||
"privacy" -> YpInfoCard("Datenschutzerklärung", "YourPart verarbeitet Konto-, Kontakt-, Inhalts- und technische Nutzungsdaten zur Bereitstellung der Community. Betroffenenrechte, einschließlich Löschung, können über den Kontaktweg geltend gemacht werden. Die vollständige, versionierte Erklärung wird mit der Web-Anwendung gepflegt.")
|
||||
else -> { YpInfoCard("Kontakt und Moderation", "Meldungen in Chat, Forum, Profil, Gästebuch und Galerie gehen direkt an die Moderation. Für sonstige Anliegen, Datenschutzrechte und Kontolöschung nutze dieses Formular."); YpTextField(email, { email = it }, "E-Mail-Adresse"); YpTextField(name, { name = it }, "Name"); YpTextField(message, { message = it }, "Nachricht"); YpPrimaryButton("Nachricht senden", { onContact(email, name, message) }, Modifier.fillMaxWidth(), email.isNotBlank() && name.isNotBlank() && message.isNotBlank()) }
|
||||
} }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.yourpart.nativeapp.feature.legal
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@HiltViewModel class LegalViewModel @Inject constructor(private val repository: LegalRepository) : ViewModel() { fun contact(email: String, name: String, message: String) { viewModelScope.launch { repository.sendContact(email, name, message) } } }
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.yourpart.nativeapp.feature.match3
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable data class Match3LevelDto(val id: Long, val boardLayout: String = "", val boardWidth: Int = 8, val boardHeight: Int = 8, val moveLimit: Int = 20)
|
||||
@Serializable data class Match3CampaignDto(val id: Long, val levels: List<Match3LevelDto> = emptyList())
|
||||
@Serializable data class Match3CampaignsEnvelope(val success: Boolean, val data: List<Match3CampaignDto> = emptyList())
|
||||
@Serializable data class Match3ProgressPayload(val score: Int, val moves: Int, val time: Int, val stars: Int, val securityHash: String, val timestamp: Long)
|
||||
|
||||
@Singleton class Match3Repository @Inject constructor(private val executor: NetworkRequestExecutor, private val json: Json, private val config: AppConfig) {
|
||||
suspend fun firstLevel(): Result<Pair<Long, Match3LevelDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/match3/campaigns").get().build()
|
||||
val response = when (val result = executor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<Match3CampaignsEnvelope>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
val campaign = response.data.firstOrNull() ?: error("Keine Match3-Kampagne verfügbar.")
|
||||
campaign.id to (campaign.levels.firstOrNull() ?: error("Kein Match3-Level verfügbar."))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveProgress(campaignId: Long, level: Match3LevelDto, score: Int, moves: Int, stars: Int): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = Match3ProgressPayload(score, moves, 0, stars, progressHash(level, score, moves, stars), System.currentTimeMillis())
|
||||
val body = json.encodeToString(payload).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/match3/campaigns/$campaignId/levels/${level.id}/progress").post(body).build()
|
||||
when (executor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Match3-Fortschritt konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun progressHash(level: Match3LevelDto, score: Int, moves: Int, stars: Int): String {
|
||||
val data = "${level.id}|$score|$moves|$stars|true|${level.boardLayout}|${level.moveLimit}"
|
||||
var hash = 0
|
||||
data.forEach { hash = (hash shl 5) - hash + it.code }
|
||||
var saltedHash = 0
|
||||
"$data|YourPart3_Match3_Security_2024".forEach { saltedHash = (saltedHash shl 5) - hash + it.code }
|
||||
return kotlin.math.abs(saltedHash).toString(16)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.yourpart.nativeapp.feature.match3
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.changedToDown
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
private val tileColors = listOf(Color(0xFFE9724C), Color(0xFFFFB347), Color(0xFF4CA6A8), Color(0xFF789D4A), Color(0xFF7D75B8), Color(0xFFC95D93))
|
||||
|
||||
@Composable
|
||||
fun Match3Screen(state: Match3UiState, onTap: (Int) -> Unit, onRestart: () -> Unit) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard("Match3", "Native Canvas-Implementierung: zwei benachbarte Steine antippen, um sie zu tauschen.")
|
||||
Text("Punkte: ${state.score} · Züge: ${state.moves}/${state.moveLimit} · Aufgelöste Steine: ${state.matches}", style = MaterialTheme.typography.titleMedium)
|
||||
Match3Board(state.board, state.selected, onTap)
|
||||
Text(state.message, style = MaterialTheme.typography.bodyMedium)
|
||||
YpPrimaryButton("Level neu starten", onRestart, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Match3Board(board: List<Int>, selected: Int?, onTap: (Int) -> Unit) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 280.dp, max = 560.dp)
|
||||
.pointerInput(board, selected) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.changedToDown() } ?: continue
|
||||
val point = change.position
|
||||
val cell = size.width / 8f
|
||||
val col = (point.x / cell).toInt().coerceIn(0, 7)
|
||||
val row = (point.y / cell).toInt().coerceIn(0, 7)
|
||||
onTap(row * 8 + col)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
val cell = size.minDimension / 8f
|
||||
val origin = Offset((size.width - cell * 8) / 2f, (size.height - cell * 8) / 2f)
|
||||
board.forEachIndexed { index, tile ->
|
||||
val row = index / 8; val col = index % 8
|
||||
val center = Offset(origin.x + col * cell + cell / 2f, origin.y + row * cell + cell / 2f)
|
||||
drawCircle(tileColors[tile.coerceIn(0, tileColors.lastIndex)], cell * .40f, center)
|
||||
if (index == selected) drawCircle(Color.White, cell * .45f, center, style = androidx.compose.ui.graphics.drawscope.Stroke(width = cell * .06f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.yourpart.nativeapp.feature.match3
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val Size = 8
|
||||
private const val Empty = -1
|
||||
|
||||
data class Match3UiState(
|
||||
val board: List<Int> = newBoard(),
|
||||
val selected: Int? = null,
|
||||
val score: Int = 0,
|
||||
val moves: Int = 0,
|
||||
val moveLimit: Int = 20,
|
||||
val matches: Int = 0,
|
||||
val campaignId: Long? = null,
|
||||
val level: Match3LevelDto? = null,
|
||||
val message: String = "Wähle zwei benachbarte Steine.",
|
||||
val completed: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel class Match3ViewModel @Inject constructor(private val repository: Match3Repository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(Match3UiState())
|
||||
val state: StateFlow<Match3UiState> = _state.asStateFlow()
|
||||
init { loadLevel() }
|
||||
|
||||
fun tap(index: Int) {
|
||||
val state = _state.value
|
||||
if (state.completed || state.moves >= state.moveLimit) return
|
||||
val first = state.selected
|
||||
if (first == null) { _state.value = state.copy(selected = index, message = "Jetzt einen benachbarten Stein wählen."); return }
|
||||
if (!adjacent(first, index)) { _state.value = state.copy(selected = index, message = "Bitte nur benachbarte Steine tauschen."); return }
|
||||
val swapped = state.board.toMutableList().also { val tile = it[first]; it[first] = it[index]; it[index] = tile }
|
||||
val found = matches(swapped)
|
||||
if (found.isEmpty()) { _state.value = state.copy(selected = null, message = "Kein Match. Wähle einen neuen Zug."); return }
|
||||
val resolved = resolve(swapped, found)
|
||||
val scoreGain = found.size * 10
|
||||
val nextMoves = state.moves + 1
|
||||
val completed = nextMoves >= state.moveLimit
|
||||
_state.value = state.copy(board = resolved, selected = null, score = state.score + scoreGain, moves = nextMoves, matches = state.matches + found.size, completed = completed, message = if (completed) "Level beendet. Fortschritt wird gespeichert." else "+$scoreGain Punkte. Kaskade aufgelöst.")
|
||||
if (completed) saveProgress()
|
||||
}
|
||||
|
||||
fun restart() { _state.value = Match3UiState(moveLimit = _state.value.moveLimit, campaignId = _state.value.campaignId, level = _state.value.level) }
|
||||
|
||||
private fun loadLevel() = viewModelScope.launch {
|
||||
repository.firstLevel().onSuccess { (campaignId, level) ->
|
||||
_state.value = Match3UiState(moveLimit = level.moveLimit.coerceIn(5, 100), campaignId = campaignId, level = level, message = "Level geladen. Wähle zwei benachbarte Steine.")
|
||||
}.onFailure { _state.value = _state.value.copy(message = "Lokales Trainingsboard: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun saveProgress() {
|
||||
val state = _state.value; val campaign = state.campaignId ?: return; val level = state.level ?: return
|
||||
viewModelScope.launch {
|
||||
val stars = when { state.score >= 500 -> 3; state.score >= 250 -> 2; else -> 1 }
|
||||
repository.saveProgress(campaign, level, state.score, state.moves, stars).onSuccess { _state.value = _state.value.copy(message = "Level beendet und Fortschritt gespeichert.") }.onFailure { _state.value = _state.value.copy(message = "Level beendet. Fortschritt konnte nicht gespeichert werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun adjacent(a: Int, b: Int): Boolean = (a / Size == b / Size && kotlin.math.abs(a - b) == 1) || kotlin.math.abs(a - b) == Size
|
||||
private fun matches(board: List<Int>): Set<Int> {
|
||||
val result = mutableSetOf<Int>()
|
||||
for (row in 0 until Size) for (col in 0 until Size) {
|
||||
val index = row * Size + col; val tile = board[index]
|
||||
if (tile == Empty) continue
|
||||
if (col <= Size - 3 && board[index + 1] == tile && board[index + 2] == tile) { result += index; result += index + 1; result += index + 2 }
|
||||
if (row <= Size - 3 && board[index + Size] == tile && board[index + 2 * Size] == tile) { result += index; result += index + Size; result += index + 2 * Size }
|
||||
}
|
||||
return result
|
||||
}
|
||||
private fun resolve(initial: List<Int>, found: Set<Int>): List<Int> {
|
||||
val board = initial.toMutableList(); found.forEach { board[it] = Empty }
|
||||
for (col in 0 until Size) {
|
||||
val kept = (Size - 1 downTo 0).map { board[it * Size + col] }.filter { it != Empty }
|
||||
for (row in Size - 1 downTo 0) board[row * Size + col] = kept.getOrNull(Size - 1 - row) ?: Random.nextInt(6)
|
||||
}
|
||||
return board
|
||||
}
|
||||
private fun newBoard(): List<Int> {
|
||||
while (true) { val board = List(Size * Size) { Random.nextInt(6) }; if (matches(board).isEmpty()) return board }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.yourpart.nativeapp.feature.minigames
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
@Composable
|
||||
fun MinigamesScreen(onOpenMatch3: () -> Unit, onOpenTaxi: () -> Unit) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard("Minispiele", "Native Spiele werden beim Verlassen angehalten. Match3 speichert abgeschlossene Level, Taxi speichert den Zwischenstand.")
|
||||
Text("Match3", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Tausche benachbarte Steine und löse Reihen auf.")
|
||||
YpPrimaryButton("Match3 starten", onOpenMatch3, Modifier.fillMaxWidth())
|
||||
Text("Taxi", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Hole Fahrgäste ab, fahre sie zum Ziel und vermeide Kollisionen.")
|
||||
YpPrimaryButton("Taxi starten", onOpenTaxi, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.yourpart.nativeapp.feature.personal
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CalendarEventDto(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val categoryId: String = "personal",
|
||||
val startDate: String,
|
||||
val endDate: String? = null,
|
||||
val startTime: String? = null,
|
||||
val endTime: String? = null,
|
||||
val allDay: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CalendarEventPayload(
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val categoryId: String = "personal",
|
||||
val startDate: String,
|
||||
val endDate: String? = null,
|
||||
val startTime: String? = null,
|
||||
val endTime: String? = null,
|
||||
val allDay: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DiaryEntryDto(
|
||||
val id: Long,
|
||||
val text: String,
|
||||
val createdAt: String? = null,
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DiaryPageDto(
|
||||
val entries: List<DiaryEntryDto> = emptyList(),
|
||||
val totalPages: Int = 1,
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.yourpart.nativeapp.feature.personal
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PersonalRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
private val sessionStore: SessionStore,
|
||||
) {
|
||||
suspend fun loadEvents(startDate: String, endDate: String): Result<List<CalendarEventDto>> =
|
||||
get("/api/calendar/events?startDate=$startDate&endDate=$endDate")
|
||||
|
||||
suspend fun createEvent(payload: CalendarEventPayload): Result<CalendarEventDto> =
|
||||
send("POST", "/api/calendar/events", payload)
|
||||
|
||||
suspend fun updateEvent(id: Long, payload: CalendarEventPayload): Result<CalendarEventDto> =
|
||||
send("PUT", "/api/calendar/events/$id", payload)
|
||||
|
||||
suspend fun deleteEvent(id: Long): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
when (val result = requestExecutor.execute(
|
||||
Request.Builder().url("${appConfig.apiBaseUrl}/api/calendar/events/$id").delete().build(),
|
||||
)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadDiary(page: Int): Result<DiaryPageDto> = get("/api/socialnetwork/diary/$page")
|
||||
|
||||
suspend fun createDiary(text: String): Result<DiaryEntryDto> = diarySend("POST", "/api/socialnetwork/diary", text)
|
||||
|
||||
suspend fun updateDiary(id: Long, text: String): Result<DiaryEntryDto> =
|
||||
diarySend("PUT", "/api/socialnetwork/diary/$id", text)
|
||||
|
||||
suspend fun deleteDiary(id: Long): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val userId = sessionStore.currentSession?.user?.id ?: error("Keine aktive Sitzung.")
|
||||
val body = "{\"userId\":${json.encodeToString(userId)}}"
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
when (val result = requestExecutor.execute(
|
||||
Request.Builder().url("${appConfig.apiBaseUrl}/api/socialnetwork/diary/$id").delete(body).build(),
|
||||
)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> get(path: String): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
when (val result = requestExecutor.execute(Request.Builder().url("${appConfig.apiBaseUrl}$path").get().build())) {
|
||||
is ApiResult.Success -> json.decodeFromString<T>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> send(method: String, path: String, payload: CalendarEventPayload): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(payload).toRequestBody("application/json".toMediaType())
|
||||
val builder = Request.Builder().url("${appConfig.apiBaseUrl}$path")
|
||||
val request = if (method == "PUT") builder.put(body).build() else builder.post(body).build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<T>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> diarySend(method: String, path: String, text: String): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val userId = sessionStore.currentSession?.user?.id ?: error("Keine aktive Sitzung.")
|
||||
val body = "{\"userId\":${json.encodeToString(userId)},\"text\":${json.encodeToString(text)}}"
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val builder = Request.Builder().url("${appConfig.apiBaseUrl}$path")
|
||||
val request = if (method == "PUT") builder.put(body).build() else builder.post(body).build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<T>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package de.yourpart.nativeapp.feature.personal
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun PersonalScreen(
|
||||
state: PersonalUiState,
|
||||
onSelectTab: (PersonalTab) -> Unit,
|
||||
onPreviousMonth: () -> Unit,
|
||||
onNextMonth: () -> Unit,
|
||||
onUpdateEventTitle: (String) -> Unit,
|
||||
onUpdateEventDescription: (String) -> Unit,
|
||||
onUpdateEventDate: (String) -> Unit,
|
||||
onUpdateEventTime: (String) -> Unit,
|
||||
onToggleAllDay: () -> Unit,
|
||||
onSaveEvent: () -> Unit,
|
||||
onEditEvent: (CalendarEventDto) -> Unit,
|
||||
onDeleteEvent: () -> Unit,
|
||||
onCancelEventEdit: () -> Unit,
|
||||
onUpdateDiaryDraft: (String) -> Unit,
|
||||
onSaveDiary: () -> Unit,
|
||||
onEditDiary: (DiaryEntryDto) -> Unit,
|
||||
onDeleteDiary: () -> Unit,
|
||||
onCancelDiaryEdit: () -> Unit,
|
||||
onPreviousDiaryPage: () -> Unit,
|
||||
onNextDiaryPage: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
item { YpInfoCard("Persönliches", "Kalender und Tagebuch werden nativ mit deinem bestehenden Backend synchronisiert.") }
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
FilterChip(state.tab == PersonalTab.CALENDAR, { onSelectTab(PersonalTab.CALENDAR) }, { Text("Kalender") })
|
||||
FilterChip(state.tab == PersonalTab.DIARY, { onSelectTab(PersonalTab.DIARY) }, { Text("Tagebuch") })
|
||||
}
|
||||
}
|
||||
state.error?.let { error -> item { YpEmptyState("Nicht verfügbar", error) } }
|
||||
state.message?.let { message -> item { YpInfoCard("Hinweis", message) } }
|
||||
when (state.tab) {
|
||||
PersonalTab.CALENDAR -> calendarContent(
|
||||
state, onPreviousMonth, onNextMonth, onUpdateEventTitle, onUpdateEventDescription,
|
||||
onUpdateEventDate, onUpdateEventTime, onToggleAllDay, onSaveEvent, onEditEvent,
|
||||
onDeleteEvent, onCancelEventEdit,
|
||||
)
|
||||
PersonalTab.DIARY -> diaryContent(
|
||||
state, onUpdateDiaryDraft, onSaveDiary, onEditDiary, onDeleteDiary,
|
||||
onCancelDiaryEdit, onPreviousDiaryPage, onNextDiaryPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.calendarContent(
|
||||
state: PersonalUiState,
|
||||
onPreviousMonth: () -> Unit,
|
||||
onNextMonth: () -> Unit,
|
||||
onUpdateTitle: (String) -> Unit,
|
||||
onUpdateDescription: (String) -> Unit,
|
||||
onUpdateDate: (String) -> Unit,
|
||||
onUpdateTime: (String) -> Unit,
|
||||
onToggleAllDay: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onEdit: (CalendarEventDto) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
YpSecondaryButton("Zurück", onPreviousMonth)
|
||||
Text(state.month.toString(), style = MaterialTheme.typography.titleMedium)
|
||||
YpSecondaryButton("Weiter", onNextMonth)
|
||||
}
|
||||
}
|
||||
item {
|
||||
EventEditor(state.eventDraft, state.isSaving, onUpdateTitle, onUpdateDescription, onUpdateDate, onUpdateTime, onToggleAllDay, onSave, onDelete, onCancel)
|
||||
}
|
||||
if (!state.isLoading && state.events.isEmpty()) item { YpEmptyState("Keine Termine", "Für diesen Monat sind keine Termine vorhanden.") }
|
||||
items(state.events, key = { it.id }) { event ->
|
||||
Card(onClick = { onEdit(event) }, modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(LocalYpSpacing.current.md), verticalArrangement = Arrangement.spacedBy(LocalYpSpacing.current.xs)) {
|
||||
Text(event.title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(event.startDate + if (event.allDay) " · ganztägig" else " · ${event.startTime.orEmpty()}", color = YpColors.TextSecondary)
|
||||
event.description?.takeIf { it.isNotBlank() }?.let { Text(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventEditor(
|
||||
draft: EventDraft,
|
||||
saving: Boolean,
|
||||
onUpdateTitle: (String) -> Unit,
|
||||
onUpdateDescription: (String) -> Unit,
|
||||
onUpdateDate: (String) -> Unit,
|
||||
onUpdateTime: (String) -> Unit,
|
||||
onToggleAllDay: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(if (draft.id == null) "Neuer Termin" else "Termin bearbeiten", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(draft.title, onUpdateTitle, Modifier.fillMaxWidth(), label = { Text("Titel") }, singleLine = true)
|
||||
OutlinedTextField(draft.description, onUpdateDescription, Modifier.fillMaxWidth(), label = { Text("Beschreibung") }, minLines = 2)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
YpSecondaryButton("Datum: ${draft.date}", {
|
||||
val date = runCatching { LocalDate.parse(draft.date) }.getOrDefault(LocalDate.now())
|
||||
DatePickerDialog(context, { _, year, month, day -> onUpdateDate(LocalDate.of(year, month + 1, day).toString()) }, date.year, date.monthValue - 1, date.dayOfMonth).show()
|
||||
})
|
||||
if (!draft.allDay) YpSecondaryButton("Uhrzeit: ${draft.time}", {
|
||||
val time = runCatching { LocalTime.parse(draft.time) }.getOrDefault(LocalTime.of(9, 0))
|
||||
TimePickerDialog(context, { _, hour, minute -> onUpdateTime(String.format(Locale.ROOT, "%02d:%02d", hour, minute)) }, time.hour, time.minute, true).show()
|
||||
})
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Switch(checked = draft.allDay, onCheckedChange = { onToggleAllDay() })
|
||||
Text("Ganztägig")
|
||||
}
|
||||
YpPrimaryButton(if (draft.id == null) "Termin erstellen" else "Termin speichern", onSave, Modifier.fillMaxWidth(), enabled = !saving && draft.title.isNotBlank())
|
||||
if (draft.id != null) {
|
||||
YpSecondaryButton("Termin löschen", onDelete, Modifier.fillMaxWidth(), enabled = !saving)
|
||||
YpSecondaryButton("Bearbeitung abbrechen", onCancel, Modifier.fillMaxWidth(), enabled = !saving)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.diaryContent(
|
||||
state: PersonalUiState,
|
||||
onUpdateDraft: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onEdit: (DiaryEntryDto) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onPreviousPage: () -> Unit,
|
||||
onNextPage: () -> Unit,
|
||||
) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(LocalYpSpacing.current.xs)) {
|
||||
Text(if (state.editingDiaryId == null) "Neuer Tagebucheintrag" else "Tagebucheintrag bearbeiten", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(state.diaryDraft, onUpdateDraft, Modifier.fillMaxWidth(), label = { Text("Text") }, minLines = 5)
|
||||
YpPrimaryButton("Speichern", onSave, Modifier.fillMaxWidth(), enabled = !state.isSaving && state.diaryDraft.isNotBlank())
|
||||
if (state.editingDiaryId != null) {
|
||||
YpSecondaryButton("Eintrag löschen", onDelete, Modifier.fillMaxWidth(), enabled = !state.isSaving)
|
||||
YpSecondaryButton("Bearbeitung abbrechen", onCancel, Modifier.fillMaxWidth(), enabled = !state.isSaving)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!state.isLoading && state.diaryEntries.isEmpty()) item { YpEmptyState("Kein Tagebuch", "Es gibt noch keine Einträge.") }
|
||||
items(state.diaryEntries, key = { it.id }) { entry ->
|
||||
Card(onClick = { onEdit(entry) }, modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(LocalYpSpacing.current.md), verticalArrangement = Arrangement.spacedBy(LocalYpSpacing.current.xs)) {
|
||||
Text(entry.createdAt.orEmpty(), color = YpColors.TextSecondary, style = MaterialTheme.typography.labelMedium)
|
||||
Text(entry.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
YpSecondaryButton("Vorherige", onPreviousPage, enabled = state.diaryPage > 1)
|
||||
Text("Seite ${state.diaryPage}/${state.diaryTotalPages}")
|
||||
YpSecondaryButton("Nächste", onNextPage, enabled = state.diaryPage < state.diaryTotalPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.yourpart.nativeapp.feature.personal
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class PersonalTab { CALENDAR, DIARY }
|
||||
|
||||
data class EventDraft(
|
||||
val id: Long? = null,
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val date: String = LocalDate.now().toString(),
|
||||
val time: String = "09:00",
|
||||
val allDay: Boolean = false,
|
||||
)
|
||||
|
||||
data class PersonalUiState(
|
||||
val tab: PersonalTab = PersonalTab.CALENDAR,
|
||||
val isLoading: Boolean = true,
|
||||
val isSaving: Boolean = false,
|
||||
val month: YearMonth = YearMonth.now(),
|
||||
val events: List<CalendarEventDto> = emptyList(),
|
||||
val eventDraft: EventDraft = EventDraft(),
|
||||
val diaryEntries: List<DiaryEntryDto> = emptyList(),
|
||||
val diaryPage: Int = 1,
|
||||
val diaryTotalPages: Int = 1,
|
||||
val diaryDraft: String = "",
|
||||
val editingDiaryId: Long? = null,
|
||||
val message: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class PersonalViewModel @Inject constructor(
|
||||
private val repository: PersonalRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(PersonalUiState())
|
||||
val uiState: StateFlow<PersonalUiState> = _uiState.asStateFlow()
|
||||
|
||||
init { loadCalendar() }
|
||||
|
||||
fun selectTab(tab: PersonalTab) {
|
||||
_uiState.value = _uiState.value.copy(tab = tab, error = null, message = null)
|
||||
if (tab == PersonalTab.DIARY && _uiState.value.diaryEntries.isEmpty()) loadDiary(1)
|
||||
}
|
||||
|
||||
fun previousMonth() = changeMonth(-1)
|
||||
fun nextMonth() = changeMonth(1)
|
||||
fun updateEventTitle(value: String) = update { copy(eventDraft = eventDraft.copy(title = value)) }
|
||||
fun updateEventDescription(value: String) = update { copy(eventDraft = eventDraft.copy(description = value)) }
|
||||
fun updateEventDate(value: String) = update { copy(eventDraft = eventDraft.copy(date = value)) }
|
||||
fun updateEventTime(value: String) = update { copy(eventDraft = eventDraft.copy(time = value)) }
|
||||
fun toggleAllDay() = update { copy(eventDraft = eventDraft.copy(allDay = !eventDraft.allDay)) }
|
||||
fun updateDiaryDraft(value: String) = update { copy(diaryDraft = value) }
|
||||
|
||||
fun editEvent(event: CalendarEventDto) = update {
|
||||
copy(
|
||||
eventDraft = EventDraft(
|
||||
id = event.id,
|
||||
title = event.title,
|
||||
description = event.description.orEmpty(),
|
||||
date = event.startDate,
|
||||
time = event.startTime ?: "09:00",
|
||||
allDay = event.allDay,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelEventEdit() = update { copy(eventDraft = EventDraft()) }
|
||||
|
||||
fun saveEvent() {
|
||||
val draft = _uiState.value.eventDraft
|
||||
if (draft.title.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
update { copy(isSaving = true, error = null, message = null) }
|
||||
val payload = CalendarEventPayload(
|
||||
title = draft.title.trim(),
|
||||
description = draft.description.trim().ifBlank { null },
|
||||
startDate = draft.date,
|
||||
endDate = draft.date,
|
||||
startTime = draft.time.takeIf { !draft.allDay },
|
||||
endTime = draft.time.takeIf { !draft.allDay },
|
||||
allDay = draft.allDay,
|
||||
)
|
||||
val result = draft.id?.let { repository.updateEvent(it, payload) } ?: repository.createEvent(payload)
|
||||
result.onSuccess {
|
||||
update { copy(isSaving = false, eventDraft = EventDraft(), message = "Termin gespeichert.") }
|
||||
loadCalendar()
|
||||
}.onFailure(::fail)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteEvent() {
|
||||
val id = _uiState.value.eventDraft.id ?: return
|
||||
viewModelScope.launch {
|
||||
update { copy(isSaving = true, error = null) }
|
||||
repository.deleteEvent(id).onSuccess {
|
||||
update { copy(isSaving = false, eventDraft = EventDraft(), message = "Termin gelöscht.") }
|
||||
loadCalendar()
|
||||
}.onFailure(::fail)
|
||||
}
|
||||
}
|
||||
|
||||
fun editDiary(entry: DiaryEntryDto) = update { copy(editingDiaryId = entry.id, diaryDraft = entry.text) }
|
||||
fun cancelDiaryEdit() = update { copy(editingDiaryId = null, diaryDraft = "") }
|
||||
|
||||
fun saveDiary() {
|
||||
val state = _uiState.value
|
||||
val text = state.diaryDraft.trim()
|
||||
if (text.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
update { copy(isSaving = true, error = null, message = null) }
|
||||
val result = state.editingDiaryId?.let { repository.updateDiary(it, text) } ?: repository.createDiary(text)
|
||||
result.onSuccess {
|
||||
update { copy(isSaving = false, editingDiaryId = null, diaryDraft = "", message = "Tagebucheintrag gespeichert.") }
|
||||
loadDiary(state.diaryPage)
|
||||
}.onFailure(::fail)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteDiary() {
|
||||
val id = _uiState.value.editingDiaryId ?: return
|
||||
viewModelScope.launch {
|
||||
update { copy(isSaving = true, error = null) }
|
||||
repository.deleteDiary(id).onSuccess {
|
||||
update { copy(isSaving = false, editingDiaryId = null, diaryDraft = "", message = "Tagebucheintrag gelöscht.") }
|
||||
loadDiary(_uiState.value.diaryPage)
|
||||
}.onFailure(::fail)
|
||||
}
|
||||
}
|
||||
|
||||
fun previousDiaryPage() = loadDiary((_uiState.value.diaryPage - 1).coerceAtLeast(1))
|
||||
fun nextDiaryPage() = loadDiary((_uiState.value.diaryPage + 1).coerceAtMost(_uiState.value.diaryTotalPages))
|
||||
|
||||
private fun changeMonth(delta: Long) {
|
||||
update { copy(month = month.plusMonths(delta)) }
|
||||
loadCalendar()
|
||||
}
|
||||
|
||||
private fun loadCalendar() {
|
||||
val month = _uiState.value.month
|
||||
viewModelScope.launch {
|
||||
update { copy(isLoading = true, error = null) }
|
||||
repository.loadEvents(month.atDay(1).toString(), month.atEndOfMonth().toString())
|
||||
.onSuccess { events -> update { copy(isLoading = false, events = events.sortedBy { it.startDate + it.startTime.orEmpty() }) } }
|
||||
.onFailure(::fail)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadDiary(page: Int) {
|
||||
viewModelScope.launch {
|
||||
update { copy(isLoading = true, error = null) }
|
||||
repository.loadDiary(page).onSuccess { data ->
|
||||
update { copy(isLoading = false, diaryEntries = data.entries, diaryPage = page, diaryTotalPages = data.totalPages.coerceAtLeast(1)) }
|
||||
}.onFailure(::fail)
|
||||
}
|
||||
}
|
||||
|
||||
private fun update(reducer: PersonalUiState.() -> PersonalUiState) { _uiState.value = _uiState.value.reducer() }
|
||||
private fun fail(error: Throwable) { update { copy(isLoading = false, isSaving = false, error = error.message ?: "Vorgang fehlgeschlagen.") } }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.yourpart.nativeapp.feature.publiccontent
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data class BlogOwnerDto(val username: String = "")
|
||||
@Serializable data class BlogDto(val id: Long, val title: String, val description: String? = null, val visibility: String = "public", val owner: BlogOwnerDto? = null)
|
||||
@Serializable data class BlogPostDto(val id: Long, val title: String, val content: String, val createdAt: String? = null)
|
||||
@Serializable data class BlogPostsPageDto(val items: List<BlogPostDto> = emptyList(), val page: Int = 1, val pageSize: Int = 10, val total: Int = 0)
|
||||
|
||||
data class GuideSection(val heading: String, val paragraphs: List<String>)
|
||||
data class Guide(val slug: String, val title: String, val description: String, val category: String, val sections: List<GuideSection>)
|
||||
|
||||
val nativeGuides = listOf(
|
||||
Guide("falukant-wirtschaft", "Falukant-Wirtschaft", "Produktion, Lager und Verkauf verstehen.", "Falukant", listOf(
|
||||
GuideSection("Produktion und Lager", listOf("Produktion und Lager gehören zusammen. Beobachte Bestände, Kapazitäten und Nachfrage, bevor du neue Aufträge startest.")),
|
||||
GuideSection("Regionale Preise", listOf("Nicht jede Ware ist in jeder Region gleich viel wert. Vergleiche Preise, bevor du investierst oder verkaufst.")),
|
||||
)),
|
||||
Guide("vokabeltrainer-alltag", "Vokabeltrainer für den Alltag", "Lernen mit kurzen Einheiten und Wiederholung.", "Vokabeltrainer", listOf(
|
||||
GuideSection("Alltag statt Listen", listOf("Wörter bleiben besser hängen, wenn sie in typischen Situationen wie Familie, Einkaufen oder Arztbesuchen vorkommen.")),
|
||||
GuideSection("Wiederholung", listOf("Kurze regelmäßige Übungen und Wiederholung schaffen einen stabileren Wortschatz als seltene lange Sitzungen.")),
|
||||
)),
|
||||
Guide("bisaya-grundlagen", "Bisaya lernen: Grundlagen", "Einstieg für deutschsprachige Anfänger.", "Sprachen", listOf(
|
||||
GuideSection("Praktischer Einstieg", listOf("Beginne mit Begrüßungen, Familie, Essen und einfachen Fragen. Diese Themen helfen schnell in echten Situationen.")),
|
||||
GuideSection("Satzmuster", listOf("Satzmuster geben einzelnen Wörtern Kontext und helfen, neue Begriffe aktiv zu verwenden.")),
|
||||
)),
|
||||
Guide("yourpart-oeffentliche-inhalte", "Öffentliche Inhalte auf YourPart", "Was Besucher ohne Konto finden.", "YourPart", listOf(
|
||||
GuideSection("Öffentliche Bereiche", listOf("Ratgeber und Blogs erklären Community, Vokabeltrainer und Falukant, bevor ein Konto benötigt wird.")),
|
||||
)),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.yourpart.nativeapp.feature.publiccontent
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Request
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton class PublicContentRepository @Inject constructor(
|
||||
private val executor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val config: AppConfig,
|
||||
) {
|
||||
suspend fun blogs(): Result<List<BlogDto>> = get("/api/blog/blogs")
|
||||
suspend fun posts(blogId: Long): Result<BlogPostsPageDto> = get("/api/blog/blogs/$blogId/posts?page=1&pageSize=50")
|
||||
|
||||
private suspend inline fun <reified T> get(path: String): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
when (val response = executor.execute(Request.Builder().url("${config.apiBaseUrl}$path").get().build())) {
|
||||
is ApiResult.Success -> json.decodeFromString<T>(response.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(response.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.yourpart.nativeapp.feature.publiccontent
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun BlogScreen(state: BlogUiState, onOpen: (BlogDto) -> Unit, onBack: () -> Unit, onRefresh: () -> Unit) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
state.selected?.let { blog ->
|
||||
item { YpSecondaryButton("Zurück zu Blogs", onBack, Modifier.fillMaxWidth()) }
|
||||
item { YpInfoCard(blog.title, listOfNotNull(blog.owner?.username?.let { "Von $it" }, blog.description).joinToString("\n")) }
|
||||
state.error?.let { item { YpEmptyState("Blog nicht verfügbar", it) } }
|
||||
if (!state.loading && state.posts.isEmpty()) item { YpEmptyState("Keine Beiträge", "Dieser Blog enthält noch keine veröffentlichten Beiträge.") }
|
||||
items(state.posts, key = { it.id }) { post ->
|
||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(post.title, style = MaterialTheme.typography.titleMedium)
|
||||
post.createdAt?.let { Text(it, style = MaterialTheme.typography.labelMedium, color = YpColors.TextSecondary) }
|
||||
RichText(post.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
item { YpInfoCard("Blogs", "Öffentliche Blogs der Community. Bearbeitung bleibt im Web-MVP ausgeschlossen.") }
|
||||
item { YpSecondaryButton("Aktualisieren", onRefresh, Modifier.fillMaxWidth()) }
|
||||
state.error?.let { item { YpEmptyState("Blogs nicht verfügbar", it) } }
|
||||
if (!state.loading && state.blogs.isEmpty()) item { YpEmptyState("Keine Blogs", "Es sind keine öffentlichen Blogs verfügbar.") }
|
||||
items(state.blogs, key = { it.id }) { blog ->
|
||||
Card(onClick = { onOpen(blog) }, modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(blog.title, style = MaterialTheme.typography.titleMedium)
|
||||
blog.owner?.username?.let { Text("Von $it", color = YpColors.TextSecondary) }
|
||||
blog.description?.takeIf { it.isNotBlank() }?.let { Text(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GuideScreen() {
|
||||
val spacing = LocalYpSpacing.current
|
||||
var selectedSlug by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val selected = nativeGuides.firstOrNull { it.slug == selectedSlug }
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
if (selected == null) {
|
||||
item { YpInfoCard("Ratgeber", "Kuratiertes, nativ gerendertes Grundlagenwissen. Ein WebView wird nicht verwendet.") }
|
||||
items(nativeGuides, key = { it.slug }) { guide ->
|
||||
Card(onClick = { selectedSlug = guide.slug }, modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(guide.title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(guide.category, style = MaterialTheme.typography.labelMedium, color = YpColors.TextSecondary)
|
||||
Text(guide.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item { YpSecondaryButton("Zurück zu Ratgebern", { selectedSlug = null }, Modifier.fillMaxWidth()) }
|
||||
item { YpInfoCard(selected.title, selected.description) }
|
||||
selected.sections.forEach { section ->
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(section.heading, style = MaterialTheme.typography.titleMedium)
|
||||
section.paragraphs.forEach { Text(it, style = MaterialTheme.typography.bodyLarge) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Safe native fallback for the blog editor's limited HTML output. */
|
||||
@Composable
|
||||
private fun RichText(content: String) {
|
||||
val plainText = content
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
||||
.replace(Regex("<[^>]+>"), "")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.trim()
|
||||
Text(plainText, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.yourpart.nativeapp.feature.publiccontent
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class BlogUiState(val loading: Boolean = true, val blogs: List<BlogDto> = emptyList(), val selected: BlogDto? = null, val posts: List<BlogPostDto> = emptyList(), val error: String? = null)
|
||||
|
||||
@HiltViewModel class PublicContentViewModel @Inject constructor(private val repository: PublicContentRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(BlogUiState())
|
||||
val state: StateFlow<BlogUiState> = _state.asStateFlow()
|
||||
init { loadBlogs() }
|
||||
fun loadBlogs() = viewModelScope.launch {
|
||||
repository.blogs().onSuccess { _state.value = _state.value.copy(loading = false, blogs = it, error = null) }.onFailure(::fail)
|
||||
}
|
||||
fun open(blog: BlogDto) = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, selected = blog, posts = emptyList(), error = null)
|
||||
repository.posts(blog.id).onSuccess { _state.value = _state.value.copy(loading = false, posts = it.items) }.onFailure(::fail)
|
||||
}
|
||||
fun back() { _state.value = _state.value.copy(selected = null, posts = emptyList(), error = null) }
|
||||
private fun fail(error: Throwable) { _state.value = _state.value.copy(loading = false, error = error.message ?: "Inhalte konnten nicht geladen werden.") }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.yourpart.nativeapp.feature.push
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class PushSettingsDto(
|
||||
val enabled: Boolean = false,
|
||||
val chatEnabled: Boolean = true,
|
||||
val friendLoginEnabled: Boolean = true,
|
||||
val falukantEnabled: Boolean = true,
|
||||
val vocabReminderEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DeviceRequest(val token: String, val enabled: Boolean = true)
|
||||
|
||||
@Singleton
|
||||
class PushRepository @Inject constructor(
|
||||
private val executor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val config: AppConfig,
|
||||
) {
|
||||
suspend fun loadSettings(): Result<PushSettingsDto> = request("/api/push/settings", "GET", null) { payload ->
|
||||
json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
suspend fun updateSettings(settings: PushSettingsDto): Result<PushSettingsDto> = request("/api/push/settings", "PUT", json.encodeToString(settings)) { payload ->
|
||||
json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
suspend fun registerToken(token: String): Result<Unit> = request("/api/push/devices", "PUT", json.encodeToString(DeviceRequest(token))) { Unit }
|
||||
|
||||
private suspend fun <T> request(path: String, method: String, payload: String?, map: (String) -> T): Result<T> = runCatching {
|
||||
val builder = Request.Builder().url("${config.apiBaseUrl}$path")
|
||||
val body = payload?.toRequestBody(JSON_MEDIA_TYPE)
|
||||
when (method) {
|
||||
"GET" -> builder.get()
|
||||
"PUT" -> builder.put(requireNotNull(body))
|
||||
else -> error("Unsupported request method: $method")
|
||||
}
|
||||
when (val result = executor.execute(builder.build())) {
|
||||
is ApiResult.Success -> map(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.yourpart.nativeapp.feature.push
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun PushScreen(uiState: PushUiState, onEnable: () -> Unit, onUpdate: (PushSettingsDto) -> Unit) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val context = LocalContext.current
|
||||
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) onEnable()
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = spacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
item {
|
||||
YpInfoCard("Push-Benachrichtigungen", "Du bestimmst hier, welche wichtigen Ereignisse außerhalb der App angezeigt werden. Die Einstellung gilt pro Benutzerkonto und Gerät.")
|
||||
}
|
||||
if (!uiState.available) {
|
||||
item { YpEmptyState("In dieser Variante deaktiviert", "Push ist nur in der konfigurierten Produktions-Release-App verfügbar. Lokale und Staging-Builds senden keine Tokens.") }
|
||||
} else {
|
||||
uiState.errorMessage?.let { message -> item { YpEmptyState("Push nicht verfügbar", message) } }
|
||||
uiState.statusMessage?.let { message -> item { YpInfoCard("Gespeichert", message) } }
|
||||
item {
|
||||
PushSwitchCard("Push aktivieren", "Erlaubt Benachrichtigungen für dieses Konto auf diesem Gerät.", uiState.settings.enabled, uiState.isSaving) { enabled ->
|
||||
if (enabled && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
onUpdate(uiState.settings.copy(enabled = enabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
item { PushSwitchCard("Chats", "Neue Nachrichten im Chat.", uiState.settings.chatEnabled, uiState.isSaving || !uiState.settings.enabled) { onUpdate(uiState.settings.copy(chatEnabled = it)) } }
|
||||
item { PushSwitchCard("Freunde online", "Wenn bestätigte Freunde sich anmelden.", uiState.settings.friendLoginEnabled, uiState.isSaving || !uiState.settings.enabled) { onUpdate(uiState.settings.copy(friendLoginEnabled = it)) } }
|
||||
item { PushSwitchCard("Falukant", "Familien- und Statusereignisse aus Falukant.", uiState.settings.falukantEnabled, uiState.isSaving || !uiState.settings.enabled) { onUpdate(uiState.settings.copy(falukantEnabled = it)) } }
|
||||
item { PushSwitchCard("Vokabeltraining", "Erinnerungen für fällige Wiederholungen.", uiState.settings.vocabReminderEnabled, uiState.isSaving || !uiState.settings.enabled) { onUpdate(uiState.settings.copy(vocabReminderEnabled = it)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PushSwitchCard(title: String, body: String, checked: Boolean, saving: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium, color = YpColors.TextSecondary)
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = !saving)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.yourpart.nativeapp.feature.push
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.yourpart.nativeapp.BuildConfig
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PushUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val isSaving: Boolean = false,
|
||||
val settings: PushSettingsDto = PushSettingsDto(),
|
||||
val errorMessage: String? = null,
|
||||
val statusMessage: String? = null,
|
||||
val available: Boolean = BuildConfig.FEATURE_PUSH,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class PushViewModel @Inject constructor(
|
||||
private val repository: PushRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(PushUiState())
|
||||
val uiState: StateFlow<PushUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
if (BuildConfig.FEATURE_PUSH) refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadSettings()
|
||||
.onSuccess { _uiState.value = _uiState.value.copy(isLoading = false, settings = it) }
|
||||
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = it.message ?: "Push-Einstellungen konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun enableAfterPermission() = save(_uiState.value.settings.copy(enabled = true), registerToken = true)
|
||||
|
||||
fun update(settings: PushSettingsDto) = save(settings, registerToken = settings.enabled && !_uiState.value.settings.enabled)
|
||||
|
||||
fun registerRotatedToken(token: String) {
|
||||
if (!BuildConfig.FEATURE_PUSH) return
|
||||
viewModelScope.launch { repository.registerToken(token) }
|
||||
}
|
||||
|
||||
private fun save(settings: PushSettingsDto, registerToken: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, statusMessage = null)
|
||||
if (registerToken) {
|
||||
runCatching { FirebaseMessaging.getInstance().token.await() }
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = "FCM-Token konnte nicht erzeugt werden: ${error.message}")
|
||||
return@launch
|
||||
}
|
||||
.onSuccess { token ->
|
||||
repository.registerToken(token).onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = "Gerät konnte nicht registriert werden: ${error.message}")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
repository.updateSettings(settings)
|
||||
.onSuccess { _uiState.value = _uiState.value.copy(isSaving = false, settings = it, statusMessage = "Benachrichtigungseinstellungen gespeichert.") }
|
||||
.onFailure { error -> _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message ?: "Einstellungen konnten nicht gespeichert werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.yourpart.nativeapp.feature.push
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.app.PendingIntent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.yourpart.nativeapp.R
|
||||
import de.yourpart.nativeapp.MainActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class YourPartFirebaseMessagingService : FirebaseMessagingService() {
|
||||
@Inject lateinit var repository: PushRepository
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch { repository.registerToken(token) }
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, "YourPart Updates", NotificationManager.IMPORTANCE_DEFAULT))
|
||||
val notification = message.notification
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
.putExtra(MainActivity.EXTRA_NOTIFICATION_ROUTE, message.data["route"] ?: "home")
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
message.messageId?.hashCode() ?: 0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
manager.notify(message.messageId?.hashCode() ?: 0, NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(notification?.title ?: "YourPart")
|
||||
.setContentText(notification?.body ?: "Es gibt eine neue Benachrichtigung.")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build())
|
||||
}
|
||||
|
||||
private companion object { const val CHANNEL_ID = "yourpart_updates" }
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package de.yourpart.nativeapp.feature.settings
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class SettingsOptionDto(
|
||||
val id: Int,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsVisibilityDto(
|
||||
val id: Int? = null,
|
||||
val description: String = "Invisible",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsFieldDto(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val minAge: Int? = null,
|
||||
val gender: String? = null,
|
||||
val datatype: String,
|
||||
val unit: String? = null,
|
||||
val immutable: Boolean = false,
|
||||
val value: String? = null,
|
||||
val options: List<SettingsOptionDto> = emptyList(),
|
||||
val visibility: SettingsVisibilityDto = SettingsVisibilityDto(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsAccountDto(
|
||||
val username: String = "",
|
||||
val email: String? = null,
|
||||
val showinsearch: Boolean = false,
|
||||
val age: Int? = null,
|
||||
val isAdult: Boolean = false,
|
||||
val adultVerificationStatus: String = "none",
|
||||
val adultVerificationRequest: JsonElement? = null,
|
||||
val adultAccessEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsSectionRequest(
|
||||
val userid: String,
|
||||
val type: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsUpdateRequest(
|
||||
val userid: String,
|
||||
val settingId: Int,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsAccountRequest(
|
||||
val userId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsAccountUpdateRequest(
|
||||
val userId: String,
|
||||
val settings: SettingsAccountUpdatePayload,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsAccountUpdatePayload(
|
||||
val username: String? = null,
|
||||
val email: String? = null,
|
||||
val showinsearch: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsVisibilityUpdateRequest(
|
||||
val userParamTypeId: Int,
|
||||
val visibilityId: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsInterestAddRequest(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsInterestSetRequest(
|
||||
val interestid: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsLlmSaveRequest(
|
||||
val enabled: Boolean = true,
|
||||
val baseUrl: String = "",
|
||||
val model: String = "gpt-4o-mini",
|
||||
val apiKey: String = "",
|
||||
val clearKey: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsContactRequest(
|
||||
val email: String,
|
||||
val name: String,
|
||||
val message: String,
|
||||
val acceptDataSave: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsSectionData(
|
||||
val section: SettingsSectionKey,
|
||||
val fields: List<SettingsFieldDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsInterestTranslationValueDto(
|
||||
val value: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsInterestTranslationDto(
|
||||
val id: Int? = null,
|
||||
val translation: String = "",
|
||||
val language: Int? = null,
|
||||
val user_param_value: SettingsInterestTranslationValueDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsInterestDto(
|
||||
val id: Int,
|
||||
val name: String = "",
|
||||
val allowed: Boolean = false,
|
||||
val adultOnly: Boolean = false,
|
||||
val interest_translations: List<SettingsInterestTranslationDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsInterestsBundleDto(
|
||||
val possibleInterests: List<SettingsInterestDto> = emptyList(),
|
||||
val userInterests: List<SettingsInterestSelectionDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsInterestSelectionDto(
|
||||
val id: Int,
|
||||
val user_interest_type: SettingsInterestDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SettingsLlmSettingsDto(
|
||||
val enabled: Boolean = true,
|
||||
val baseUrl: String = "",
|
||||
val model: String = "gpt-4o-mini",
|
||||
val hasKey: Boolean = false,
|
||||
val keyLast4: String? = null,
|
||||
val keyStatus: String = "missing",
|
||||
)
|
||||
|
||||
enum class SettingsSectionKey(
|
||||
val apiType: String,
|
||||
val title: String,
|
||||
) {
|
||||
PERSONAL("personal", "Persönlich"),
|
||||
VIEW("view", "Ansicht"),
|
||||
SEXUALITY("sexuality", "Sexualität"),
|
||||
FLIRT("flirt", "Flirt"),
|
||||
ACCOUNT("account", "Account"),
|
||||
LANGUAGE_ASSISTANT("languageAssistant", "Sprachassistent"),
|
||||
INTERESTS("interests", "Interessen"),
|
||||
}
|
||||
|
||||
fun SettingsSectionKey.displayBody(): String = when (this) {
|
||||
SettingsSectionKey.PERSONAL -> "Persönliche Basisdaten, Sprache und Profilwerte."
|
||||
SettingsSectionKey.VIEW -> "Darstellungs- und Sichtbarkeitsregeln für dein Profil."
|
||||
SettingsSectionKey.SEXUALITY -> "Spezifische Einstellungen für den sensiblen Bereich."
|
||||
SettingsSectionKey.FLIRT -> "Flirt- und Matching-bezogene Einstellungen."
|
||||
SettingsSectionKey.ACCOUNT -> "Benutzername, E-Mail und grundlegende Account-Optionen."
|
||||
SettingsSectionKey.LANGUAGE_ASSISTANT -> "LLM- und Assistenten-Konfiguration aus dem Backend."
|
||||
SettingsSectionKey.INTERESTS -> "Deine Interessen und Vorschläge aus dem Backend."
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package de.yourpart.nativeapp.feature.settings
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.AuthSessionStore
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import de.yourpart.nativeapp.core.persistence.UserPreferencesStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SettingsRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val sessionStore: AuthSessionStore,
|
||||
private val userPreferencesStore: UserPreferencesStore,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun loadSection(section: SettingsSectionKey): Result<SettingsSectionData> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val userId = requireSessionUserId()
|
||||
val requestBody = json.encodeToString(
|
||||
SettingsSectionRequest(userid = userId, type = section.apiType),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/filter")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> SettingsSectionData(
|
||||
section = section,
|
||||
fields = json.decodeFromString<List<SettingsFieldDto>>(result.value),
|
||||
)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadAccount(): Result<SettingsAccountDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val userId = requireSessionUserId()
|
||||
val requestBody = json.encodeToString(
|
||||
SettingsAccountRequest(userId = userId),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/account")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<SettingsAccountDto>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadVisibilities(): Result<List<SettingsVisibilityDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/visibilities")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<SettingsVisibilityDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadInterests(): Result<SettingsInterestsBundleDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val possibleRequest = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/getpossibleinterests")
|
||||
.get()
|
||||
.build()
|
||||
val userRequest = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/getuserinterests")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val possibleInterests = when (val result = requestExecutor.execute(possibleRequest)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<SettingsInterestDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
val userInterests = when (val result = requestExecutor.execute(userRequest)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<SettingsInterestSelectionDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
|
||||
SettingsInterestsBundleDto(
|
||||
possibleInterests = possibleInterests,
|
||||
userInterests = userInterests,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateField(field: SettingsFieldDto, value: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val userId = requireSessionUserId()
|
||||
val requestBody = json.encodeToString(
|
||||
SettingsUpdateRequest(userid = userId, settingId = field.id, value = value),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/update")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
if (field.name == "language") {
|
||||
val languageCode = field.options.firstOrNull { it.id.toString() == value }?.value
|
||||
if (!languageCode.isNullOrBlank()) {
|
||||
userPreferencesStore.setLanguage(languageCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ApiResult.Failure -> throw IllegalStateException("Einstellung konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateVisibility(fieldId: Int, visibilityId: Int): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(
|
||||
SettingsVisibilityUpdateRequest(userParamTypeId = fieldId, visibilityId = visibilityId),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/update-visibility")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Sichtbarkeit konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateAccount(username: String, email: String, showInSearch: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val userId = requireSessionUserId()
|
||||
val requestBody = json.encodeToString(
|
||||
SettingsAccountUpdateRequest(
|
||||
userId = userId,
|
||||
settings = SettingsAccountUpdatePayload(
|
||||
username = username.trim(),
|
||||
email = email.trim(),
|
||||
showinsearch = showInSearch,
|
||||
),
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/set-account")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Account konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addInterest(name: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(
|
||||
SettingsInterestAddRequest(name = name.trim()),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/addinterest")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Interesse konnte nicht hinzugefügt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setInterest(interestId: Int): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(
|
||||
SettingsInterestSetRequest(interestid = interestId),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/setinterest")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Interesse konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeInterest(interestId: Int): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/removeinterest/$interestId")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Interesse konnte nicht entfernt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadLlmSettings(): Result<SettingsLlmSettingsDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/llm")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<SettingsLlmSettingsDto>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveLlmSettings(
|
||||
enabled: Boolean,
|
||||
baseUrl: String,
|
||||
model: String,
|
||||
apiKey: String,
|
||||
clearKey: Boolean,
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(
|
||||
SettingsLlmSaveRequest(
|
||||
enabled = enabled,
|
||||
baseUrl = baseUrl,
|
||||
model = model,
|
||||
apiKey = apiKey,
|
||||
clearKey = clearKey,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/settings/llm")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("LLM-Einstellungen konnten nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestAccountDeletion(email: String, name: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val message = buildString {
|
||||
append("Bitte um Löschung meines Accounts.\n\n")
|
||||
append("Username: ").append(name).append('\n')
|
||||
append("E-Mail: ").append(email).append('\n')
|
||||
append("Bitte bestätigt mir den Eingang dieser Anfrage.")
|
||||
}
|
||||
|
||||
val requestBody = json.encodeToString(
|
||||
SettingsContactRequest(
|
||||
email = email.trim(),
|
||||
name = name.trim(),
|
||||
message = message,
|
||||
acceptDataSave = true,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/contact")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Die Löschanfrage konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireSessionUserId(): String {
|
||||
return sessionStore.currentSession?.user?.id
|
||||
?: throw IllegalStateException("Keine aktive Sitzung gefunden.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
package de.yourpart.nativeapp.feature.settings
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
uiState: SettingsUiState,
|
||||
onSelectSection: (SettingsSectionKey) -> Unit,
|
||||
onSaveField: (SettingsFieldDto, String) -> Unit,
|
||||
onSaveVisibility: (Int, Int) -> Unit,
|
||||
onSaveAccount: (String, String, Boolean) -> Unit,
|
||||
onUpdateInterestQuery: (String) -> Unit,
|
||||
onAddInterestFromQuery: () -> Unit,
|
||||
onSelectInterest: (Int) -> Unit,
|
||||
onRemoveInterest: (Int) -> Unit,
|
||||
onSaveLlmSettings: (Boolean, String, String, String, Boolean) -> Unit,
|
||||
onRequestAccountDeletion: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = spacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Einstellungen",
|
||||
body = "Die native Settings-Seite liest die Backend-Definitionen direkt aus `/api/settings/*` und trennt Account, Sprache, Sichtbarkeit und Spezialbereiche voneinander.",
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text("Bereich", style = MaterialTheme.typography.titleMedium)
|
||||
SectionChipRow(
|
||||
selectedSection = uiState.selectedSection,
|
||||
onSelectSection = onSelectSection,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
uiState.statusMessage?.let { message ->
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Hinweis",
|
||||
body = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
uiState.errorMessage?.let { error ->
|
||||
item {
|
||||
YpEmptyState(
|
||||
title = "Einstellungen nicht verfügbar",
|
||||
body = error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (uiState.selectedSection) {
|
||||
SettingsSectionKey.ACCOUNT -> {
|
||||
item {
|
||||
AccountCard(
|
||||
account = uiState.account,
|
||||
isSaving = uiState.isSaving,
|
||||
onSaveAccount = onSaveAccount,
|
||||
)
|
||||
}
|
||||
item {
|
||||
AccountDeletionCard(
|
||||
account = uiState.account,
|
||||
isSaving = uiState.isSaving,
|
||||
onRequestAccountDeletion = onRequestAccountDeletion,
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsSectionKey.INTERESTS -> {
|
||||
item {
|
||||
InterestsCard(
|
||||
bundle = uiState.interests,
|
||||
query = uiState.interestQuery,
|
||||
isSaving = uiState.isSaving,
|
||||
onQueryChange = onUpdateInterestQuery,
|
||||
onAddInterestFromQuery = onAddInterestFromQuery,
|
||||
onSelectInterest = onSelectInterest,
|
||||
onRemoveInterest = onRemoveInterest,
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsSectionKey.LANGUAGE_ASSISTANT -> {
|
||||
item {
|
||||
LanguageAssistantCard(
|
||||
settings = uiState.llmSettings,
|
||||
isSaving = uiState.isSaving,
|
||||
onSaveLlmSettings = onSaveLlmSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (uiState.isLoading) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Lade Daten",
|
||||
body = "Die nativen Einstellungen werden vom Backend nachgeladen.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = uiState.selectedSection.title,
|
||||
body = uiState.selectedSection.displayBody(),
|
||||
)
|
||||
}
|
||||
items(uiState.sectionData.fields, key = { it.id }) { field ->
|
||||
SettingFieldCard(
|
||||
field = field,
|
||||
visibilities = uiState.visibilities,
|
||||
isSaving = uiState.isSaving,
|
||||
onSaveField = onSaveField,
|
||||
onSaveVisibility = onSaveVisibility,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "MVP-Grenze",
|
||||
body = "Interessen, LLM und Account-Löschanfrage sind jetzt nativ abgebildet. Der spätere Vollausbau kommt getrennt.",
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
YpSecondaryButton(
|
||||
label = "Aktualisieren",
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionChipRow(
|
||||
selectedSection: SettingsSectionKey,
|
||||
onSelectSection: (SettingsSectionKey) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val rows = listOf(
|
||||
listOf(SettingsSectionKey.PERSONAL, SettingsSectionKey.VIEW, SettingsSectionKey.ACCOUNT),
|
||||
listOf(SettingsSectionKey.INTERESTS, SettingsSectionKey.SEXUALITY, SettingsSectionKey.FLIRT),
|
||||
listOf(SettingsSectionKey.LANGUAGE_ASSISTANT),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
rows.forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
row.forEach { section ->
|
||||
FilterChip(
|
||||
selected = selectedSection == section,
|
||||
onClick = { onSelectSection(section) },
|
||||
label = {
|
||||
Text(
|
||||
section.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
selectedContainerColor = YpColors.PrimarySoft,
|
||||
labelColor = YpColors.TextPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountCard(
|
||||
account: SettingsAccountDto?,
|
||||
isSaving: Boolean,
|
||||
onSaveAccount: (String, String, Boolean) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
var username by rememberSaveable(account?.username) { mutableStateOf(account?.username.orEmpty()) }
|
||||
var email by rememberSaveable(account?.email) { mutableStateOf(account?.email.orEmpty()) }
|
||||
var showInSearch by rememberSaveable(account?.showinsearch) { mutableStateOf(account?.showinsearch ?: false) }
|
||||
|
||||
LaunchedEffect(account) {
|
||||
username = account?.username.orEmpty()
|
||||
email = account?.email.orEmpty()
|
||||
showInSearch = account?.showinsearch ?: false
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text("Account-Basis", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Benutzername, E-Mail und Sucheinstellung werden direkt gegen `/api/settings/set-account` gespeichert.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
YpTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = "Benutzername",
|
||||
)
|
||||
YpTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = "E-Mail",
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("In der Suche anzeigen", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Entspricht `showinsearch` aus dem Backend.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = showInSearch,
|
||||
onCheckedChange = { showInSearch = it },
|
||||
enabled = !isSaving,
|
||||
)
|
||||
}
|
||||
|
||||
if (account != null) {
|
||||
Text(
|
||||
buildString {
|
||||
append("Status: ")
|
||||
append(if (account.adultAccessEnabled) "erwachsen / freigeschaltet" else "eingeschränkt")
|
||||
account.age?.let { append(" · $it Jahre") }
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = if (isSaving) "Speichere..." else "Account speichern",
|
||||
onClick = { onSaveAccount(username, email, showInSearch) },
|
||||
enabled = !isSaving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountDeletionCard(
|
||||
account: SettingsAccountDto?,
|
||||
isSaving: Boolean,
|
||||
onRequestAccountDeletion: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceStrong),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text("Account-Löschung", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Es gibt im Backend keinen direkten Delete-Endpoint. Die native App sendet deshalb nur eine Support-Anfrage mit den vorhandenen Kontodaten.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
Text(
|
||||
"Benutzername: ${account?.username.orEmpty()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
Text(
|
||||
"E-Mail: ${account?.email.orEmpty().ifBlank { "nicht gesetzt" }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = if (isSaving) "Sende..." else "Löschanfrage senden",
|
||||
onClick = onRequestAccountDeletion,
|
||||
enabled = !isSaving && !account?.email.isNullOrBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InterestsCard(
|
||||
bundle: SettingsInterestsBundleDto,
|
||||
query: String,
|
||||
isSaving: Boolean,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onAddInterestFromQuery: () -> Unit,
|
||||
onSelectInterest: (Int) -> Unit,
|
||||
onRemoveInterest: (Int) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val selectedIds = remember(bundle.userInterests) {
|
||||
bundle.userInterests.mapNotNull { it.id }.toSet()
|
||||
}
|
||||
val suggestions = remember(bundle.possibleInterests, query, selectedIds) {
|
||||
bundle.possibleInterests.filter { interest ->
|
||||
!selectedIds.contains(interest.id) && interestMatchesQuery(interest, query)
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text("Interessen", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Die native App lädt vorgeschlagene Interessen und Deine Auswahl direkt über die Settings-API.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
if (bundle.userInterests.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text("Deine Interessen", style = MaterialTheme.typography.bodyLarge)
|
||||
bundle.userInterests.forEach { item ->
|
||||
val interest = item.user_interest_type
|
||||
if (interest != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(interestDisplayName(interest), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
interest.displayDescription(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
YpSecondaryButton(
|
||||
label = "Entfernen",
|
||||
onClick = { onRemoveInterest(interest.id) },
|
||||
enabled = !isSaving,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
YpTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
label = "Neues Interesse",
|
||||
)
|
||||
|
||||
YpPrimaryButton(
|
||||
label = if (isSaving) "Speichere..." else "Interesse hinzufügen",
|
||||
onClick = onAddInterestFromQuery,
|
||||
enabled = !isSaving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (suggestions.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text("Vorschläge", style = MaterialTheme.typography.bodyLarge)
|
||||
suggestions.take(8).forEach { interest ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(interestDisplayName(interest), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
interest.displayDescription(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
YpSecondaryButton(
|
||||
label = "Auswählen",
|
||||
onClick = { onSelectInterest(interest.id) },
|
||||
enabled = !isSaving,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LanguageAssistantCard(
|
||||
settings: SettingsLlmSettingsDto?,
|
||||
isSaving: Boolean,
|
||||
onSaveLlmSettings: (Boolean, String, String, String, Boolean) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
var enabled by rememberSaveable(settings?.enabled) { mutableStateOf(settings?.enabled ?: true) }
|
||||
var baseUrl by rememberSaveable(settings?.baseUrl) { mutableStateOf(settings?.baseUrl.orEmpty()) }
|
||||
var model by rememberSaveable(settings?.model) { mutableStateOf(settings?.model ?: "gpt-4o-mini") }
|
||||
var apiKey by rememberSaveable { mutableStateOf("") }
|
||||
var clearKey by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(settings) {
|
||||
enabled = settings?.enabled ?: true
|
||||
baseUrl = settings?.baseUrl.orEmpty()
|
||||
model = settings?.model ?: "gpt-4o-mini"
|
||||
apiKey = ""
|
||||
clearKey = false
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text("Language Assistant", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"LLM-Konfiguration, API-Key und Aktivierung folgen direkt dem Backend-Setup.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Aktiviert", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Steuert, ob der Assistent im Backend aktiv ist.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = { enabled = it },
|
||||
enabled = !isSaving,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
YpSecondaryButton(
|
||||
label = "Ollama",
|
||||
onClick = {
|
||||
enabled = true
|
||||
baseUrl = "http://127.0.0.1:11434/v1"
|
||||
model = "qwen2.5:3b-instruct"
|
||||
apiKey = ""
|
||||
clearKey = false
|
||||
},
|
||||
enabled = !isSaving,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "OpenAI",
|
||||
onClick = {
|
||||
enabled = true
|
||||
baseUrl = ""
|
||||
model = "gpt-4o-mini"
|
||||
apiKey = ""
|
||||
clearKey = false
|
||||
},
|
||||
enabled = !isSaving,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
YpTextField(
|
||||
value = baseUrl,
|
||||
onValueChange = { baseUrl = it },
|
||||
label = "Base URL",
|
||||
)
|
||||
YpTextField(
|
||||
value = model,
|
||||
onValueChange = { model = it },
|
||||
label = "Modell",
|
||||
)
|
||||
YpTextField(
|
||||
value = apiKey,
|
||||
onValueChange = { apiKey = it },
|
||||
label = "API Key",
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Schlüssel löschen", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Wenn aktiviert, wird der gespeicherte API-Key entfernt.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = clearKey,
|
||||
onCheckedChange = { clearKey = it },
|
||||
enabled = !isSaving,
|
||||
)
|
||||
}
|
||||
|
||||
settings?.let {
|
||||
Text(
|
||||
"Status: ${it.keyStatus}${it.keyLast4?.let { last4 -> " · ****$last4" } ?: ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = if (isSaving) "Speichere..." else "LLM speichern",
|
||||
onClick = { onSaveLlmSettings(enabled, baseUrl, model, apiKey, clearKey) },
|
||||
enabled = !isSaving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingFieldCard(
|
||||
field: SettingsFieldDto,
|
||||
visibilities: List<SettingsVisibilityDto>,
|
||||
isSaving: Boolean,
|
||||
onSaveField: (SettingsFieldDto, String) -> Unit,
|
||||
onSaveVisibility: (Int, Int) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
var draftValue by rememberSaveable(field.id, field.value) { mutableStateOf(field.value.orEmpty()) }
|
||||
var visibilityExpanded by remember { mutableStateOf(false) }
|
||||
var selectedVisibilityId by rememberSaveable(field.id, field.visibility.id) { mutableStateOf(field.visibility.id) }
|
||||
|
||||
LaunchedEffect(field.value) {
|
||||
draftValue = field.value.orEmpty()
|
||||
selectedVisibilityId = field.visibility.id
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceStrong),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text(field.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
buildString {
|
||||
append(field.datatype)
|
||||
field.unit?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
if (field.immutable) append(" · gesperrt")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
when (field.datatype) {
|
||||
"bool" -> {
|
||||
val boolValue = field.value.equals("true", ignoreCase = true)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("Aktiv", style = MaterialTheme.typography.bodyLarge)
|
||||
Switch(
|
||||
checked = boolValue,
|
||||
onCheckedChange = { onSaveField(field, if (it) "true" else "false") },
|
||||
enabled = !isSaving && !field.immutable,
|
||||
)
|
||||
}
|
||||
}
|
||||
"singleselect" -> {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedLabel = field.options.firstOrNull { it.id.toString() == draftValue }?.value ?: draftValue
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
) {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = selectedLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Auswahl") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true,
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
field.options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.value) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
draftValue = option.id.toString()
|
||||
onSaveField(field, option.id.toString())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
YpTextField(
|
||||
value = draftValue,
|
||||
onValueChange = { draftValue = it },
|
||||
label = "Wert",
|
||||
singleLine = field.datatype != "multiselect",
|
||||
)
|
||||
if (field.datatype == "multiselect") {
|
||||
Text(
|
||||
"Mehrfachauswahl wird als JSON-Liste gespeichert.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
YpSecondaryButton(
|
||||
label = if (isSaving) "Speichere..." else "Wert speichern",
|
||||
onClick = { onSaveField(field, draftValue) },
|
||||
enabled = !isSaving && !field.immutable,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (visibilities.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text("Sichtbarkeit", style = MaterialTheme.typography.bodyMedium)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = visibilityExpanded,
|
||||
onExpandedChange = { visibilityExpanded = !visibilityExpanded },
|
||||
) {
|
||||
val selectedVisibility = visibilities.firstOrNull { it.id == selectedVisibilityId }
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = selectedVisibility?.description ?: "Invisible",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Visibilität") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = visibilityExpanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true,
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = visibilityExpanded,
|
||||
onDismissRequest = { visibilityExpanded = false },
|
||||
) {
|
||||
visibilities.forEach { visibility ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(visibility.description) },
|
||||
onClick = {
|
||||
visibilityExpanded = false
|
||||
selectedVisibilityId = visibility.id
|
||||
if (visibility.id != null) {
|
||||
onSaveVisibility(field.id, visibility.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interestDisplayName(interest: SettingsInterestDto): String {
|
||||
return interest.interest_translations.firstOrNull()?.translation?.takeIf { it.isNotBlank() } ?: interest.name
|
||||
}
|
||||
|
||||
private fun SettingsInterestDto.displayDescription(): String {
|
||||
return buildString {
|
||||
append(if (adultOnly) "Erwachseneninteresse" else "Allgemein")
|
||||
if (!allowed) append(" · nicht freigegeben")
|
||||
}
|
||||
}
|
||||
|
||||
private fun interestMatchesQuery(interest: SettingsInterestDto, query: String): Boolean {
|
||||
if (query.isBlank()) return true
|
||||
val normalized = query.lowercase()
|
||||
if (interest.name.lowercase().contains(normalized)) return true
|
||||
return interest.interest_translations.any { translation ->
|
||||
translation.translation.lowercase().contains(normalized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package de.yourpart.nativeapp.feature.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SettingsUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val isSaving: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val selectedSection: SettingsSectionKey = SettingsSectionKey.PERSONAL,
|
||||
val sectionData: SettingsSectionData = SettingsSectionData(SettingsSectionKey.PERSONAL),
|
||||
val account: SettingsAccountDto? = null,
|
||||
val visibilities: List<SettingsVisibilityDto> = emptyList(),
|
||||
val interests: SettingsInterestsBundleDto = SettingsInterestsBundleDto(),
|
||||
val llmSettings: SettingsLlmSettingsDto? = null,
|
||||
val interestQuery: String = "",
|
||||
val statusMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val repository: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var activeLoadJob: Job? = null
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
loadSelectedSection()
|
||||
loadAccount()
|
||||
loadVisibilities()
|
||||
}
|
||||
|
||||
fun selectSection(section: SettingsSectionKey) {
|
||||
if (_uiState.value.selectedSection == section) {
|
||||
return
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(selectedSection = section, errorMessage = null)
|
||||
loadSelectedSection()
|
||||
}
|
||||
|
||||
fun saveField(field: SettingsFieldDto, value: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
||||
repository.updateField(field, value)
|
||||
.onSuccess { loadSection(_uiState.value.selectedSection) }
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Einstellung konnte nicht gespeichert werden.")
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isSaving = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveVisibility(fieldId: Int, visibilityId: Int) {
|
||||
viewModelScope.launch {
|
||||
repository.updateVisibility(fieldId, visibilityId)
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Sichtbarkeit konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveAccount(username: String, email: String, showInSearch: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
||||
repository.updateAccount(username, email, showInSearch)
|
||||
.onSuccess { loadAccount() }
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Account konnte nicht gespeichert werden.")
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isSaving = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateInterestQuery(query: String) {
|
||||
_uiState.value = _uiState.value.copy(interestQuery = query)
|
||||
}
|
||||
|
||||
fun addInterestFromQuery() {
|
||||
val query = _uiState.value.interestQuery.trim()
|
||||
if (query.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
||||
repository.addInterest(query)
|
||||
.onSuccess { loadInterests() }
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Interesse konnte nicht angelegt werden.")
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isSaving = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectInterest(interestId: Int) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
||||
repository.setInterest(interestId)
|
||||
.onSuccess { loadInterests() }
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Interesse konnte nicht gespeichert werden.")
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isSaving = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeInterest(interestId: Int) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
||||
repository.removeInterest(interestId)
|
||||
.onSuccess { loadInterests() }
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Interesse konnte nicht entfernt werden.")
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isSaving = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLlmSettings(
|
||||
enabled: Boolean,
|
||||
baseUrl: String,
|
||||
model: String,
|
||||
apiKey: String,
|
||||
clearKey: Boolean,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
||||
repository.saveLlmSettings(enabled, baseUrl, model, apiKey, clearKey)
|
||||
.onSuccess { loadLlmSettings() }
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "LLM-Einstellungen konnten nicht gespeichert werden.")
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isSaving = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestAccountDeletion() {
|
||||
val account = _uiState.value.account
|
||||
val email = account?.email.orEmpty()
|
||||
val username = account?.username.orEmpty()
|
||||
if (email.isBlank() || username.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = "Für die Löschanfrage werden Benutzername und E-Mail benötigt.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
||||
repository.requestAccountDeletion(email, username)
|
||||
.onSuccess {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
statusMessage = "Die Löschanfrage wurde an den Support gesendet. Die eigentliche Löschung erfolgt nicht automatisch in der App.",
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Die Löschanfrage konnte nicht gesendet werden.")
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(isSaving = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSelectedSection() {
|
||||
when (_uiState.value.selectedSection) {
|
||||
SettingsSectionKey.INTERESTS -> loadInterests()
|
||||
SettingsSectionKey.LANGUAGE_ASSISTANT -> loadLlmSettings()
|
||||
else -> loadSection(_uiState.value.selectedSection)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSection(section: SettingsSectionKey) {
|
||||
activeLoadJob?.cancel()
|
||||
activeLoadJob = viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadSection(section)
|
||||
.onSuccess { sectionData ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
sectionData = sectionData,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Einstellungen konnten nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInterests() {
|
||||
activeLoadJob?.cancel()
|
||||
activeLoadJob = viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadInterests()
|
||||
.onSuccess { bundle ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
interests = bundle,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Interessen konnten nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLlmSettings() {
|
||||
activeLoadJob?.cancel()
|
||||
activeLoadJob = viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadLlmSettings()
|
||||
.onSuccess { llmSettings ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
llmSettings = llmSettings,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "LLM-Einstellungen konnten nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAccount() {
|
||||
viewModelScope.launch {
|
||||
repository.loadAccount()
|
||||
.onSuccess { account ->
|
||||
_uiState.value = _uiState.value.copy(account = account)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = error.message ?: "Accountdaten konnten nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadVisibilities() {
|
||||
viewModelScope.launch {
|
||||
repository.loadVisibilities()
|
||||
.onSuccess { visibilities ->
|
||||
_uiState.value = _uiState.value.copy(visibilities = visibilities)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = error.message ?: "Sichtbarkeiten konnten nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.yourpart.nativeapp.feature.social
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class FriendshipUserDto(
|
||||
val username: String = "",
|
||||
val hashedId: String = "",
|
||||
val gender: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FriendshipDto(
|
||||
val id: Int,
|
||||
val user: FriendshipUserDto = FriendshipUserDto(),
|
||||
val accepted: Boolean = false,
|
||||
val denied: Boolean = false,
|
||||
val withdrawn: Boolean = false,
|
||||
val isInitiator: Boolean = false,
|
||||
)
|
||||
|
||||
data class FriendshipBuckets(
|
||||
val existing: List<FriendshipDto> = emptyList(),
|
||||
val pending: List<FriendshipDto> = emptyList(),
|
||||
val requested: List<FriendshipDto> = emptyList(),
|
||||
val rejected: List<FriendshipDto> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package de.yourpart.nativeapp.feature.social
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FriendsRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun loadFriendships(): Result<FriendshipBuckets> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/friendships?acceptedOnly=false")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val friendships = when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<FriendshipDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
|
||||
FriendshipBuckets(
|
||||
existing = friendships.filter { it.accepted },
|
||||
pending = friendships.filter { !it.accepted && !it.denied && !it.withdrawn && !it.isInitiator },
|
||||
requested = friendships.filter { !it.accepted && !it.denied && !it.withdrawn && it.isInitiator },
|
||||
rejected = friendships.filter { it.denied },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun endFriendship(friendUserId: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
sendFriendAction("/api/friendships/end", friendUserId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun acceptFriendship(friendUserId: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
sendFriendAction("/api/friendships/accept", friendUserId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun rejectFriendship(friendUserId: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
sendFriendAction("/api/friendships/reject", friendUserId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun withdrawRequest(friendUserId: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
sendFriendAction("/api/friendships/withdraw", friendUserId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendFriendAction(path: String, friendUserId: String) {
|
||||
val requestBody = json.encodeToString(mapOf("friendUserId" to friendUserId))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}$path")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Freundschaftsaktion fehlgeschlagen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package de.yourpart.nativeapp.feature.social
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun FriendsScreen(
|
||||
uiState: FriendsUiState,
|
||||
onOpenSearch: () -> Unit,
|
||||
onSelectTab: (FriendsTab) -> Unit,
|
||||
onEndFriendship: (String) -> Unit,
|
||||
onAcceptFriendship: (String) -> Unit,
|
||||
onRejectFriendship: (String) -> Unit,
|
||||
onWithdrawRequest: (String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val currentItems = when (uiState.selectedTab) {
|
||||
FriendsTab.EXISTING -> uiState.buckets.existing
|
||||
FriendsTab.PENDING -> uiState.buckets.pending
|
||||
FriendsTab.REQUESTED -> uiState.buckets.requested
|
||||
FriendsTab.REJECTED -> uiState.buckets.rejected
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Freunde",
|
||||
body = "Der native Social-Einstieg nutzt die vorhandenen Friendship-APIs und bildet die vier Zustände bestehend, offen, angefragt und abgelehnt ab.",
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
YpSecondaryButton(
|
||||
label = "Benutzersuche",
|
||||
onClick = onOpenSearch,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
FriendsTab.entries.forEach { tab ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedTab == tab,
|
||||
onClick = { onSelectTab(tab) },
|
||||
label = { Text(tab.title) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
selectedContainerColor = YpColors.PrimarySoft,
|
||||
labelColor = YpColors.TextPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.errorMessage?.let { error ->
|
||||
item {
|
||||
YpEmptyState(title = "Freunde nicht verfügbar", body = error)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Status",
|
||||
body = "Bestehend: ${uiState.buckets.existing.size} · Offen: ${uiState.buckets.pending.size} · Angefragt: ${uiState.buckets.requested.size} · Abgelehnt: ${uiState.buckets.rejected.size}",
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.isLoading) {
|
||||
item {
|
||||
YpInfoCard(title = "Lade Daten", body = "Die Freundesliste wird aktualisiert.")
|
||||
}
|
||||
} else if (currentItems.isEmpty()) {
|
||||
item {
|
||||
YpEmptyState(
|
||||
title = "Keine Einträge",
|
||||
body = "Für diesen Tab gibt es gerade keine Freundschaften.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(currentItems, key = { it.id }) { item ->
|
||||
FriendCard(
|
||||
friendship = item,
|
||||
onEndFriendship = onEndFriendship,
|
||||
onAcceptFriendship = onAcceptFriendship,
|
||||
onRejectFriendship = onRejectFriendship,
|
||||
onWithdrawRequest = onWithdrawRequest,
|
||||
selectedTab = uiState.selectedTab,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpSecondaryButton(label = "Aktualisieren", onClick = onRefresh, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FriendCard(
|
||||
friendship: FriendshipDto,
|
||||
onEndFriendship: (String) -> Unit,
|
||||
onAcceptFriendship: (String) -> Unit,
|
||||
onRejectFriendship: (String) -> Unit,
|
||||
onWithdrawRequest: (String) -> Unit,
|
||||
selectedTab: FriendsTab,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text(friendship.user.username, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
buildString {
|
||||
append(friendship.user.gender ?: "unbekannt")
|
||||
append(" · ")
|
||||
append(if (friendship.isInitiator) "von dir initiiert" else "von anderen initiiert")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
when (selectedTab) {
|
||||
FriendsTab.EXISTING -> YpSecondaryButton(
|
||||
label = "Beenden",
|
||||
onClick = { onEndFriendship(friendship.user.hashedId) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FriendsTab.PENDING -> {
|
||||
YpPrimaryButton(
|
||||
label = "Annehmen",
|
||||
onClick = { onAcceptFriendship(friendship.user.hashedId) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Ablehnen",
|
||||
onClick = { onRejectFriendship(friendship.user.hashedId) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
FriendsTab.REQUESTED -> YpSecondaryButton(
|
||||
label = "Zurückziehen",
|
||||
onClick = { onWithdrawRequest(friendship.user.hashedId) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FriendsTab.REJECTED -> YpPrimaryButton(
|
||||
label = "Annehmen",
|
||||
onClick = { onAcceptFriendship(friendship.user.hashedId) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user