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

This commit is contained in:
Torsten Schulz (local)
2026-07-21 09:08:15 +02:00
parent 75b52de282
commit 1ab9407e79
1400 changed files with 22278 additions and 485 deletions

6
android/native/.idea/AndroidProjectSystem.xml generated Executable file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

2001
android/native/.idea/caches/deviceStreaming.xml generated Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="YourPartNative.app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-07-10T13:18:31.238846181Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/torsten/.config/.android/avd/Medium_Tablet.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

13
android/native/.idea/deviceManager.xml generated Executable file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

30
android/native/.idea/gradle.xml generated Executable file
View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="/mnt/share/torsten/Programs/YourPart3/android/native" />
<option value="/mnt/share/torsten/Programs/YourPart3/android/native/app" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="/mnt/share/torsten/Programs/YourPart3/android/native" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="/mnt/share/torsten/Programs/YourPart3/android/native" />
<option value="/mnt/share/torsten/Programs/YourPart3/android/native/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

8
android/native/.idea/markdown.xml generated Executable file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

10
android/native/.idea/migrations.xml generated Executable file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="/mnt/share/torsten/Programs/YourPart3/android/native" />
</set>
</option>
</component>
</project>

9
android/native/.idea/misc.xml generated Executable file
View File

@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
android/native/.idea/runConfigurations.xml generated Executable file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

7
android/native/.idea/vcs.xml generated Executable file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
<mapping directory="/mnt/share/torsten/Programs/YourPart3" vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,76 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing /mnt/share/torsten/Programs/YourPart3/android/native/app/src/main/java/de/yourpart/nativeapp/MainActivity.kt:24:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 33 more

View File

@@ -0,0 +1,76 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing /mnt/share/torsten/Programs/YourPart3/android/native/app/src/main/java/de/yourpart/nativeapp/MainActivity.kt:24:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 33 more

View File

@@ -0,0 +1,76 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing /mnt/share/torsten/Programs/YourPart3/android/native/app/src/main/java/de/yourpart/nativeapp/MainActivity.kt:24:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 33 more

View File

@@ -0,0 +1,149 @@
kotlin version: 2.2.21
error message: Daemon compilation failed: null
java.lang.Exception
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:236)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:155)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:110)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:31)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin: class-attributes.tab, source-to-classes.tab, internal-name-to-source.tab
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218)
at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55)
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
... 3 more
Caused by: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin: class-attributes.tab, source-to-classes.tab, internal-name-to-source.tab
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:205)
... 22 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 43 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 44 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 44 more

View File

@@ -0,0 +1,150 @@
kotlin version: 2.2.21
error message: Daemon compilation failed: null
java.lang.Exception
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:236)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:155)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:110)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:31)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin: class-attributes.tab, proto.tab
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218)
at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55)
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
... 3 more
Caused by: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin: class-attributes.tab, proto.tab
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:205)
... 22 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 43 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 43 more
Suppressed: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/inputs: source-to-output.tab
... 25 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/inputs/source-to-output.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 44 more

View File

@@ -0,0 +1,153 @@
kotlin version: 2.2.21
error message: Daemon compilation failed: null
java.lang.Exception
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:236)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:155)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:110)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:31)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin: class-fq-name-to-source.tab, proto.tab
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218)
at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55)
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
... 3 more
Caused by: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin: class-fq-name-to-source.tab, proto.tab
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:205)
... 22 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 43 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/proto.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 43 more
Suppressed: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/lookups: lookups.tab
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.LookupStorage.close(LookupStorage.kt:155)
... 23 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 25 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/compileLocalDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 45 more

View File

@@ -0,0 +1,128 @@
kotlin version: 2.2.21
error message: Daemon compilation failed: null
java.lang.Exception
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:236)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:155)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:110)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:31)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.lang.AssertionError: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin: class-fq-name-to-source.tab
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:218)
at org.jetbrains.kotlin.incremental.IncrementalCachesManager.close(IncrementalCachesManager.kt:55)
at kotlin.io.CloseableKt.closeFinally(Closeable.kt:46)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:293)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
... 3 more
Caused by: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin: class-fq-name-to-source.tab
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.com.google.common.io.Closer.close(Closer.java:205)
... 22 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.close(PersistentStorage.kt:124)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 24 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/jvm/kotlin/class-fq-name-to-source.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 43 more
Suppressed: java.lang.Exception: Could not close incremental caches in /mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/lookups: lookups.tab
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:95)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.close(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.LookupStorage.close(LookupStorage.kt:155)
... 23 more
Suppressed: java.lang.IllegalStateException: Storage for [/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab] is already registered
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:410)
at org.jetbrains.kotlin.com.intellij.util.io.PagedFileStorage.<init>(PagedFileStorage.java:72)
at org.jetbrains.kotlin.com.intellij.util.io.ResizeableMappedFile.<init>(ResizeableMappedFile.java:68)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentBTreeEnumerator.<init>(PersistentBTreeEnumerator.java:128)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentEnumerator.createDefaultEnumerator(PersistentEnumerator.java:52)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:165)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.<init>(PersistentMapImpl.java:140)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.buildImplementation(PersistentMapBuilder.java:88)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapBuilder.build(PersistentMapBuilder.java:71)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:46)
at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.<init>(PersistentHashMap.java:72)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.createMap(LazyStorage.kt:62)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.getStorageOrCreateNew(LazyStorage.kt:59)
at org.jetbrains.kotlin.incremental.storage.LazyStorage.set(LazyStorage.kt:80)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.applyChanges(InMemoryStorage.kt:108)
at org.jetbrains.kotlin.incremental.storage.AppendableInMemoryStorage.applyChanges(InMemoryStorage.kt:179)
at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.close(InMemoryStorage.kt:136)
at org.jetbrains.kotlin.incremental.storage.AppendableSetBasicMap.close(BasicMap.kt:157)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner$close$1.invoke(BasicMapsOwner.kt:53)
at org.jetbrains.kotlin.incremental.storage.BasicMapsOwner.forEachMapSafe(BasicMapsOwner.kt:87)
... 25 more
Suppressed: java.lang.Exception: Storage[/mnt/share/torsten/Programs/YourPart3/android/native/app/build/kotlin/kaptGenerateStubsLocalDebugKotlin/cacheable/caches-jvm/lookups/lookups.tab] registration stack trace
at org.jetbrains.kotlin.com.intellij.util.io.FilePageCache.registerPagedFileStorage(FilePageCache.java:437)
... 45 more

24
android/native/README.md Normal file → Executable file
View File

@@ -13,6 +13,7 @@ Native Android-App fuer YourPart als paralleler Aufbau zur bestehenden Capacitor
- compileSdk/targetSdk: 36
- Flavors: `local`, `staging`, `production`
- Build Types: `debug`, `release`
- Feature Flags im Build: `FEATURE_ADMIN`, `FEATURE_ADULT`, `FEATURE_3D`, `FEATURE_MINIGAMES`, `FEATURE_PUSH`
## Kommandos
@@ -32,3 +33,26 @@ Der `local` Flavor nutzt:
- API: `http://10.0.2.2:2020`
- Socket.IO: `http://10.0.2.2:2020`
- Daemon: `ws://10.0.2.2:2021`
## Security
- Debug erlaubt Cleartext nur fuer `10.0.2.2` und `localhost`.
- Release verbietet Cleartext komplett.
- Release-Builds pruefen automatisch, dass keine lokalen oder unverschluesselten URLs in `staging` oder `production` verwendet werden.
## Firebase Push
1. Die registrierte Produktions-App `de.yourpart.nativeapp` als
`app/google-services.json` ablegen. Die Beispieldatei
`app/google-services.json.example` ist kein gültiger Ersatz.
2. Push ist ausschließlich in `productionRelease` aktiviert. `local` und `staging`
verwenden abweichende Paketnamen und bleiben absichtlich pushfrei, bis dafür eigene
Firebase-Apps registriert sind.
3. Im Backend eine Firebase-Service-Account-Datei außerhalb des Repositories bereitstellen
und `FIREBASE_SERVICE_ACCOUNT_PATH=/sicherer/pfad/firebase-service-account.json`
setzen. Alternativ kann `FIREBASE_SERVICE_ACCOUNT_JSON` mit dem JSON-Inhalt gesetzt
werden. Ohne eine dieser Variablen werden keine Pushes versendet, die App und die API
bleiben aber funktionsfähig.
Das Google-Services-Plugin wird nur für `productionRelease` mit vorhandener echter
Konfigurationsdatei angewendet; lokale Builds bleiben dadurch reproduzierbar.

154
android/native/app/build.gradle.kts Normal file → Executable file
View 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")
}

View 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"
}

View 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
View File

View 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')"
]
}
}

View File

@@ -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()
}
}

View File

@@ -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" }
}

View File

@@ -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()
}
}

View 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
View 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>

View 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"
}
}

View 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,
)

View File

@@ -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)
}

View File

@@ -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"
}
}

View File

@@ -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,
)
}
}

View 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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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>
}

View File

@@ -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
}
}

View File

@@ -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,
)
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(),
)
}
}
}

View File

@@ -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)
}

View File

@@ -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,
)

View File

@@ -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)
}

View File

@@ -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,
)

View File

@@ -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"
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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,
)

View File

@@ -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()) }
}

View File

@@ -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() }
}
}
}

View File

@@ -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.") }
}
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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) } }
}

View File

@@ -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,
)

View File

@@ -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.",
)
}
}

View File

@@ -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()
}
}

View File

@@ -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,
)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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,
)

View File

@@ -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(),
)

View File

@@ -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)
}
}
}
}

View File

@@ -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.")
}
}
}
}

View File

@@ -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

View File

@@ -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.")
}
}
}
}

View File

@@ -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()

View File

@@ -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",
)

View File

@@ -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 = "",
)

View File

@@ -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)
}
}
}
}

View File

@@ -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()

View File

@@ -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")

View File

@@ -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>,
)

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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() }
}
}

View File

@@ -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 = "",
)

View File

@@ -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
}
}
}

View File

@@ -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.",
)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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.") }
} }
}

View File

@@ -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()) }
} }
}

View File

@@ -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) } } }

View File

@@ -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)
}
}

View File

@@ -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))
}
}
}

View File

@@ -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 }
}

View File

@@ -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())
}
}

View File

@@ -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,
)

Some files were not shown because too many files have changed in this diff Show More