Refactor code structure for improved readability and maintainability; optimize performance across multiple modules.
All checks were successful
Deploy to production / deploy (push) Successful in 2m56s
All checks were successful
Deploy to production / deploy (push) Successful in 2m56s
This commit is contained in:
6
android/native/.idea/AndroidProjectSystem.xml
generated
Executable file
6
android/native/.idea/AndroidProjectSystem.xml
generated
Executable 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
2001
android/native/.idea/caches/deviceStreaming.xml
generated
Executable file
File diff suppressed because it is too large
Load Diff
18
android/native/.idea/deploymentTargetSelector.xml
generated
Executable file
18
android/native/.idea/deploymentTargetSelector.xml
generated
Executable 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
13
android/native/.idea/deviceManager.xml
generated
Executable 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
30
android/native/.idea/gradle.xml
generated
Executable 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
8
android/native/.idea/markdown.xml
generated
Executable 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
10
android/native/.idea/migrations.xml
generated
Executable 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
9
android/native/.idea/misc.xml
generated
Executable 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
17
android/native/.idea/runConfigurations.xml
generated
Executable 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
7
android/native/.idea/vcs.xml
generated
Executable 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>
|
||||
76
android/native/.kotlin/errors/errors-1783688579715.log
Executable file
76
android/native/.kotlin/errors/errors-1783688579715.log
Executable 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
|
||||
|
||||
|
||||
76
android/native/.kotlin/errors/errors-1783688926385.log
Executable file
76
android/native/.kotlin/errors/errors-1783688926385.log
Executable 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
|
||||
|
||||
|
||||
76
android/native/.kotlin/errors/errors-1783689039380.log
Executable file
76
android/native/.kotlin/errors/errors-1783689039380.log
Executable 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
|
||||
|
||||
|
||||
149
android/native/.kotlin/errors/errors-1784193658070.log
Normal file
149
android/native/.kotlin/errors/errors-1784193658070.log
Normal 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
|
||||
|
||||
|
||||
150
android/native/.kotlin/errors/errors-1784193658098.log
Normal file
150
android/native/.kotlin/errors/errors-1784193658098.log
Normal 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
|
||||
|
||||
|
||||
153
android/native/.kotlin/errors/errors-1784193702998.log
Normal file
153
android/native/.kotlin/errors/errors-1784193702998.log
Normal 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
|
||||
|
||||
|
||||
128
android/native/.kotlin/errors/errors-1784193703057.log
Normal file
128
android/native/.kotlin/errors/errors-1784193703057.log
Normal 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
24
android/native/README.md
Normal file → Executable 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
154
android/native/app/build.gradle.kts
Normal file → Executable file
@@ -4,10 +4,20 @@ plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.google.dagger.hilt.android")
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
// Only the production release uses the Firebase app registered as de.yourpart.nativeapp.
|
||||
// Local/staging builds have different application IDs and deliberately stay push-free.
|
||||
val isProductionReleaseBuild = gradle.startParameter.taskNames.any {
|
||||
it.contains("productionrelease", ignoreCase = true)
|
||||
}
|
||||
if (isProductionReleaseBuild && file("google-services.json").isFile) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
|
||||
val gitShaProvider = providers.exec {
|
||||
workingDir = rootDir.resolve("../..")
|
||||
commandLine("git", "rev-parse", "--short=12", "HEAD")
|
||||
@@ -15,6 +25,34 @@ val gitShaProvider = providers.exec {
|
||||
|
||||
val gitSha: String = runCatching { gitShaProvider.get() }.getOrDefault("dev")
|
||||
|
||||
data class EnvironmentConfig(
|
||||
val apiBaseUrl: String,
|
||||
val socketIoUrl: String,
|
||||
val daemonSocketUrl: String,
|
||||
val cleartextAllowed: Boolean,
|
||||
)
|
||||
|
||||
val environmentConfigs = mapOf(
|
||||
"local" to EnvironmentConfig(
|
||||
apiBaseUrl = "http://10.0.2.2:2020",
|
||||
socketIoUrl = "http://10.0.2.2:2020",
|
||||
daemonSocketUrl = "ws://10.0.2.2:2021",
|
||||
cleartextAllowed = true,
|
||||
),
|
||||
"staging" to EnvironmentConfig(
|
||||
apiBaseUrl = "https://staging.your-part.de",
|
||||
socketIoUrl = "https://staging.your-part.de",
|
||||
daemonSocketUrl = "wss://staging.your-part.de",
|
||||
cleartextAllowed = false,
|
||||
),
|
||||
"production" to EnvironmentConfig(
|
||||
apiBaseUrl = "https://www.your-part.de",
|
||||
socketIoUrl = "https://www.your-part.de",
|
||||
daemonSocketUrl = "wss://www.your-part.de",
|
||||
cleartextAllowed = false,
|
||||
),
|
||||
)
|
||||
|
||||
android {
|
||||
namespace = "de.yourpart.nativeapp"
|
||||
compileSdk = 36
|
||||
@@ -35,23 +73,32 @@ android {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".local"
|
||||
versionNameSuffix = "-local"
|
||||
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:2020\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"http://10.0.2.2:2020\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"ws://10.0.2.2:2021\"")
|
||||
val cfg = environmentConfigs.getValue(name)
|
||||
buildConfigField("String", "ENVIRONMENT", "\"$name\"")
|
||||
buildConfigField("String", "API_BASE_URL", "\"${cfg.apiBaseUrl}\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"${cfg.socketIoUrl}\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"${cfg.daemonSocketUrl}\"")
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", cfg.cleartextAllowed.toString())
|
||||
}
|
||||
create("staging") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".staging"
|
||||
versionNameSuffix = "-staging"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://staging.your-part.de\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"https://staging.your-part.de\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"wss://staging.your-part.de\"")
|
||||
val cfg = environmentConfigs.getValue(name)
|
||||
buildConfigField("String", "ENVIRONMENT", "\"$name\"")
|
||||
buildConfigField("String", "API_BASE_URL", "\"${cfg.apiBaseUrl}\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"${cfg.socketIoUrl}\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"${cfg.daemonSocketUrl}\"")
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", cfg.cleartextAllowed.toString())
|
||||
}
|
||||
create("production") {
|
||||
dimension = "environment"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://www.your-part.de\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"https://www.your-part.de\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"wss://www.your-part.de\"")
|
||||
val cfg = environmentConfigs.getValue(name)
|
||||
buildConfigField("String", "ENVIRONMENT", "\"$name\"")
|
||||
buildConfigField("String", "API_BASE_URL", "\"${cfg.apiBaseUrl}\"")
|
||||
buildConfigField("String", "SOCKET_IO_URL", "\"${cfg.socketIoUrl}\"")
|
||||
buildConfigField("String", "DAEMON_SOCKET_URL", "\"${cfg.daemonSocketUrl}\"")
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", cfg.cleartextAllowed.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +106,21 @@ android {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
buildConfigField("boolean", "IS_RELEASE_BUILD", "false")
|
||||
buildConfigField("boolean", "FEATURE_ADMIN", "false")
|
||||
buildConfigField("boolean", "FEATURE_ADULT", "true")
|
||||
buildConfigField("boolean", "FEATURE_3D", "false")
|
||||
buildConfigField("boolean", "FEATURE_MINIGAMES", "false")
|
||||
buildConfigField("boolean", "FEATURE_PUSH", "false")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
buildConfigField("boolean", "IS_RELEASE_BUILD", "true")
|
||||
buildConfigField("boolean", "FEATURE_ADMIN", "false")
|
||||
buildConfigField("boolean", "FEATURE_ADULT", "false")
|
||||
buildConfigField("boolean", "FEATURE_3D", "false")
|
||||
buildConfigField("boolean", "FEATURE_MINIGAMES", "false")
|
||||
buildConfigField("boolean", "FEATURE_PUSH", "true")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
@@ -74,6 +133,29 @@ android {
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
managedDevices {
|
||||
devices {
|
||||
create<com.android.build.api.dsl.ManagedVirtualDevice>("mediumPhone") {
|
||||
device = "Medium Phone"
|
||||
apiLevel = 34
|
||||
systemImageSource = "google"
|
||||
}
|
||||
create<com.android.build.api.dsl.ManagedVirtualDevice>("mediumTablet") {
|
||||
device = "Medium Tablet"
|
||||
apiLevel = 34
|
||||
systemImageSource = "google"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = true
|
||||
checkReleaseBuilds = true
|
||||
@@ -91,13 +173,51 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
arguments {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
val validateReleaseConfig by tasks.registering {
|
||||
doLast {
|
||||
environmentConfigs
|
||||
.filterKeys { it != "local" }
|
||||
.forEach { (name, cfg) ->
|
||||
require(cfg.apiBaseUrl.startsWith("https://")) {
|
||||
"Release flavor '$name' must use HTTPS API URL, got ${cfg.apiBaseUrl}"
|
||||
}
|
||||
require(cfg.socketIoUrl.startsWith("https://")) {
|
||||
"Release flavor '$name' must use HTTPS Socket.IO URL, got ${cfg.socketIoUrl}"
|
||||
}
|
||||
require(cfg.daemonSocketUrl.startsWith("wss://")) {
|
||||
"Release flavor '$name' must use WSS daemon URL, got ${cfg.daemonSocketUrl}"
|
||||
}
|
||||
require(!cfg.apiBaseUrl.contains("10.0.2.2") && !cfg.apiBaseUrl.contains("localhost")) {
|
||||
"Release flavor '$name' must not use local API URL, got ${cfg.apiBaseUrl}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { task ->
|
||||
task.name.startsWith("assemble") && task.name.endsWith("Release")
|
||||
}.configureEach {
|
||||
dependsOn(validateReleaseConfig)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||
implementation("androidx.browser:browser:1.9.0")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
|
||||
implementation("androidx.compose.material3:material3:1.4.0")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.4.1")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
@@ -106,17 +226,27 @@ dependencies {
|
||||
implementation("com.squareup.retrofit2:retrofit:3.0.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:5.3.2")
|
||||
implementation("io.socket:socket.io-client:2.1.0")
|
||||
implementation("com.google.dagger:hilt-android:2.57.2")
|
||||
kapt("com.google.dagger:hilt-android-compiler:2.57.2")
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
kapt("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.room:room-runtime:2.8.0")
|
||||
implementation("androidx.room:room-ktx:2.8.0")
|
||||
kapt("androidx.room:room-compiler:2.8.0")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("com.google.firebase:firebase-messaging:24.1.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test:rules:1.7.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.9.4")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest:1.9.4")
|
||||
androidTestImplementation("androidx.room:room-testing:2.8.0")
|
||||
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
}
|
||||
|
||||
29
android/native/app/google-services.json
Normal file
29
android/native/app/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "872182891503",
|
||||
"project_id": "yourpart-2f689",
|
||||
"storage_bucket": "yourpart-2f689.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:872182891503:android:6004ef4c46ce27e26eec1a",
|
||||
"android_client_info": {
|
||||
"package_name": "de.yourpart.nativeapp"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyC28yw1StDE15tdHKDQEXqEC_cBNXG9xkM"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
17
android/native/app/google-services.json.example
Normal file
17
android/native/app/google-services.json.example
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "REPLACE_WITH_FIREBASE_PROJECT_NUMBER",
|
||||
"project_id": "REPLACE_WITH_FIREBASE_PROJECT_ID",
|
||||
"storage_bucket": "REPLACE_WITH_FIREBASE_STORAGE_BUCKET"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "REPLACE_WITH_ANDROID_APP_ID",
|
||||
"android_client_info": { "package_name": "de.yourpart.nativeapp" }
|
||||
},
|
||||
"api_key": [{ "current_key": "REPLACE_WITH_FIREBASE_API_KEY"}]
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
0
android/native/app/proguard-rules.pro
vendored
Normal file → Executable file
0
android/native/app/proguard-rules.pro
vendored
Normal file → Executable file
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "771f218fbabd59ebba146d6688176afa",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "user_profile_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT, `avatarUrl` TEXT, `profileJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`userId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "avatarUrl",
|
||||
"columnName": "avatarUrl",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "profileJson",
|
||||
"columnName": "profileJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"userId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "friend_search_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `resultsJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`query`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "query",
|
||||
"columnName": "query",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "resultsJson",
|
||||
"columnName": "resultsJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"query"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "falukant_status_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `statusJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`cacheKey`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "cacheKey",
|
||||
"columnName": "cacheKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "statusJson",
|
||||
"columnName": "statusJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"cacheKey"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "vocab_course_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT, `courseJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`courseId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "courseId",
|
||||
"columnName": "courseId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "summary",
|
||||
"columnName": "summary",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "courseJson",
|
||||
"columnName": "courseJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"courseId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "vocab_lesson_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lessonId` TEXT NOT NULL, `courseId` TEXT NOT NULL, `title` TEXT NOT NULL, `lessonJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`lessonId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "lessonId",
|
||||
"columnName": "lessonId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "courseId",
|
||||
"columnName": "courseId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lessonJson",
|
||||
"columnName": "lessonJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAtMillis",
|
||||
"columnName": "updatedAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"lessonId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "vocab_review_outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`requestId` TEXT NOT NULL, `payloadJson` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, PRIMARY KEY(`requestId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "requestId",
|
||||
"columnName": "requestId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "payloadJson",
|
||||
"columnName": "payloadJson",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAtMillis",
|
||||
"columnName": "createdAtMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"requestId"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '771f218fbabd59ebba146d6688176afa')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.yourpart.nativeapp.core.auth.storage
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.core.auth.model.UserDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SessionStoreResumeTest {
|
||||
@Test fun savedSessionSurvivesStoreRecreation() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val firstStore = SessionStore(context, Json { ignoreUnknownKeys = true })
|
||||
firstStore.clearSession()
|
||||
firstStore.saveSession(AuthSession(UserDto("resume-user", "alice", true, authCode = "resume-code"), true, 1L))
|
||||
|
||||
val resumedStore = SessionStore(context, Json { ignoreUnknownKeys = true })
|
||||
|
||||
assertEquals("resume-user", resumedStore.currentSession?.user?.id)
|
||||
resumedStore.clearSession()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AppCacheMigrationTest {
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppCacheDatabase::class.java,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun migrationFromVersion1PreservesExistingCacheAndCreatesReviewOutbox() {
|
||||
helper.createDatabase(DB_NAME, 1).apply {
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `user_profile_cache` (`userId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT, `avatarUrl` TEXT, `profileJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`userId`))")
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `friend_search_cache` (`query` TEXT NOT NULL, `resultsJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`query`))")
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `falukant_status_cache` (`cacheKey` TEXT NOT NULL, `statusJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`cacheKey`))")
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `vocab_course_cache` (`courseId` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT, `courseJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`courseId`))")
|
||||
execSQL("CREATE TABLE IF NOT EXISTS `vocab_lesson_cache` (`lessonId` TEXT NOT NULL, `courseId` TEXT NOT NULL, `title` TEXT NOT NULL, `lessonJson` TEXT NOT NULL, `updatedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`lessonId`))")
|
||||
execSQL("INSERT INTO `vocab_lesson_cache` VALUES ('lesson-1', 'course-1', 'Titel', '{}', 1)")
|
||||
close()
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(DB_NAME, 2, true, AppCacheMigrations.MIGRATION_1_2).use { database ->
|
||||
database.query("SELECT lessonId FROM vocab_lesson_cache").use { cursor ->
|
||||
assertEquals(1, cursor.count)
|
||||
}
|
||||
database.query("SELECT name FROM sqlite_master WHERE type='table' AND name='vocab_review_outbox'").use { cursor ->
|
||||
assertEquals(1, cursor.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object { const val DB_NAME = "cache-migration-test" }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.yourpart.nativeapp.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import de.yourpart.nativeapp.feature.auth.AuthScreen
|
||||
import de.yourpart.nativeapp.feature.auth.LoginUiState
|
||||
import de.yourpart.nativeapp.feature.home.HomeScreen
|
||||
import de.yourpart.nativeapp.feature.home.HomeUiState
|
||||
import de.yourpart.nativeapp.feature.vocab.VocabScreen
|
||||
import de.yourpart.nativeapp.feature.vocab.VocabUiState
|
||||
import de.yourpart.nativeapp.ui.theme.YourPartTheme
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class NativeScreenComposeTest {
|
||||
@get:Rule val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun loginShowsValidationAndPrimaryAction() {
|
||||
composeRule.setContent {
|
||||
YourPartTheme {
|
||||
AuthScreen(
|
||||
uiState = LoginUiState(errorMessage = "Ungültige Anmeldung"),
|
||||
oauthProviders = emptyList(),
|
||||
onUsernameChange = {}, onPasswordChange = {}, onRememberMeChange = {}, onLogin = {},
|
||||
onRegisterClick = {}, onForgotPasswordClick = {}, onOAuthLogin = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText("Anmelden").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Ungültige Anmeldung").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun navigationMovesToDestination() {
|
||||
composeRule.setContent {
|
||||
YourPartTheme {
|
||||
val navController = rememberNavController()
|
||||
LaunchedEffect(Unit) { navController.navigate("target") }
|
||||
NavHost(navController = navController, startDestination = "start") {
|
||||
composable("start") { Text("Start") }
|
||||
composable("target") { Text("Ziel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText("Ziel").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun homeRendersDashboardAndInvokesRefresh() {
|
||||
var refreshes = 0
|
||||
composeRule.setContent { YourPartTheme { HomeScreen(HomeUiState(isLoading = false), onRefresh = { refreshes += 1 }) } }
|
||||
composeRule.onNodeWithText("Dashboard").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Aktualisieren").performClick()
|
||||
composeRule.runOnIdle { assertEquals(1, refreshes) }
|
||||
}
|
||||
|
||||
@Test fun vocabRendersLoadedLesson() {
|
||||
val lesson = buildJsonObject { put("title", "Lektion eins") }
|
||||
composeRule.setContent {
|
||||
YourPartTheme {
|
||||
VocabScreen(
|
||||
state = VocabUiState(lesson = lesson),
|
||||
onCourses = {}, onCourseId = {}, onLoadCourse = {}, onLessonId = {}, onLoadLesson = {},
|
||||
onLanguageId = {}, onLanguageName = {}, onCreateLanguage = {}, onShareCode = {}, onSubscribe = {},
|
||||
onChapters = {}, onChapterId = {}, onChapterVocabs = {}, onDictionaryQuery = {}, onSearchDictionary = {},
|
||||
onPractice = {}, onStartPractice = {}, onCheckPractice = {}, onLearning = {}, onReference = {},
|
||||
onToggleCorrect = {}, onAnswer = {}, onReview = {}, onDictionary = {}, onProgress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.onNodeWithText("Lektion").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Lektion eins").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
8
android/native/app/src/debug/res/xml/network_security_config.xml
Executable file
8
android/native/app/src/debug/res/xml/network_security_config.xml
Executable file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
21
android/native/app/src/main/AndroidManifest.xml
Normal file → Executable file
21
android/native/app/src/main/AndroidManifest.xml
Normal file → Executable file
@@ -1,15 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".YourPartNativeApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher_foreground"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@drawable/ic_launcher_foreground"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.YourPartNative">
|
||||
<service
|
||||
android:name=".feature.push.YourPartFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
@@ -17,6 +26,18 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/android/oauth/callback" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/socialnetwork" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/falukant" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/settings" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/blogs" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:pathPrefix="/guides" />
|
||||
<data android:scheme="https" android:host="www.your-part.de" android:path="/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
50
android/native/app/src/main/java/de/yourpart/nativeapp/MainActivity.kt
Normal file → Executable file
50
android/native/app/src/main/java/de/yourpart/nativeapp/MainActivity.kt
Normal file → Executable file
@@ -1,22 +1,70 @@
|
||||
package de.yourpart.nativeapp
|
||||
|
||||
import android.os.Bundle
|
||||
import android.content.Intent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeManager
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeCacheInvalidator
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeEventCenter
|
||||
import de.yourpart.nativeapp.feature.auth.AuthViewModel
|
||||
import de.yourpart.nativeapp.core.navigation.AppLink
|
||||
import de.yourpart.nativeapp.core.navigation.AppLinkParser
|
||||
import de.yourpart.nativeapp.ui.YourPartNativeApp
|
||||
import de.yourpart.nativeapp.ui.theme.YourPartTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val authViewModel: AuthViewModel by viewModels()
|
||||
@Inject lateinit var realtimeManager: RealtimeManager
|
||||
@Inject lateinit var realtimeEventCenter: RealtimeEventCenter
|
||||
@Inject lateinit var realtimeCacheInvalidator: RealtimeCacheInvalidator
|
||||
private var incomingAppLink by mutableStateOf<AppLink?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
incomingAppLink = AppLinkParser.parse(intent) ?: intent.notificationRoute()
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
YourPartTheme {
|
||||
YourPartNativeApp()
|
||||
YourPartNativeApp(
|
||||
authViewModel = authViewModel,
|
||||
realtimeManager = realtimeManager,
|
||||
realtimeEventCenter = realtimeEventCenter,
|
||||
realtimeCacheInvalidator = realtimeCacheInvalidator,
|
||||
incomingAppLink = incomingAppLink,
|
||||
onSensitiveContentVisible = ::setSensitiveContentProtection,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
incomingAppLink = AppLinkParser.parse(intent) ?: intent.notificationRoute()
|
||||
}
|
||||
|
||||
private fun Intent.notificationRoute(): AppLink.Route? = getStringExtra(EXTRA_NOTIFICATION_ROUTE)
|
||||
?.let { AppLink.Route(route = it, requiresAuth = true) }
|
||||
|
||||
private fun setSensitiveContentProtection(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_NOTIFICATION_ROUTE = "de.yourpart.nativeapp.NOTIFICATION_ROUTE"
|
||||
}
|
||||
}
|
||||
|
||||
0
android/native/app/src/main/java/de/yourpart/nativeapp/YourPartNativeApplication.kt
Normal file → Executable file
0
android/native/app/src/main/java/de/yourpart/nativeapp/YourPartNativeApplication.kt
Normal file → Executable file
@@ -0,0 +1,58 @@
|
||||
package de.yourpart.nativeapp.core.auth.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserParamDto(
|
||||
val name: String,
|
||||
val value: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserDto(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val active: Boolean,
|
||||
val param: List<UserParamDto> = emptyList(),
|
||||
val authCode: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RegisterRequest(
|
||||
val email: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val language: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForgotPasswordRequest(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OAuthProviderDto(
|
||||
val slug: String,
|
||||
val label: String,
|
||||
val issuer: String,
|
||||
val scope: String,
|
||||
val configured: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ErrorResponse(
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthSession(
|
||||
val user: UserDto,
|
||||
val rememberMe: Boolean,
|
||||
val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.yourpart.nativeapp.core.auth.storage
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
|
||||
interface AuthSessionStore {
|
||||
val currentSession: AuthSession?
|
||||
fun saveSession(session: AuthSession)
|
||||
fun clearSession(notifyExpired: Boolean = false)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.yourpart.nativeapp.core.auth.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SessionStore @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val json: Json,
|
||||
) : AuthSessionStore {
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
private val _session = MutableStateFlow(loadSession())
|
||||
private val _sessionExpiredMessage = MutableStateFlow<String?>(null)
|
||||
|
||||
val session: StateFlow<AuthSession?> = _session.asStateFlow()
|
||||
val sessionExpiredMessage: StateFlow<String?> = _sessionExpiredMessage.asStateFlow()
|
||||
|
||||
override val currentSession: AuthSession?
|
||||
get() = _session.value
|
||||
|
||||
override fun saveSession(session: AuthSession) {
|
||||
prefs.edit().putString(KEY_SESSION, json.encodeToString(session)).apply()
|
||||
_session.value = session
|
||||
}
|
||||
|
||||
override fun clearSession(notifyExpired: Boolean) {
|
||||
prefs.edit().remove(KEY_SESSION).apply()
|
||||
_session.value = null
|
||||
if (notifyExpired) {
|
||||
_sessionExpiredMessage.value = "Deine Sitzung ist abgelaufen."
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeSessionExpiredMessage() {
|
||||
_sessionExpiredMessage.value = null
|
||||
}
|
||||
|
||||
private fun loadSession(): AuthSession? {
|
||||
val raw = prefs.getString(KEY_SESSION, null) ?: return null
|
||||
return runCatching { json.decodeFromString<AuthSession>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "yourpart_native_secure_auth"
|
||||
const val KEY_SESSION = "auth_session"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.yourpart.nativeapp.core.config
|
||||
|
||||
import de.yourpart.nativeapp.BuildConfig
|
||||
|
||||
data class AppConfig(
|
||||
val environment: String,
|
||||
val apiBaseUrl: String,
|
||||
val socketIoUrl: String,
|
||||
val daemonSocketUrl: String,
|
||||
val allowCleartext: Boolean,
|
||||
val isReleaseBuild: Boolean,
|
||||
val featureAdmin: Boolean,
|
||||
val featureAdult: Boolean,
|
||||
val feature3d: Boolean,
|
||||
val featureMinigames: Boolean,
|
||||
val featurePush: Boolean,
|
||||
val gitSha: String,
|
||||
) {
|
||||
companion object {
|
||||
fun fromBuildConfig(): AppConfig = AppConfig(
|
||||
environment = BuildConfig.ENVIRONMENT,
|
||||
apiBaseUrl = BuildConfig.API_BASE_URL,
|
||||
socketIoUrl = BuildConfig.SOCKET_IO_URL,
|
||||
daemonSocketUrl = BuildConfig.DAEMON_SOCKET_URL,
|
||||
allowCleartext = BuildConfig.ALLOW_CLEARTEXT,
|
||||
isReleaseBuild = BuildConfig.IS_RELEASE_BUILD,
|
||||
featureAdmin = BuildConfig.FEATURE_ADMIN,
|
||||
featureAdult = BuildConfig.FEATURE_ADULT,
|
||||
feature3d = BuildConfig.FEATURE_3D,
|
||||
featureMinigames = BuildConfig.FEATURE_MINIGAMES,
|
||||
featurePush = BuildConfig.FEATURE_PUSH,
|
||||
gitSha = BuildConfig.GIT_SHA,
|
||||
)
|
||||
}
|
||||
}
|
||||
60
android/native/app/src/main/java/de/yourpart/nativeapp/core/di/AppModule.kt
Executable file
60
android/native/app/src/main/java/de/yourpart/nativeapp/core/di/AppModule.kt
Executable file
@@ -0,0 +1,60 @@
|
||||
package de.yourpart.nativeapp.core.di
|
||||
|
||||
import de.yourpart.nativeapp.BuildConfig
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.AuthHeaderInterceptor
|
||||
import de.yourpart.nativeapp.core.network.NetworkPolicy
|
||||
import de.yourpart.nativeapp.core.realtime.DefaultRealtimeManager
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import javax.inject.Singleton
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppConfig(): AppConfig = AppConfig.fromBuildConfig()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
authHeaderInterceptor: AuthHeaderInterceptor,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.addInterceptor(authHeaderInterceptor)
|
||||
.connectTimeout(NetworkPolicy.connectTimeoutMillis(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(NetworkPolicy.readTimeoutMillis(), TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(NetworkPolicy.writeTimeoutMillis(), TimeUnit.MILLISECONDS)
|
||||
.callTimeout(NetworkPolicy.callTimeoutMillis(), TimeUnit.MILLISECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
|
||||
if (!BuildConfig.IS_RELEASE_BUILD) {
|
||||
builder.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRealtimeManager(impl: DefaultRealtimeManager): RealtimeManager = impl
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.yourpart.nativeapp.core.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.room.Room
|
||||
import de.yourpart.nativeapp.core.persistence.AppCacheDatabase
|
||||
import de.yourpart.nativeapp.core.persistence.AppCacheMigrations
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PersistenceModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePreferencesDataStore(
|
||||
@ApplicationContext context: Context,
|
||||
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.preferencesDataStoreFile("yourpart_native_user_prefs.preferences_pb") },
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppCacheDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): AppCacheDatabase = Room.databaseBuilder(
|
||||
context,
|
||||
AppCacheDatabase::class.java,
|
||||
"yourpart_native_cache.db",
|
||||
).addMigrations(AppCacheMigrations.MIGRATION_1_2).build()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.yourpart.nativeapp.core.di
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.AuthSessionStore
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class StorageModule {
|
||||
@Binds
|
||||
abstract fun bindAuthSessionStore(impl: SessionStore): AuthSessionStore
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.yourpart.nativeapp.core.navigation
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import de.yourpart.nativeapp.ui.navigation.BlogDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.FalukantDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.ForumTopicDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.ForumTopicsDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.GalleryDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.GuideDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.HomeDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.SettingsDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.SocialDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.SocialProfileDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.SocialSearchDestination
|
||||
import de.yourpart.nativeapp.ui.navigation.VocabDestination
|
||||
|
||||
sealed interface AppLink {
|
||||
data class Route(val route: String, val requiresAuth: Boolean) : AppLink
|
||||
data class OAuthCallback(val code: String?, val state: String?, val issuer: String?, val error: String?) : AppLink
|
||||
}
|
||||
|
||||
object AppLinkParser {
|
||||
private const val HOST = "www.your-part.de"
|
||||
|
||||
fun parse(intent: Intent?): AppLink? = intent?.data?.let(::parse)
|
||||
|
||||
fun parse(uri: Uri): AppLink? {
|
||||
if (uri.scheme != "https" || uri.host != HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments == listOf("android", "oauth", "callback")) {
|
||||
return AppLink.OAuthCallback(
|
||||
code = uri.getQueryParameter("code"),
|
||||
state = uri.getQueryParameter("state"),
|
||||
issuer = uri.getQueryParameter("iss"),
|
||||
error = uri.getQueryParameter("error"),
|
||||
)
|
||||
}
|
||||
return when {
|
||||
segments.isEmpty() -> AppLink.Route(HomeDestination.route, requiresAuth = true)
|
||||
segments == listOf("socialnetwork", "search") -> AppLink.Route(SocialSearchDestination.route, true)
|
||||
segments == listOf("socialnetwork", "gallery") -> AppLink.Route(GalleryDestination.route, true)
|
||||
segments.getOrNull(0) == "socialnetwork" && segments.getOrNull(1) == "forum" ->
|
||||
segments.getOrNull(2)?.toLongOrNull()?.let { AppLink.Route(ForumTopicsDestination.createRoute(it), true) }
|
||||
segments.getOrNull(0) == "socialnetwork" && segments.getOrNull(1) == "forumtopic" ->
|
||||
segments.getOrNull(2)?.toLongOrNull()?.let { AppLink.Route(ForumTopicDestination.createRoute(it), true) }
|
||||
segments.getOrNull(0) == "socialnetwork" && segments.getOrNull(1) == "vocab" -> AppLink.Route(VocabDestination.route, true)
|
||||
segments.getOrNull(0) == "socialnetwork" && segments.getOrNull(1) == "profile" ->
|
||||
segments.getOrNull(2)?.takeIf { it.isNotBlank() }?.let { AppLink.Route(SocialProfileDestination.createRoute(it), true) }
|
||||
segments.firstOrNull() == "falukant" -> AppLink.Route(FalukantDestination.route, true)
|
||||
segments.firstOrNull() == "settings" -> AppLink.Route(SettingsDestination.route, true)
|
||||
segments.firstOrNull() == "blogs" -> AppLink.Route(BlogDestination.route, requiresAuth = false)
|
||||
segments.firstOrNull() == "guides" -> AppLink.Route(GuideDestination.route, requiresAuth = false)
|
||||
segments.firstOrNull() == "socialnetwork" -> AppLink.Route(SocialDestination.route, true)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
sealed interface ApiResult<out T> {
|
||||
data class Success<T>(val value: T) : ApiResult<T>
|
||||
data class Failure(val error: NetworkError) : ApiResult<Nothing>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.AuthSessionStore
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthHeaderInterceptor @Inject constructor(
|
||||
private val sessionStore: AuthSessionStore,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val session = sessionStore.currentSession
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
|
||||
session?.user?.let { user ->
|
||||
requestBuilder.header("userid", user.id)
|
||||
requestBuilder.header("authcode", user.authCode)
|
||||
}
|
||||
|
||||
val response = chain.proceed(requestBuilder.build())
|
||||
if (response.code == 401 && session != null) {
|
||||
sessionStore.clearSession(notifyExpired = true)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.ErrorResponse
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object BackendErrorParser {
|
||||
fun parseBackendCode(rawBody: String?, json: Json): String? {
|
||||
if (rawBody.isNullOrBlank()) return null
|
||||
return runCatching {
|
||||
json.decodeFromString<ErrorResponse>(rawBody).error
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun toNetworkError(
|
||||
statusCode: Int,
|
||||
rawBody: String?,
|
||||
json: Json,
|
||||
): NetworkError {
|
||||
return HttpNetworkError(
|
||||
statusCode = statusCode,
|
||||
backendCode = parseBackendCode(rawBody, json),
|
||||
rawBody = rawBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DownloadHelper @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
suspend fun downloadToFile(
|
||||
url: String,
|
||||
destination: File,
|
||||
): Result<File> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Download failed with HTTP ${response.code}")
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IllegalStateException("Download response body missing")
|
||||
destination.outputStream().use { output ->
|
||||
body.byteStream().use { input -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
destination
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import androidx.annotation.WorkerThread
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class MultipartUploadHelper(
|
||||
private val contentResolver: ContentResolver,
|
||||
) {
|
||||
@WorkerThread
|
||||
fun toMultipartPart(
|
||||
uri: Uri,
|
||||
formField: String,
|
||||
fileName: String = "upload.bin",
|
||||
): MultipartBody.Part {
|
||||
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val bytes = requireNotNull(contentResolver.openInputStream(uri)?.use { it.readBytes() }) {
|
||||
"Unable to read content URI: $uri"
|
||||
}
|
||||
|
||||
val requestBody = bytes.toRequestBody(mimeType.toMediaType())
|
||||
return MultipartBody.Part.createFormData(formField, fileName, requestBody)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
sealed interface NetworkError {
|
||||
val message: String
|
||||
}
|
||||
|
||||
data class HttpNetworkError(
|
||||
val statusCode: Int,
|
||||
val backendCode: String? = null,
|
||||
val rawBody: String? = null,
|
||||
override val message: String = when (backendCode) {
|
||||
"credentialsinvalid" -> "Benutzername oder Passwort ist falsch."
|
||||
"userblocked" -> "Dieses Konto ist deaktiviert."
|
||||
"emailinuse" -> "Diese E-Mail-Adresse wird bereits verwendet."
|
||||
"languagenotfound" -> "Die Sprachkonfiguration konnte nicht geladen werden."
|
||||
else -> "Die Anfrage wurde vom Server abgelehnt."
|
||||
},
|
||||
) : NetworkError
|
||||
|
||||
data object NoNetworkError : NetworkError {
|
||||
override val message: String = "Keine Netzwerkverbindung."
|
||||
}
|
||||
|
||||
data object TimeoutNetworkError : NetworkError {
|
||||
override val message: String = "Die Anfrage hat zu lange gedauert."
|
||||
}
|
||||
|
||||
data class SerializationNetworkError(
|
||||
override val message: String = "Antwort konnte nicht verarbeitet werden.",
|
||||
) : NetworkError
|
||||
|
||||
data class UnknownNetworkError(
|
||||
override val message: String = "Unbekannter Netzwerkfehler.",
|
||||
) : NetworkError
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object NetworkPolicy {
|
||||
const val CONNECT_TIMEOUT_SECONDS = 15L
|
||||
const val READ_TIMEOUT_SECONDS = 30L
|
||||
const val WRITE_TIMEOUT_SECONDS = 30L
|
||||
const val CALL_TIMEOUT_SECONDS = 60L
|
||||
const val MAX_RETRY_ATTEMPTS = 2
|
||||
|
||||
fun connectTimeoutMillis(): Long = TimeUnit.SECONDS.toMillis(CONNECT_TIMEOUT_SECONDS)
|
||||
fun readTimeoutMillis(): Long = TimeUnit.SECONDS.toMillis(READ_TIMEOUT_SECONDS)
|
||||
fun writeTimeoutMillis(): Long = TimeUnit.SECONDS.toMillis(WRITE_TIMEOUT_SECONDS)
|
||||
fun callTimeoutMillis(): Long = TimeUnit.SECONDS.toMillis(CALL_TIMEOUT_SECONDS)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NetworkRequestExecutor @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val json: Json,
|
||||
) {
|
||||
suspend fun execute(request: Request): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
val payload = response.body?.string().orEmpty()
|
||||
if (response.isSuccessful) {
|
||||
ApiResult.Success(payload)
|
||||
} else {
|
||||
ApiResult.Failure(BackendErrorParser.toNetworkError(response.code, payload, json))
|
||||
}
|
||||
}
|
||||
}.getOrElse { throwable ->
|
||||
ApiResult.Failure(throwable.toNetworkError())
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.toNetworkError(): NetworkError = when (this) {
|
||||
is SocketTimeoutException -> TimeoutNetworkError
|
||||
is IOException -> NoNetworkError
|
||||
else -> UnknownNetworkError(message ?: UnknownNetworkError().message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.yourpart.nativeapp.core.network
|
||||
|
||||
data class PageRequest(
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 30,
|
||||
)
|
||||
|
||||
data class PageResponse<T>(
|
||||
val items: List<T>,
|
||||
val page: Int,
|
||||
val pageSize: Int,
|
||||
val hasMore: Boolean,
|
||||
)
|
||||
|
||||
sealed interface RefreshState {
|
||||
data object Idle : RefreshState
|
||||
data object Loading : RefreshState
|
||||
data object Error : RefreshState
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
UserProfileCacheEntity::class,
|
||||
FriendSearchCacheEntity::class,
|
||||
FalukantStatusCacheEntity::class,
|
||||
VocabCourseCacheEntity::class,
|
||||
VocabLessonCacheEntity::class,
|
||||
VocabReviewOutboxEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AppCacheDatabase : RoomDatabase() {
|
||||
abstract fun userProfileCacheDao(): UserProfileCacheDao
|
||||
abstract fun friendSearchCacheDao(): FriendSearchCacheDao
|
||||
abstract fun falukantStatusCacheDao(): FalukantStatusCacheDao
|
||||
abstract fun vocabCourseCacheDao(): VocabCourseCacheDao
|
||||
abstract fun vocabLessonCacheDao(): VocabLessonCacheDao
|
||||
abstract fun vocabReviewOutboxDao(): VocabReviewOutboxDao
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
object AppCacheMigrations {
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `vocab_review_outbox` (
|
||||
`requestId` TEXT NOT NULL,
|
||||
`payloadJson` TEXT NOT NULL,
|
||||
`createdAtMillis` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`requestId`)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface UserProfileCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: UserProfileCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM user_profile_cache WHERE userId = :userId LIMIT 1")
|
||||
fun observe(userId: String): Flow<UserProfileCacheEntity?>
|
||||
|
||||
@Query("DELETE FROM user_profile_cache")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface FriendSearchCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: FriendSearchCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM friend_search_cache WHERE query = :query LIMIT 1")
|
||||
fun observe(query: String): Flow<FriendSearchCacheEntity?>
|
||||
|
||||
@Query("DELETE FROM friend_search_cache")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface FalukantStatusCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: FalukantStatusCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM falukant_status_cache WHERE cacheKey = 'current' LIMIT 1")
|
||||
fun observe(): Flow<FalukantStatusCacheEntity?>
|
||||
|
||||
@Query("DELETE FROM falukant_status_cache")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface VocabCourseCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: VocabCourseCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM vocab_course_cache WHERE courseId = :courseId LIMIT 1")
|
||||
fun observe(courseId: String): Flow<VocabCourseCacheEntity?>
|
||||
|
||||
@Query("DELETE FROM vocab_course_cache")
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface VocabLessonCacheDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: VocabLessonCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM vocab_lesson_cache WHERE lessonId = :lessonId LIMIT 1")
|
||||
fun observe(lessonId: String): Flow<VocabLessonCacheEntity?>
|
||||
|
||||
@Query("SELECT * FROM vocab_lesson_cache WHERE lessonId = :lessonId LIMIT 1")
|
||||
suspend fun get(lessonId: String): VocabLessonCacheEntity?
|
||||
|
||||
@Query("DELETE FROM vocab_lesson_cache WHERE courseId = :courseId")
|
||||
suspend fun clearForCourse(courseId: String)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface VocabReviewOutboxDao {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun enqueue(entity: VocabReviewOutboxEntity)
|
||||
|
||||
@Query("SELECT * FROM vocab_review_outbox ORDER BY createdAtMillis ASC")
|
||||
suspend fun pending(): List<VocabReviewOutboxEntity>
|
||||
|
||||
@Query("DELETE FROM vocab_review_outbox WHERE requestId = :requestId")
|
||||
suspend fun delete(requestId: String)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "user_profile_cache")
|
||||
data class UserProfileCacheEntity(
|
||||
@PrimaryKey val userId: String,
|
||||
val username: String,
|
||||
val displayName: String? = null,
|
||||
val avatarUrl: String? = null,
|
||||
val profileJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "friend_search_cache")
|
||||
data class FriendSearchCacheEntity(
|
||||
@PrimaryKey val query: String,
|
||||
val resultsJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "falukant_status_cache")
|
||||
data class FalukantStatusCacheEntity(
|
||||
@PrimaryKey val cacheKey: String = "current",
|
||||
val statusJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "vocab_course_cache")
|
||||
data class VocabCourseCacheEntity(
|
||||
@PrimaryKey val courseId: String,
|
||||
val title: String,
|
||||
val summary: String? = null,
|
||||
val courseJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "vocab_lesson_cache")
|
||||
data class VocabLessonCacheEntity(
|
||||
@PrimaryKey val lessonId: String,
|
||||
val courseId: String,
|
||||
val title: String,
|
||||
val lessonJson: String,
|
||||
val updatedAtMillis: Long,
|
||||
)
|
||||
|
||||
/** A review is kept locally until the server acknowledges it. */
|
||||
@Entity(tableName = "vocab_review_outbox")
|
||||
data class VocabReviewOutboxEntity(
|
||||
@PrimaryKey val requestId: String,
|
||||
val payloadJson: String,
|
||||
val createdAtMillis: Long,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object CachePolicy {
|
||||
const val USER_PROFILE_TTL_MILLIS = 24L * 60L * 60L * 1000L
|
||||
const val FRIEND_SEARCH_TTL_MILLIS = 15L * 60L * 1000L
|
||||
const val FALUKANT_STATUS_TTL_MILLIS = 5L * 60L * 1000L
|
||||
const val VOCAB_COURSE_TTL_MILLIS = 12L * 60L * 60L * 1000L
|
||||
const val VOCAB_LESSON_TTL_MILLIS = 12L * 60L * 60L * 1000L
|
||||
|
||||
fun isExpired(updatedAtMillis: Long, ttlMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean {
|
||||
return nowMillis - updatedAtMillis > ttlMillis
|
||||
}
|
||||
|
||||
fun ttlHours(hours: Long): Long = TimeUnit.HOURS.toMillis(hours)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
data class FeatureFlagState(
|
||||
val adminEnabled: Boolean = false,
|
||||
val adultEnabled: Boolean = false,
|
||||
val threeDEnabled: Boolean = false,
|
||||
val minigamesEnabled: Boolean = false,
|
||||
val pushEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.yourpart.nativeapp.core.persistence
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class UserPreferencesStore @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
val language: Flow<String> = dataStore.data.map { prefs ->
|
||||
prefs[Keys.LANGUAGE] ?: DEFAULT_LANGUAGE
|
||||
}
|
||||
|
||||
val featureFlags: Flow<FeatureFlagState> = dataStore.data.map { prefs ->
|
||||
FeatureFlagState(
|
||||
adminEnabled = prefs[Keys.FEATURE_ADMIN] ?: false,
|
||||
adultEnabled = prefs[Keys.FEATURE_ADULT] ?: false,
|
||||
threeDEnabled = prefs[Keys.FEATURE_3D] ?: false,
|
||||
minigamesEnabled = prefs[Keys.FEATURE_MINIGAMES] ?: false,
|
||||
pushEnabled = prefs[Keys.FEATURE_PUSH] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setLanguage(language: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[Keys.LANGUAGE] = language.trim().ifBlank { DEFAULT_LANGUAGE }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setFeatureFlags(state: FeatureFlagState) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[Keys.FEATURE_ADMIN] = state.adminEnabled
|
||||
prefs[Keys.FEATURE_ADULT] = state.adultEnabled
|
||||
prefs[Keys.FEATURE_3D] = state.threeDEnabled
|
||||
prefs[Keys.FEATURE_MINIGAMES] = state.minigamesEnabled
|
||||
prefs[Keys.FEATURE_PUSH] = state.pushEnabled
|
||||
}
|
||||
}
|
||||
|
||||
private object Keys {
|
||||
val LANGUAGE = stringPreferencesKey("ui_language")
|
||||
val FEATURE_ADMIN = booleanPreferencesKey("feature_admin")
|
||||
val FEATURE_ADULT = booleanPreferencesKey("feature_adult")
|
||||
val FEATURE_3D = booleanPreferencesKey("feature_3d")
|
||||
val FEATURE_MINIGAMES = booleanPreferencesKey("feature_minigames")
|
||||
val FEATURE_PUSH = booleanPreferencesKey("feature_push")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_LANGUAGE = "de"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
object DaemonMessageParser {
|
||||
fun parse(rawMessage: String, json: Json): DaemonEnvelope? {
|
||||
val trimmed = rawMessage.trim()
|
||||
if (trimmed.isEmpty() || trimmed == "ping" || trimmed == "pong") {
|
||||
return null
|
||||
}
|
||||
|
||||
val element = runCatching { json.parseToJsonElement(trimmed) }.getOrNull() ?: return null
|
||||
val obj = element as? JsonObject ?: return null
|
||||
val event = obj["event"]?.toString()?.trim('"')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
val payload = obj["data"]?.toString()
|
||||
return DaemonEnvelope(event = event, payloadRaw = payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import io.socket.client.IO
|
||||
import io.socket.client.Socket
|
||||
import io.socket.emitter.Emitter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Singleton
|
||||
class DefaultRealtimeManager @Inject constructor(
|
||||
private val appConfig: AppConfig,
|
||||
private val json: Json,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) : RealtimeManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val _connectionSnapshot = MutableStateFlow(RealtimeConnectionSnapshot())
|
||||
override val connectionSnapshot: StateFlow<RealtimeConnectionSnapshot> = _connectionSnapshot.asStateFlow()
|
||||
private val _events = MutableSharedFlow<RealtimeSocketEvent>(extraBufferCapacity = 64)
|
||||
override val events: SharedFlow<RealtimeSocketEvent> = _events.asSharedFlow()
|
||||
|
||||
@Volatile
|
||||
private var session: AuthSession? = null
|
||||
|
||||
@Volatile
|
||||
private var isForeground: Boolean = false
|
||||
|
||||
private var backendSocket: Socket? = null
|
||||
private var daemonWebSocket: WebSocket? = null
|
||||
private val reconnectingDaemon = AtomicBoolean(false)
|
||||
|
||||
override fun sync(context: RealtimeSessionContext) {
|
||||
session = context.session
|
||||
isForeground = context.isForeground
|
||||
reconnectingDaemon.set(false)
|
||||
scope.launch { reconcileConnections() }
|
||||
}
|
||||
|
||||
override fun onAppForeground() {
|
||||
isForeground = true
|
||||
scope.launch { reconcileConnections() }
|
||||
}
|
||||
|
||||
override fun onAppBackground() {
|
||||
isForeground = false
|
||||
scope.launch { disconnectAll() }
|
||||
}
|
||||
|
||||
private suspend fun reconcileConnections() {
|
||||
if (session == null || !isForeground) {
|
||||
disconnectAll()
|
||||
return
|
||||
}
|
||||
|
||||
connectBackend(session)
|
||||
connectDaemon(session)
|
||||
}
|
||||
|
||||
private suspend fun connectBackend(session: AuthSession?) {
|
||||
if (backendSocket?.connected() == true) {
|
||||
backendSocket?.emit("setUserId", session?.user?.id)
|
||||
return
|
||||
}
|
||||
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(backend = RealtimeConnectionStatus.Connecting)
|
||||
val options = IO.Options().apply {
|
||||
forceNew = true
|
||||
reconnection = true
|
||||
reconnectionAttempts = 5
|
||||
reconnectionDelay = 1_000
|
||||
timeout = 10_000
|
||||
transports = arrayOf("websocket")
|
||||
query = "client=native"
|
||||
}
|
||||
|
||||
val socket = IO.socket(appConfig.socketIoUrl, options)
|
||||
backendSocket = socket
|
||||
|
||||
socket.on(Socket.EVENT_CONNECT) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(backend = RealtimeConnectionStatus.Connected)
|
||||
session?.user?.id?.let { userId ->
|
||||
socket.emit("setUserId", userId)
|
||||
}
|
||||
}
|
||||
socket.on(Socket.EVENT_DISCONNECT) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(backend = RealtimeConnectionStatus.Disconnected)
|
||||
}
|
||||
socket.on(Socket.EVENT_CONNECT_ERROR) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(backend = RealtimeConnectionStatus.Error)
|
||||
}
|
||||
|
||||
registerSocketEventHandlers(socket)
|
||||
socket.connect()
|
||||
}
|
||||
|
||||
private fun registerSocketEventHandlers(socket: Socket) {
|
||||
val eventNames = listOf(
|
||||
"forumschanged",
|
||||
"topicschanged",
|
||||
"messageschanged",
|
||||
"friendloginchanged",
|
||||
"reloadmenu",
|
||||
"adultVerificationChanged",
|
||||
"moderationReportChanged",
|
||||
"userAccessChanged",
|
||||
"falukantUpdateStatus",
|
||||
"falukantUpdateFamily",
|
||||
"falukantUpdateChurch",
|
||||
"falukantUpdateDebt",
|
||||
"children_update",
|
||||
"falukantUpdateProductionCertificate",
|
||||
"falukantBranchUpdate",
|
||||
"stock_change",
|
||||
"familychanged",
|
||||
)
|
||||
|
||||
eventNames.forEach { eventName ->
|
||||
socket.on(eventName, socketEventListener(eventName))
|
||||
}
|
||||
}
|
||||
|
||||
private fun socketEventListener(eventName: String): Emitter.Listener = Emitter.Listener { args ->
|
||||
val payloadRaw = args.firstOrNull()?.let { toRawJson(it) }
|
||||
_events.tryEmit(
|
||||
RealtimeSocketEvent(
|
||||
source = RealtimeSource.BackendSocket,
|
||||
eventName = eventName,
|
||||
payloadRaw = payloadRaw,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun connectDaemon(session: AuthSession?) {
|
||||
if (daemonWebSocket != null) return
|
||||
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(daemon = RealtimeConnectionStatus.Connecting)
|
||||
val request = Request.Builder()
|
||||
.url(appConfig.daemonSocketUrl)
|
||||
.build()
|
||||
|
||||
daemonWebSocket = suspendCancellableCoroutine { continuation ->
|
||||
val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(daemon = RealtimeConnectionStatus.Connected)
|
||||
session?.user?.id?.let { userId ->
|
||||
webSocket.send(
|
||||
"""{"event":"setUserId","data":{"userId":"$userId"}}""",
|
||||
)
|
||||
}
|
||||
continuation.resume(webSocket)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (text == "ping") {
|
||||
webSocket.send("pong")
|
||||
return
|
||||
}
|
||||
|
||||
val envelope = DaemonMessageParser.parse(text, json) ?: return
|
||||
_events.tryEmit(
|
||||
RealtimeSocketEvent(
|
||||
source = RealtimeSource.DaemonWebSocket,
|
||||
eventName = envelope.event,
|
||||
payloadRaw = envelope.payloadRaw,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(daemon = RealtimeConnectionStatus.Disconnected)
|
||||
if (shouldReconnect()) {
|
||||
scheduleDaemonReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
_connectionSnapshot.value = _connectionSnapshot.value.copy(daemon = RealtimeConnectionStatus.Error)
|
||||
if (shouldReconnect()) {
|
||||
scheduleDaemonReconnect()
|
||||
}
|
||||
if (!continuation.isCompleted) {
|
||||
continuation.resumeWithException(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
val webSocket = okHttpClient.newWebSocket(request, listener)
|
||||
continuation.invokeOnCancellation {
|
||||
webSocket.close(1000, "cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleDaemonReconnect() {
|
||||
if (!reconnectingDaemon.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
delay(2_000)
|
||||
reconnectingDaemon.set(false)
|
||||
if (shouldReconnect()) {
|
||||
daemonWebSocket = null
|
||||
connectDaemon(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldReconnect(): Boolean = isForeground && session != null
|
||||
|
||||
private suspend fun disconnectAll() {
|
||||
backendSocket?.let { socket ->
|
||||
socket.off()
|
||||
socket.disconnect()
|
||||
socket.close()
|
||||
}
|
||||
backendSocket = null
|
||||
|
||||
daemonWebSocket?.close(1000, "background")
|
||||
daemonWebSocket = null
|
||||
|
||||
_connectionSnapshot.value = RealtimeConnectionSnapshot()
|
||||
}
|
||||
|
||||
private fun toRawJson(value: Any): String? = when (value) {
|
||||
is JSONObject -> value.toString()
|
||||
is String -> value
|
||||
is Number, is Boolean -> value.toString()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import de.yourpart.nativeapp.core.persistence.AppCacheDatabase
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RealtimeCacheInvalidator @Inject constructor(
|
||||
private val cacheDatabase: AppCacheDatabase,
|
||||
) {
|
||||
suspend fun handle(event: RealtimeSocketEvent) {
|
||||
when (event.eventName) {
|
||||
"reloadmenu" -> {
|
||||
// Menu ist aus API-Policy abgeleitet; kein lokaler Cache zu leeren.
|
||||
}
|
||||
"friendloginchanged", "forumschanged" -> {
|
||||
cacheDatabase.friendSearchCacheDao().clear()
|
||||
}
|
||||
"adultVerificationChanged", "moderationReportChanged", "userAccessChanged" -> {
|
||||
cacheDatabase.userProfileCacheDao().clear()
|
||||
}
|
||||
"falukantUpdateStatus", "falukantUpdateFamily", "falukantUpdateChurch", "falukantUpdateDebt",
|
||||
"children_update", "falukantUpdateProductionCertificate", "falukantBranchUpdate", "stock_change", "familychanged" -> {
|
||||
cacheDatabase.falukantStatusCacheDao().clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class RealtimeDebugState(
|
||||
val recentEvents: List<RealtimeSocketEvent> = emptyList(),
|
||||
val invalidationTags: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class RealtimeEventCenter @Inject constructor() {
|
||||
private val _debugState = MutableStateFlow(RealtimeDebugState())
|
||||
val debugState: StateFlow<RealtimeDebugState> = _debugState.asStateFlow()
|
||||
|
||||
fun handle(event: RealtimeSocketEvent) {
|
||||
val updatedTags = when (event.eventName) {
|
||||
"reloadmenu" -> setOf("menu", "dashboard")
|
||||
"friendloginchanged", "forumschanged", "topicschanged", "messageschanged" -> setOf("social", "community", "forum")
|
||||
"adultVerificationChanged", "moderationReportChanged", "userAccessChanged" -> setOf("account", "access")
|
||||
"falukantUpdateStatus", "falukantUpdateFamily", "falukantUpdateChurch", "falukantUpdateDebt",
|
||||
"children_update", "falukantUpdateProductionCertificate", "falukantBranchUpdate", "stock_change", "familychanged" -> setOf("falukant")
|
||||
else -> emptySet()
|
||||
}
|
||||
|
||||
_debugState.value = _debugState.value.copy(
|
||||
recentEvents = (_debugState.value.recentEvents + event).takeLast(20),
|
||||
invalidationTags = _debugState.value.invalidationTags + updatedTags,
|
||||
)
|
||||
}
|
||||
|
||||
fun acknowledgeInvalidations() {
|
||||
_debugState.value = _debugState.value.copy(invalidationTags = emptySet())
|
||||
}
|
||||
|
||||
fun clearHistory() {
|
||||
_debugState.value = RealtimeDebugState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface RealtimeManager {
|
||||
val connectionSnapshot: StateFlow<RealtimeConnectionSnapshot>
|
||||
val events: SharedFlow<RealtimeSocketEvent>
|
||||
|
||||
fun sync(context: RealtimeSessionContext)
|
||||
fun onAppForeground()
|
||||
fun onAppBackground()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.yourpart.nativeapp.core.realtime
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
|
||||
enum class RealtimeSource {
|
||||
BackendSocket,
|
||||
DaemonWebSocket,
|
||||
}
|
||||
|
||||
enum class RealtimeConnectionStatus {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class RealtimeSocketEvent(
|
||||
val source: RealtimeSource,
|
||||
val eventName: String,
|
||||
val payloadRaw: String? = null,
|
||||
)
|
||||
|
||||
data class RealtimeConnectionSnapshot(
|
||||
val backend: RealtimeConnectionStatus = RealtimeConnectionStatus.Disconnected,
|
||||
val daemon: RealtimeConnectionStatus = RealtimeConnectionStatus.Disconnected,
|
||||
)
|
||||
|
||||
data class DaemonEnvelope(
|
||||
val event: String,
|
||||
val payloadRaw: String? = null,
|
||||
)
|
||||
|
||||
data class RealtimeSessionContext(
|
||||
val session: AuthSession?,
|
||||
val isForeground: Boolean,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.yourpart.nativeapp.feature.admin
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Request
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class NavigationNodeDto(
|
||||
val path: String? = null,
|
||||
val children: Map<String, NavigationNodeDto> = emptyMap(),
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class AdminAccessRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun loadAdminPaths(userId: String): Result<Set<String>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/navigation/$userId")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val menu = json.decodeFromString<Map<String, NavigationNodeDto>>(result.value)
|
||||
menu["administration"]?.paths().orEmpty()
|
||||
}
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavigationNodeDto.paths(): Set<String> = buildSet {
|
||||
path?.let(::add)
|
||||
children.values.forEach { addAll(it.paths()) }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.yourpart.nativeapp.feature.admin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AdminAccessUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val paths: Set<String> = emptySet(),
|
||||
) {
|
||||
val hasAdminAccess: Boolean get() = paths.isNotEmpty()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AdminAccessViewModel @Inject constructor(
|
||||
private val repository: AdminAccessRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AdminAccessUiState())
|
||||
val state: StateFlow<AdminAccessUiState> = _state.asStateFlow()
|
||||
|
||||
fun refresh(userId: String?) {
|
||||
if (userId == null) {
|
||||
_state.value = AdminAccessUiState()
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true)
|
||||
repository.loadAdminPaths(userId)
|
||||
.onSuccess { paths -> _state.value = AdminAccessUiState(paths = paths) }
|
||||
.onFailure { _state.value = AdminAccessUiState() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.yourpart.nativeapp.feature.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
private val adminAreas = listOf(
|
||||
"/admin/users" to "Benutzer und Rechte",
|
||||
"/admin/rights" to "Berechtigungen",
|
||||
"/admin/moderation/reports" to "Moderationsmeldungen",
|
||||
"/admin/users/adult-verification" to "Altersverifikation",
|
||||
"/admin/users/erotic-moderation" to "Erotikmoderation",
|
||||
"/admin/forum" to "Forumverwaltung",
|
||||
"/admin/falukant" to "Falukant-Verwaltung",
|
||||
"/admin/services/status" to "Service-Status",
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AdminScreen(paths: Set<String>) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val visibleAreas = adminAreas.filter { (path, _) -> paths.any { it == path || it.startsWith("$path/") } }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard(
|
||||
"Administration",
|
||||
"Der Zugriff wird bei jeder Sitzung aus der vom Backend gefilterten Navigation abgeleitet. Das Build-Feature-Flag ist nur eine zusätzliche Schutzschicht.",
|
||||
)
|
||||
if (visibleAreas.isEmpty()) {
|
||||
YpEmptyState("Keine Berechtigung", "Für dieses Konto sind keine nativen Administrationsbereiche freigegeben.")
|
||||
} else {
|
||||
Text("Freigegebene Bereiche", style = MaterialTheme.typography.titleMedium)
|
||||
visibleAreas.forEach { (_, title) -> YpInfoCard(title, "Backend-Vertrag erfasst; serverseitige Rechteprüfung bleibt verbindlich.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.yourpart.nativeapp.feature.adult
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable data class AdultAccountDto(val isAdult: Boolean = false, val adultVerificationStatus: String = "none", val adultAccessEnabled: Boolean = false)
|
||||
@Serializable data class AdultFolderDto(val id: Long, val name: String = "")
|
||||
|
||||
@Singleton
|
||||
class AdultRepository @Inject constructor(
|
||||
private val executor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val sessions: SessionStore,
|
||||
private val config: AppConfig,
|
||||
@ApplicationContext private val context: android.content.Context,
|
||||
) {
|
||||
suspend fun account(): Result<AdultAccountDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val userId = sessions.currentSession?.user?.id ?: error("Keine aktive Sitzung.")
|
||||
val body = json.encodeToString(mapOf("userId" to userId)).toRequestBody("application/json".toMediaTypeOrNull())
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/settings/account").post(body).build()
|
||||
when (val result = executor.execute(request)) { is ApiResult.Success -> json.decodeFromString(result.value); is ApiResult.Failure -> error(result.error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun folders(): Result<List<AdultFolderDto>> = get("/api/socialnetwork/erotic/folders")
|
||||
|
||||
suspend fun submitVerification(uri: Uri, note: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val resolver: ContentResolver = context.contentResolver
|
||||
val type = resolver.getType(uri) ?: "application/octet-stream"
|
||||
require(type in setOf("image/jpeg", "image/png", "image/webp", "application/pdf")) { "Erlaubt sind JPEG, PNG, WebP oder PDF." }
|
||||
val file = File.createTempFile("adult-verification-", ".upload", context.cacheDir)
|
||||
resolver.openInputStream(uri).use { input -> requireNotNull(input) { "Datei konnte nicht gelesen werden." }.copyTo(file.outputStream()) }
|
||||
try {
|
||||
val body = MultipartBody.Builder().setType(MultipartBody.FORM)
|
||||
.addFormDataPart("note", note.trim())
|
||||
.addFormDataPart("document", "verification", file.asRequestBody(type.toMediaTypeOrNull()))
|
||||
.build()
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/settings/adult-verification/request").post(body).build()
|
||||
when (executor.execute(request)) { is ApiResult.Success -> Unit; is ApiResult.Failure -> error("Verifikationsantrag konnte nicht gesendet werden.") }
|
||||
} finally { file.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> get(path: String): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}$path").get().build()
|
||||
when (val result = executor.execute(request)) { is ApiResult.Success -> json.decodeFromString<T>(result.value); is ApiResult.Failure -> error(result.error.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.yourpart.nativeapp.feature.adult
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
@Composable fun AdultScreen(state: AdultUiState, onSubmit: (android.net.Uri, String) -> Unit) {
|
||||
val spacing = LocalYpSpacing.current; var note by remember { mutableStateOf("") }
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri != null) onSubmit(uri, note) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard("Geschützter Bereich", "Die App zeigt Inhalte nur bei aktivem Feature-Flag, bestätigter Volljährigkeit und serverseitig bestätigter Freischaltung.")
|
||||
when {
|
||||
state.loading -> Text("Prüfe Zugriffsstatus …")
|
||||
!state.account.isAdult -> YpEmptyState("Nicht verfügbar", "Dieser Bereich ist ausschließlich für volljährige Nutzer.")
|
||||
!state.account.adultAccessEnabled -> {
|
||||
Text("Freischaltstatus: ${state.account.adultVerificationStatus}", style = MaterialTheme.typography.titleMedium)
|
||||
YpTextField(note, { note = it }, "Optionale Nachricht")
|
||||
YpPrimaryButton("Nachweis auswählen", { picker.launch("application/pdf,image/jpeg,image/png,image/webp") }, Modifier.fillMaxWidth())
|
||||
}
|
||||
else -> {
|
||||
Text("Freigeschaltet", style = MaterialTheme.typography.titleMedium)
|
||||
if (state.folders.isEmpty()) YpEmptyState("Keine Inhalte", "Es sind derzeit keine geschützten Ordner verfügbar.")
|
||||
state.folders.forEach { folder -> YpInfoCard(folder.name.ifBlank { "Ordner ${folder.id}" }, "Geschützter Inhaltsordner") }
|
||||
}
|
||||
}
|
||||
state.message?.let { YpInfoCard("Status", it) }; state.error?.let { YpEmptyState("Fehler", it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.yourpart.nativeapp.feature.adult
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AdultUiState(val loading: Boolean = true, val account: AdultAccountDto = AdultAccountDto(), val folders: List<AdultFolderDto> = emptyList(), val message: String? = null, val error: String? = null)
|
||||
|
||||
@HiltViewModel class AdultViewModel @Inject constructor(private val repository: AdultRepository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AdultUiState()); val state: StateFlow<AdultUiState> = _state.asStateFlow()
|
||||
init { refresh() }
|
||||
fun refresh() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
repository.account().onSuccess { account ->
|
||||
_state.value = _state.value.copy(loading = false, account = account)
|
||||
if (account.adultAccessEnabled) repository.folders().onSuccess { folders -> _state.value = _state.value.copy(folders = folders) }.onFailure { e -> _state.value = _state.value.copy(error = e.message) }
|
||||
}.onFailure { e -> _state.value = _state.value.copy(loading = false, error = e.message) }
|
||||
}
|
||||
fun submit(uri: Uri, note: String) = viewModelScope.launch { repository.submitVerification(uri, note).onSuccess { _state.value = _state.value.copy(message = "Antrag eingereicht."); refresh() }.onFailure { e -> _state.value = _state.value.copy(error = e.message) } }
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.core.auth.model.ForgotPasswordRequest
|
||||
import de.yourpart.nativeapp.core.auth.model.LoginRequest
|
||||
import de.yourpart.nativeapp.core.auth.model.OAuthProviderDto
|
||||
import de.yourpart.nativeapp.core.auth.model.RegisterRequest
|
||||
import de.yourpart.nativeapp.core.auth.model.UserDto
|
||||
import de.yourpart.nativeapp.core.auth.storage.AuthSessionStore
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val sessionStore: AuthSessionStore,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
rememberMe: Boolean,
|
||||
): Result<UserDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(LoginRequest(username, password))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/login")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val user = json.decodeFromString<UserDto>(result.value)
|
||||
if (!user.active) {
|
||||
throw AuthException("Dieses Konto ist deaktiviert.")
|
||||
}
|
||||
sessionStore.saveSession(
|
||||
AuthSession(
|
||||
user = user,
|
||||
rememberMe = rememberMe,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
user
|
||||
}
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(
|
||||
email: String,
|
||||
username: String,
|
||||
password: String,
|
||||
language: String,
|
||||
): Result<UserDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(
|
||||
RegisterRequest(
|
||||
email = email,
|
||||
username = username,
|
||||
password = password,
|
||||
language = language,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/register")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<UserDto>(result.value)
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestPasswordReset(email: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val requestBody = json.encodeToString(ForgotPasswordRequest(email))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/forgot-password")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val payload = json.decodeFromString<ForgotPasswordResponse>(result.value)
|
||||
payload.message.ifBlank { "Password reset email sent" }
|
||||
}
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadOAuthProviders(): Result<List<OAuthProviderDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/oauth/providers")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<OAuthProvidersResponse>(result.value).providers
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun oauthStartUrl(provider: OAuthProviderDto): String =
|
||||
"${appConfig.apiBaseUrl}/api/auth/oauth/${provider.slug}/start?client=android"
|
||||
|
||||
suspend fun exchangeOAuth(code: String, state: String, issuer: String?): Result<UserDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = OAuthExchangeRequest(code = code, state = state, iss = issuer)
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/oauth/exchange")
|
||||
.post(json.encodeToString(payload).toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val user = json.decodeFromString<UserDto>(result.value)
|
||||
if (!user.active) throw AuthException("Dieses Konto ist deaktiviert.")
|
||||
sessionStore.saveSession(AuthSession(user = user, rememberMe = true, createdAt = System.currentTimeMillis()))
|
||||
user
|
||||
}
|
||||
is ApiResult.Failure -> throw AuthException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout() = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/auth/logout")
|
||||
.get()
|
||||
.build()
|
||||
requestExecutor.execute(request)
|
||||
}
|
||||
sessionStore.clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
class AuthException(message: String) : IllegalStateException(message)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class OAuthProvidersResponse(
|
||||
val providers: List<OAuthProviderDto> = emptyList(),
|
||||
)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class ForgotPasswordResponse(
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class OAuthExchangeRequest(
|
||||
val code: String,
|
||||
val state: String,
|
||||
val iss: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.core.auth.model.OAuthProviderDto
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
uiState: LoginUiState,
|
||||
oauthProviders: List<OAuthProviderDto>,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onRememberMeChange: (Boolean) -> Unit,
|
||||
onLogin: () -> Unit,
|
||||
onRegisterClick: () -> Unit,
|
||||
onForgotPasswordClick: () -> Unit,
|
||||
onOAuthLogin: (OAuthProviderDto) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
YpInfoCard(
|
||||
title = "YourPart",
|
||||
body = "Die native App startet hier fuer nicht eingeloggte Nutzer und fuehrt danach in die Dashboard- und Modulwelt.",
|
||||
)
|
||||
Text("Anmeldung", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Die native App nutzt denselben Backend-Login wie die Web-App: `/api/auth/login` mit Rueckgabe eines Users inklusive `authCode`.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
YpTextField(
|
||||
value = uiState.username,
|
||||
onValueChange = onUsernameChange,
|
||||
label = "Benutzername",
|
||||
)
|
||||
YpTextField(
|
||||
value = uiState.password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = "Passwort",
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = uiState.rememberMe,
|
||||
onCheckedChange = onRememberMeChange,
|
||||
)
|
||||
Text("Sitzung merken", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
if (!uiState.errorMessage.isNullOrBlank()) {
|
||||
YpEmptyState(
|
||||
title = "Anmeldung fehlgeschlagen",
|
||||
body = uiState.errorMessage,
|
||||
)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.isSubmitting) "Anmeldung..." else "Anmelden",
|
||||
onClick = onLogin,
|
||||
enabled = !uiState.isSubmitting,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
YpSecondaryButton(
|
||||
label = "Registrieren",
|
||||
onClick = onRegisterClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Passwort vergessen",
|
||||
onClick = onForgotPasswordClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
if (oauthProviders.isNotEmpty()) {
|
||||
YpInfoCard(
|
||||
title = "OAuth",
|
||||
body = "Konfigurierte Provider im Backend:",
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
oauthProviders.take(3).forEach { provider ->
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = { onOAuthLogin(provider) },
|
||||
label = { Text(provider.label) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
labelColor = YpColors.TextPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
YpInfoCard(
|
||||
title = "OAuth",
|
||||
body = "Im aktuellen Backend sind keine OAuth-Provider aktiv.",
|
||||
)
|
||||
}
|
||||
|
||||
YpInfoCard(
|
||||
title = "Auth-Header",
|
||||
body = "Nach erfolgreichem Login setzt der native Client automatisch `userid` und `authcode` fuer weitere API-Requests.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.core.auth.model.OAuthProviderDto
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class LoginUiState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val rememberMe: Boolean = true,
|
||||
val isSubmitting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
data class RegisterUiState(
|
||||
val email: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val language: String = "de",
|
||||
val isSubmitting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val successMessage: String? = null,
|
||||
)
|
||||
|
||||
data class PasswordResetUiState(
|
||||
val email: String = "",
|
||||
val isSubmitting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val successMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionStore: SessionStore,
|
||||
) : ViewModel() {
|
||||
private val _loginUiState = MutableStateFlow(LoginUiState())
|
||||
val loginUiState: StateFlow<LoginUiState> = _loginUiState.asStateFlow()
|
||||
|
||||
val session: StateFlow<AuthSession?> = sessionStore.session.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = sessionStore.currentSession,
|
||||
)
|
||||
|
||||
val sessionExpiredMessage: StateFlow<String?> = sessionStore.sessionExpiredMessage.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
private val _registerUiState = MutableStateFlow(RegisterUiState())
|
||||
val registerUiState: StateFlow<RegisterUiState> = _registerUiState.asStateFlow()
|
||||
|
||||
private val _passwordResetUiState = MutableStateFlow(PasswordResetUiState())
|
||||
val passwordResetUiState: StateFlow<PasswordResetUiState> = _passwordResetUiState.asStateFlow()
|
||||
|
||||
private val _oauthProviders = MutableStateFlow<List<OAuthProviderDto>>(emptyList())
|
||||
val oauthProviders: StateFlow<List<OAuthProviderDto>> = _oauthProviders.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
authRepository.loadOAuthProviders().onSuccess { providers ->
|
||||
_oauthProviders.value = providers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUsername(value: String) {
|
||||
_loginUiState.value = _loginUiState.value.copy(username = value, errorMessage = null)
|
||||
}
|
||||
|
||||
fun updatePassword(value: String) {
|
||||
_loginUiState.value = _loginUiState.value.copy(password = value, errorMessage = null)
|
||||
}
|
||||
|
||||
fun updateRememberMe(value: Boolean) {
|
||||
_loginUiState.value = _loginUiState.value.copy(rememberMe = value)
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val state = _loginUiState.value
|
||||
if (state.username.isBlank() || state.password.isBlank()) {
|
||||
_loginUiState.value = state.copy(errorMessage = "Bitte Benutzername und Passwort eingeben.")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_loginUiState.value = state.copy(isSubmitting = true, errorMessage = null)
|
||||
authRepository.login(
|
||||
username = state.username.trim(),
|
||||
password = state.password,
|
||||
rememberMe = state.rememberMe,
|
||||
).onSuccess {
|
||||
_loginUiState.value = LoginUiState(rememberMe = state.rememberMe)
|
||||
}.onFailure { error ->
|
||||
_loginUiState.value = state.copy(
|
||||
isSubmitting = false,
|
||||
errorMessage = error.message ?: "Die Anmeldung ist fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun oauthStartUrl(provider: OAuthProviderDto): String = authRepository.oauthStartUrl(provider)
|
||||
|
||||
fun handleOAuthCallback(code: String?, state: String?, issuer: String?, error: String?) {
|
||||
if (!error.isNullOrBlank()) {
|
||||
_loginUiState.value = _loginUiState.value.copy(errorMessage = "OAuth-Anmeldung wurde abgebrochen: $error")
|
||||
return
|
||||
}
|
||||
if (code.isNullOrBlank() || state.isNullOrBlank()) {
|
||||
_loginUiState.value = _loginUiState.value.copy(errorMessage = "OAuth-Callback enthält keinen gültigen Code oder Status.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_loginUiState.value = _loginUiState.value.copy(isSubmitting = true, errorMessage = null)
|
||||
authRepository.exchangeOAuth(code, state, issuer)
|
||||
.onSuccess { _loginUiState.value = LoginUiState(rememberMe = true) }
|
||||
.onFailure { callbackError ->
|
||||
_loginUiState.value = LoginUiState(
|
||||
rememberMe = true,
|
||||
errorMessage = callbackError.message ?: "OAuth-Anmeldung ist fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRegisterEmail(value: String) {
|
||||
_registerUiState.value = _registerUiState.value.copy(email = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun updateRegisterUsername(value: String) {
|
||||
_registerUiState.value = _registerUiState.value.copy(username = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun updateRegisterPassword(value: String) {
|
||||
_registerUiState.value = _registerUiState.value.copy(password = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun updateRegisterLanguage(value: String) {
|
||||
_registerUiState.value = _registerUiState.value.copy(language = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun register() {
|
||||
val state = _registerUiState.value
|
||||
if (state.email.isBlank() || state.username.isBlank() || state.password.isBlank()) {
|
||||
_registerUiState.value = state.copy(errorMessage = "Bitte E-Mail, Benutzername und Passwort eingeben.")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_registerUiState.value = state.copy(isSubmitting = true, errorMessage = null, successMessage = null)
|
||||
authRepository.register(
|
||||
email = state.email.trim(),
|
||||
username = state.username.trim(),
|
||||
password = state.password,
|
||||
language = state.language.trim().ifBlank { "de" },
|
||||
).onSuccess {
|
||||
_registerUiState.value = RegisterUiState(
|
||||
language = state.language.trim().ifBlank { "de" },
|
||||
successMessage = "Registrierung angelegt. Bitte prüfe Dein Postfach für die Aktivierung.",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_registerUiState.value = state.copy(
|
||||
isSubmitting = false,
|
||||
errorMessage = error.message ?: "Die Registrierung ist fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePasswordResetEmail(value: String) {
|
||||
_passwordResetUiState.value = _passwordResetUiState.value.copy(email = value, errorMessage = null, successMessage = null)
|
||||
}
|
||||
|
||||
fun requestPasswordReset() {
|
||||
val state = _passwordResetUiState.value
|
||||
if (state.email.isBlank()) {
|
||||
_passwordResetUiState.value = state.copy(errorMessage = "Bitte eine E-Mail-Adresse eingeben.")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_passwordResetUiState.value = state.copy(isSubmitting = true, errorMessage = null, successMessage = null)
|
||||
authRepository.requestPasswordReset(state.email.trim()).onSuccess {
|
||||
_passwordResetUiState.value = PasswordResetUiState(
|
||||
successMessage = "E-Mail zum Zurücksetzen wurde angefordert.",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_passwordResetUiState.value = state.copy(
|
||||
isSubmitting = false,
|
||||
errorMessage = error.message ?: "Der Passwort-Reset ist fehlgeschlagen.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout()
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeSessionExpiredMessage() {
|
||||
sessionStore.consumeSessionExpiredMessage()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun ForgotPasswordScreen(
|
||||
uiState: PasswordResetUiState,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onRequestReset: () -> Unit,
|
||||
onBackToLogin: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text("Passwort zurücksetzen", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Das Backend verschickt einen Reset-Link an die hinterlegte E-Mail-Adresse.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
YpTextField(value = uiState.email, onValueChange = onEmailChange, label = "E-Mail")
|
||||
|
||||
if (!uiState.errorMessage.isNullOrBlank()) {
|
||||
YpEmptyState(title = "Reset fehlgeschlagen", body = uiState.errorMessage)
|
||||
}
|
||||
if (!uiState.successMessage.isNullOrBlank()) {
|
||||
YpInfoCard(title = "Reset angefordert", body = uiState.successMessage)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.isSubmitting) "Sende..." else "Reset anfordern",
|
||||
onClick = onRequestReset,
|
||||
enabled = !uiState.isSubmitting,
|
||||
)
|
||||
|
||||
YpSecondaryButton(
|
||||
label = "Zurück zum Login",
|
||||
onClick = onBackToLogin,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.yourpart.nativeapp.feature.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
uiState: RegisterUiState,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLanguageChange: (String) -> Unit,
|
||||
onRegister: () -> Unit,
|
||||
onBackToLogin: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text("Registrierung", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Der Backend-Flow legt ein neues Konto an und verschickt eine Aktivierungs-E-Mail.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
|
||||
YpTextField(value = uiState.email, onValueChange = onEmailChange, label = "E-Mail")
|
||||
YpTextField(value = uiState.username, onValueChange = onUsernameChange, label = "Benutzername")
|
||||
YpTextField(value = uiState.password, onValueChange = onPasswordChange, label = "Passwort")
|
||||
YpTextField(value = uiState.language, onValueChange = onLanguageChange, label = "Sprache")
|
||||
|
||||
if (!uiState.errorMessage.isNullOrBlank()) {
|
||||
YpEmptyState(title = "Registrierung fehlgeschlagen", body = uiState.errorMessage)
|
||||
}
|
||||
if (!uiState.successMessage.isNullOrBlank()) {
|
||||
YpInfoCard(title = "Registrierung", body = uiState.successMessage)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.isSubmitting) "Registriere..." else "Registrieren",
|
||||
onClick = onRegister,
|
||||
enabled = !uiState.isSubmitting,
|
||||
)
|
||||
|
||||
YpSecondaryButton(
|
||||
label = "Zurück zum Login",
|
||||
onClick = onBackToLogin,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.yourpart.nativeapp.feature.chat
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomTypeDto(
|
||||
val id: Long = 0,
|
||||
val tr: String? = null,
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
val roomTypeId: Long? = null,
|
||||
val isPublic: Boolean = false,
|
||||
val isAdultOnly: Boolean = false,
|
||||
val genderRestrictionId: Long? = null,
|
||||
val minAge: Int? = null,
|
||||
val maxAge: Int? = null,
|
||||
val friendsOfOwnerOnly: Boolean? = null,
|
||||
val requiredUserRightId: Long? = null,
|
||||
val roomType: ChatRoomTypeDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomCreateOptionsDto(
|
||||
val rights: List<ChatRoomRightDto> = emptyList(),
|
||||
val roomTypes: List<ChatRoomTypeOptionDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomRightDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatRoomTypeOptionDto(
|
||||
val id: Long,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OneToOneMessageDto(
|
||||
val timestamp: Long = 0,
|
||||
val sender: String = "",
|
||||
val recipient: String = "",
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatRegisterRequestDto(
|
||||
val gender: String,
|
||||
val age: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatAgeRangeDto(
|
||||
val min: Int,
|
||||
val max: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatMatchRequestDto(
|
||||
val genders: List<String>,
|
||||
val age: RandomChatAgeRangeDto,
|
||||
val id: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatPartnerDto(
|
||||
val id: String = "",
|
||||
val gender: String? = null,
|
||||
val age: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatMatchResponseDto(
|
||||
val status: String = "",
|
||||
val user: RandomChatPartnerDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatSendMessageRequestDto(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RandomChatMessageDto(
|
||||
val from: String = "",
|
||||
val to: String = "",
|
||||
val text: String = "",
|
||||
val activity: String? = null,
|
||||
val timestamp: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatIncidentReportDto(
|
||||
val context: String,
|
||||
val reporterHashedId: String? = null,
|
||||
val reporterRandomId: String? = null,
|
||||
val reporterUsername: String? = null,
|
||||
val offenderHashedId: String? = null,
|
||||
val offenderRandomId: String? = null,
|
||||
val offenderUsername: String? = null,
|
||||
val incidentAt: String,
|
||||
val chatHistory: List<ChatHistoryEntryDto>,
|
||||
val metadata: Map<String, JsonElement>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatHistoryEntryDto(
|
||||
val type: String = "",
|
||||
val user: String = "",
|
||||
val text: String = "",
|
||||
val timestamp: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,279 @@
|
||||
package de.yourpart.nativeapp.feature.chat
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ChatRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val sessionStore: SessionStore,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
private val userId: String
|
||||
get() = sessionStore.currentSession?.user?.id.orEmpty()
|
||||
|
||||
suspend fun loadPublicRooms(): Result<List<ChatRoomDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/rooms")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<ChatRoomDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadOwnRooms(): Result<List<ChatRoomDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/my-rooms")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<ChatRoomDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteOwnRoom(roomId: Long): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/my-rooms/$roomId")
|
||||
.delete()
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Raum konnte nicht geloescht werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCreateOptions(): Result<ChatRoomCreateOptionsDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/room-create-options")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<ChatRoomCreateOptionsDto>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun initOneToOne(partnerHashId: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(mapOf("partnerHashId" to partnerHashId))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/initOneToOne")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("One-to-one Chat konnte nicht initialisiert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun registerRandomUser(gender: String, age: Int): Result<String> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(RandomChatRegisterRequestDto(gender = gender, age = age))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/register")
|
||||
.post(body)
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val payload = json.decodeFromString<Map<String, String>>(result.value)
|
||||
payload["id"].orEmpty().takeIf { it.isNotBlank() }
|
||||
?: throw IllegalStateException("Random-Chat-Registrierung lieferte keine ID.")
|
||||
}
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findRandomMatch(
|
||||
genders: List<String>,
|
||||
ageFrom: Int,
|
||||
ageTo: Int,
|
||||
id: String,
|
||||
): Result<RandomChatMatchResponseDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(
|
||||
RandomChatMatchRequestDto(
|
||||
genders = genders,
|
||||
age = RandomChatAgeRangeDto(min = ageFrom, max = ageTo),
|
||||
id = id,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/findMatch")
|
||||
.post(body)
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<RandomChatMatchResponseDto>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendRandomMessage(from: String, to: String, text: String): Result<String> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(
|
||||
RandomChatSendMessageRequestDto(
|
||||
from = from,
|
||||
to = to,
|
||||
text = text,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/sendMessage")
|
||||
.post(body)
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val payload = json.decodeFromString<Map<String, String>>(result.value)
|
||||
payload["text"].orEmpty()
|
||||
}
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadRandomMessages(to: String, from: String): Result<List<RandomChatMessageDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/getMessages")
|
||||
.post(
|
||||
json.encodeToString(
|
||||
mapOf(
|
||||
"to" to to,
|
||||
"from" to from,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType()),
|
||||
)
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<RandomChatMessageDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun leaveRandomChat(id: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/leave")
|
||||
.post(json.encodeToString(mapOf("id" to id)).toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Random-Chat konnte nicht verlassen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exitRandomChat(id: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/exit")
|
||||
.post(json.encodeToString(mapOf("id" to id)).toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Random-Chat konnte nicht beendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadOneToOneHistory(partnerHashId: String): Result<List<OneToOneMessageDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/oneToOne/messageHistory?user1HashId=$userId&user2HashId=$partnerHashId")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> {
|
||||
val payload = json.decodeFromString<OneToOneHistoryResponse>(result.value)
|
||||
payload.history
|
||||
}
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendOneToOneMessage(partnerHashId: String, message: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(
|
||||
mapOf(
|
||||
"user1HashId" to userId,
|
||||
"user2HashId" to partnerHashId,
|
||||
"message" to message,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/oneToOne/sendMessage")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Nachricht konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportIncident(
|
||||
context: String,
|
||||
offenderUsername: String? = null,
|
||||
chatHistory: List<ChatHistoryEntryDto>,
|
||||
metadata: Map<String, String> = emptyMap(),
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val session = sessionStore.currentSession
|
||||
val body = json.encodeToString(
|
||||
ChatIncidentReportDto(
|
||||
context = context,
|
||||
reporterHashedId = session?.user?.id,
|
||||
reporterUsername = session?.user?.username,
|
||||
offenderUsername = offenderUsername,
|
||||
incidentAt = java.time.Instant.now().toString(),
|
||||
chatHistory = chatHistory,
|
||||
metadata = metadata.mapValues { kotlinx.serialization.json.JsonPrimitive(it.value) },
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/chat/report")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Meldung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
private data class OneToOneHistoryResponse(
|
||||
val history: List<OneToOneMessageDto> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,414 @@
|
||||
package de.yourpart.nativeapp.feature.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
uiState: ChatUiState,
|
||||
onSelectTab: (ChatTab) -> Unit,
|
||||
onUpdatePartnerHashId: (String) -> Unit,
|
||||
onUpdateDirectMessage: (String) -> Unit,
|
||||
onLoadDirectHistory: () -> Unit,
|
||||
onSendDirectMessage: () -> Unit,
|
||||
onReportDirectChat: () -> Unit,
|
||||
onDeleteRoom: (Long) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onUpdateRandomAge: (String) -> Unit,
|
||||
onUpdateRandomGender: (String) -> Unit,
|
||||
onUpdateRandomAgeFrom: (String) -> Unit,
|
||||
onUpdateRandomAgeTo: (String) -> Unit,
|
||||
onToggleRandomSearchGender: (String) -> Unit,
|
||||
onToggleRandomCamOnly: () -> Unit,
|
||||
onToggleRandomShowCam: () -> Unit,
|
||||
onToggleRandomAutosearch: () -> Unit,
|
||||
onUpdateRandomInput: (String) -> Unit,
|
||||
onStartRandomChat: () -> Unit,
|
||||
onStopRandomChat: () -> Unit,
|
||||
onNextRandomUser: () -> Unit,
|
||||
onSendRandomMessage: () -> Unit,
|
||||
onReportRandomChat: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Chat",
|
||||
body = "Nativer Chat mit Räumen, Direktnachrichten und Random-Chat. Die Web-UI wird dafür nicht mehr benötigt.",
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
ChatTab.entries.forEach { tab ->
|
||||
FilterChip(
|
||||
selected = uiState.selectedTab == tab,
|
||||
onClick = { onSelectTab(tab) },
|
||||
label = { Text(tab.title) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
selectedContainerColor = YpColors.PrimarySoft,
|
||||
labelColor = YpColors.TextPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.errorMessage?.let { error ->
|
||||
item { YpEmptyState(title = "Chat-Fehler", body = error) }
|
||||
}
|
||||
|
||||
uiState.statusMessage?.let { message ->
|
||||
item { YpInfoCard(title = "Hinweis", body = message, accentColor = YpColors.SurfaceAccent) }
|
||||
}
|
||||
|
||||
when (uiState.selectedTab) {
|
||||
ChatTab.ROOMS -> {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Öffentliche Räume",
|
||||
body = "${uiState.publicRooms.size} Raum/Räume verfügbar",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
if (uiState.isLoading) {
|
||||
item { YpInfoCard(title = "Lade Räume", body = "Die Raumliste wird geladen.") }
|
||||
} else if (uiState.publicRooms.isEmpty()) {
|
||||
item { YpEmptyState(title = "Keine Räume", body = "Es sind aktuell keine öffentlichen Räume sichtbar.") }
|
||||
} else {
|
||||
items(uiState.publicRooms, key = { it.id }) { room ->
|
||||
RoomCard(room = room, showDelete = false, onDelete = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatTab.OWN -> {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Eigene Räume",
|
||||
body = "${uiState.ownRooms.size} eigener Raum/eigene Räume",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
if (uiState.isLoading) {
|
||||
item { YpInfoCard(title = "Lade Räume", body = "Deine Räume werden geladen.") }
|
||||
} else if (uiState.ownRooms.isEmpty()) {
|
||||
item { YpEmptyState(title = "Keine eigenen Räume", body = "Du hast noch keine Räume angelegt.") }
|
||||
} else {
|
||||
items(uiState.ownRooms, key = { it.id }) { room ->
|
||||
RoomCard(room = room, showDelete = true, onDelete = onDeleteRoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatTab.DIRECT -> {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.partnerHashId,
|
||||
onValueChange = onUpdatePartnerHashId,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Partner Hash-ID") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.directMessage,
|
||||
onValueChange = onUpdateDirectMessage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Nachricht") },
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Verlauf laden",
|
||||
onClick = onLoadDirectHistory,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
YpPrimaryButton(
|
||||
label = "Senden",
|
||||
onClick = onSendDirectMessage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.partnerHashId.isNotBlank() && uiState.directMessage.isNotBlank(),
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Melden",
|
||||
onClick = onReportDirectChat,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.directHistory.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Verlauf",
|
||||
body = "${uiState.directHistory.size} Nachricht(en)",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.isLoading) {
|
||||
item { YpInfoCard(title = "Lade Verlauf", body = "Der Chatverlauf wird geladen.") }
|
||||
} else if (uiState.directHistory.isEmpty()) {
|
||||
item { YpEmptyState(title = "Kein Verlauf", body = "Lade zuerst den Verlauf oder sende eine Nachricht.") }
|
||||
} else {
|
||||
items(uiState.directHistory) { message ->
|
||||
DirectMessageCard(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatTab.RANDOM -> {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard(
|
||||
title = "Random Chat",
|
||||
body = if (uiState.randomChatRunning) {
|
||||
if (uiState.randomSearching) "Suche nach einem Chatpartner." else "Verbunden mit einem Chatpartner."
|
||||
} else {
|
||||
"Startet einen zufälligen 1:1-Chat über das Backend."
|
||||
},
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.randomAge.toString(),
|
||||
onValueChange = onUpdateRandomAge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Alter") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.randomGender,
|
||||
onValueChange = onUpdateRandomGender,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Geschlecht") },
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.randomAgeFrom.toString(),
|
||||
onValueChange = onUpdateRandomAgeFrom,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Suche ab") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.randomAgeTo.toString(),
|
||||
onValueChange = onUpdateRandomAgeTo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Suche bis") },
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
FilterChip(
|
||||
selected = uiState.randomSearchMale,
|
||||
onClick = { onToggleRandomSearchGender("m") },
|
||||
label = { Text("Männer") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = uiState.randomSearchFemale,
|
||||
onClick = { onToggleRandomSearchGender("f") },
|
||||
label = { Text("Frauen") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = uiState.randomCamOnly,
|
||||
onClick = onToggleRandomCamOnly,
|
||||
label = { Text("Cam only") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = uiState.randomShowCam,
|
||||
onClick = onToggleRandomShowCam,
|
||||
label = { Text("Cam zeigen") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = uiState.randomAutosearch,
|
||||
onClick = onToggleRandomAutosearch,
|
||||
label = { Text("Auto") },
|
||||
)
|
||||
}
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.randomChatRunning) "Neu suchen" else "Random Chat starten",
|
||||
onClick = onStartRandomChat,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Beenden",
|
||||
onClick = onStopRandomChat,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.randomChatRunning || uiState.randomUserId.isNotBlank(),
|
||||
)
|
||||
if (uiState.randomChatRunning) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpSecondaryButton(
|
||||
label = "Nächster",
|
||||
onClick = onNextRandomUser,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.randomPartner != null,
|
||||
)
|
||||
YpSecondaryButton(
|
||||
label = "Melden",
|
||||
onClick = onReportRandomChat,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.randomLastPartner != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Random Verlauf",
|
||||
body = "${uiState.randomMessages.size} Nachricht(en)",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.randomChatRunning && uiState.randomPartner != null) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.randomInput,
|
||||
onValueChange = onUpdateRandomInput,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Nachricht") },
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
)
|
||||
YpPrimaryButton(
|
||||
label = "Senden",
|
||||
onClick = onSendRandomMessage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.randomInput.isNotBlank(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.randomMessages.isEmpty()) {
|
||||
item { YpEmptyState(title = "Kein Verlauf", body = "Starte die Suche oder warte auf neue Nachrichten.") }
|
||||
} else {
|
||||
items(uiState.randomMessages) { message ->
|
||||
RandomMessageCard(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpSecondaryButton(label = "Aktualisieren", onClick = onRefresh, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomCard(room: ChatRoomDto, showDelete: Boolean, onDelete: ((Long) -> Unit)?) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Text(room.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Typ: ${room.roomType?.name ?: room.roomType?.tr ?: room.roomTypeId?.toString() ?: "-"}",
|
||||
color = YpColors.TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
buildString {
|
||||
append(if (room.isPublic) "öffentlich" else "privat")
|
||||
if (room.isAdultOnly) append(" · 18+")
|
||||
room.minAge?.let { append(" · ab $it") }
|
||||
room.maxAge?.let { append(" · bis $it") }
|
||||
},
|
||||
color = YpColors.TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (showDelete && onDelete != null) {
|
||||
YpSecondaryButton(
|
||||
label = "Löschen",
|
||||
onClick = { onDelete(room.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectMessageCard(message: OneToOneMessageDto) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Text(message.sender, style = MaterialTheme.typography.titleSmall)
|
||||
Text(message.message, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(message.timestamp.toString(), style = MaterialTheme.typography.bodySmall, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RandomMessageCard(message: RandomChatMessageDto) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Text(
|
||||
message.from.ifBlank { "System" },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
message.activity ?: message.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 6,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
message.timestamp?.let {
|
||||
Text(it.toString(), style = MaterialTheme.typography.bodySmall, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package de.yourpart.nativeapp.feature.chat
|
||||
|
||||
import de.yourpart.nativeapp.core.auth.storage.SessionStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class ChatTab(val title: String) {
|
||||
ROOMS("Räume"),
|
||||
OWN("Eigene Räume"),
|
||||
DIRECT("1:1"),
|
||||
RANDOM("Random"),
|
||||
}
|
||||
|
||||
data class ChatUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val errorMessage: String? = null,
|
||||
val statusMessage: String? = null,
|
||||
val selectedTab: ChatTab = ChatTab.ROOMS,
|
||||
val publicRooms: List<ChatRoomDto> = emptyList(),
|
||||
val ownRooms: List<ChatRoomDto> = emptyList(),
|
||||
val createOptions: ChatRoomCreateOptionsDto = ChatRoomCreateOptionsDto(),
|
||||
val partnerHashId: String = "",
|
||||
val directHistory: List<OneToOneMessageDto> = emptyList(),
|
||||
val directMessage: String = "",
|
||||
val randomChatRunning: Boolean = false,
|
||||
val randomSearching: Boolean = false,
|
||||
val randomUserId: String = "",
|
||||
val randomAge: Int = 18,
|
||||
val randomGender: String = "f",
|
||||
val randomAgeFrom: Int = 18,
|
||||
val randomAgeTo: Int = 150,
|
||||
val randomSearchMale: Boolean = true,
|
||||
val randomSearchFemale: Boolean = true,
|
||||
val randomCamOnly: Boolean = false,
|
||||
val randomShowCam: Boolean = false,
|
||||
val randomAutosearch: Boolean = false,
|
||||
val randomInput: String = "",
|
||||
val randomMessages: List<RandomChatMessageDto> = emptyList(),
|
||||
val randomPartner: RandomChatPartnerDto? = null,
|
||||
val randomLastPartner: RandomChatPartnerDto? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
private val sessionStore: SessionStore,
|
||||
) : ViewModel() {
|
||||
private val _uiState = kotlinx.coroutines.flow.MutableStateFlow(ChatUiState())
|
||||
val uiState: kotlinx.coroutines.flow.StateFlow<ChatUiState> = _uiState
|
||||
|
||||
private var directPollingJob: Job? = null
|
||||
private var randomSearchJob: Job? = null
|
||||
private var randomPollingJob: Job? = null
|
||||
|
||||
init {
|
||||
applySessionDefaults()
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stopDirectPolling()
|
||||
stopRandomPolling()
|
||||
stopRandomSearch()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun applySessionDefaults() {
|
||||
val user = sessionStore.currentSession?.user ?: return
|
||||
val genderParam = user.param.firstOrNull { it.name == "gender" }?.value
|
||||
val birthdateParam = user.param.firstOrNull { it.name == "birthdate" }?.value
|
||||
val age = birthdateParam?.let { parseAge(it) } ?: 18
|
||||
val gender = when (genderParam) {
|
||||
"1", "m", "male", "männlich", "maennlich" -> "m"
|
||||
else -> "f"
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomAge = age.coerceAtLeast(18),
|
||||
randomGender = gender,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAge(birthdate: String): Int {
|
||||
val birth = runCatching { java.time.LocalDate.parse(birthdate) }.getOrNull() ?: return 18
|
||||
return java.time.Period.between(birth, java.time.LocalDate.now()).years.coerceAtLeast(18)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, statusMessage = null)
|
||||
val publicRooms = repository.loadPublicRooms()
|
||||
val ownRooms = repository.loadOwnRooms()
|
||||
val createOptions = repository.loadCreateOptions()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
publicRooms = publicRooms.getOrDefault(emptyList()),
|
||||
ownRooms = ownRooms.getOrDefault(emptyList()),
|
||||
createOptions = createOptions.getOrDefault(ChatRoomCreateOptionsDto()),
|
||||
errorMessage = publicRooms.exceptionOrNull()?.message
|
||||
?: ownRooms.exceptionOrNull()?.message
|
||||
?: createOptions.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTab(tab: ChatTab) {
|
||||
_uiState.value = _uiState.value.copy(selectedTab = tab)
|
||||
if (tab == ChatTab.DIRECT) {
|
||||
startDirectPolling()
|
||||
} else {
|
||||
stopDirectPolling()
|
||||
}
|
||||
if (tab != ChatTab.RANDOM) {
|
||||
stopRandomPolling()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePartnerHashId(value: String) {
|
||||
_uiState.value = _uiState.value.copy(partnerHashId = value.trim())
|
||||
stopDirectPolling()
|
||||
}
|
||||
|
||||
fun updateDirectMessage(value: String) {
|
||||
_uiState.value = _uiState.value.copy(directMessage = value)
|
||||
}
|
||||
|
||||
fun loadDirectHistory() {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
if (partner.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = "Partner-Hash fehlt.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.initOneToOne(partner)
|
||||
repository.loadOneToOneHistory(partner)
|
||||
.onSuccess { history ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
directHistory = history,
|
||||
statusMessage = "Verlauf geladen.",
|
||||
)
|
||||
startDirectPolling()
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Verlauf konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendDirectMessage() {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
val message = _uiState.value.directMessage.trim()
|
||||
if (partner.isBlank() || message.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = "Partner und Nachricht sind erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, statusMessage = null)
|
||||
repository.sendOneToOneMessage(partner, message)
|
||||
.onSuccess {
|
||||
_uiState.value = _uiState.value.copy(directMessage = "", isLoading = false, statusMessage = "Nachricht gesendet.")
|
||||
refreshDirectHistory()
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Nachricht konnte nicht gesendet werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshDirectHistory() {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
if (partner.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
repository.loadOneToOneHistory(partner)
|
||||
.onSuccess { history ->
|
||||
_uiState.value = _uiState.value.copy(directHistory = history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDirectPolling() {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
if (partner.isBlank()) return
|
||||
directPollingJob?.cancel()
|
||||
directPollingJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
delay(4000)
|
||||
val currentPartner = _uiState.value.partnerHashId.trim()
|
||||
if (currentPartner.isBlank()) continue
|
||||
repository.loadOneToOneHistory(currentPartner)
|
||||
.onSuccess { history ->
|
||||
_uiState.value = _uiState.value.copy(directHistory = history)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopDirectPolling() {
|
||||
directPollingJob?.cancel()
|
||||
directPollingJob = null
|
||||
}
|
||||
|
||||
fun updateRandomAge(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomAge = value.toIntOrNull()?.coerceAtLeast(18) ?: _uiState.value.randomAge)
|
||||
}
|
||||
|
||||
fun updateRandomGender(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomGender = value)
|
||||
}
|
||||
|
||||
fun updateRandomAgeFrom(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomAgeFrom = value.toIntOrNull()?.coerceAtLeast(18) ?: _uiState.value.randomAgeFrom)
|
||||
}
|
||||
|
||||
fun updateRandomAgeTo(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomAgeTo = value.toIntOrNull()?.coerceAtLeast(18) ?: _uiState.value.randomAgeTo)
|
||||
}
|
||||
|
||||
fun toggleRandomSearchGender(value: String) {
|
||||
val state = _uiState.value
|
||||
when (value) {
|
||||
"m" -> _uiState.value = state.copy(randomSearchMale = !state.randomSearchMale)
|
||||
"f" -> _uiState.value = state.copy(randomSearchFemale = !state.randomSearchFemale)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRandomCamOnly() {
|
||||
_uiState.value = _uiState.value.copy(randomCamOnly = !_uiState.value.randomCamOnly)
|
||||
}
|
||||
|
||||
fun toggleRandomShowCam() {
|
||||
_uiState.value = _uiState.value.copy(randomShowCam = !_uiState.value.randomShowCam)
|
||||
}
|
||||
|
||||
fun toggleRandomAutosearch() {
|
||||
_uiState.value = _uiState.value.copy(randomAutosearch = !_uiState.value.randomAutosearch)
|
||||
}
|
||||
|
||||
fun updateRandomInput(value: String) {
|
||||
_uiState.value = _uiState.value.copy(randomInput = value)
|
||||
}
|
||||
|
||||
fun startRandomChat() {
|
||||
viewModelScope.launch {
|
||||
if (_uiState.value.randomChatRunning || _uiState.value.randomUserId.isNotBlank()) {
|
||||
val previousId = _uiState.value.randomUserId.trim()
|
||||
stopRandomPolling()
|
||||
stopRandomSearch()
|
||||
if (previousId.isNotBlank()) {
|
||||
repository.leaveRandomChat(previousId)
|
||||
repository.exitRandomChat(previousId)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomChatRunning = false,
|
||||
randomSearching = false,
|
||||
randomUserId = "",
|
||||
randomPartner = null,
|
||||
randomMessages = emptyList(),
|
||||
randomInput = "",
|
||||
)
|
||||
}
|
||||
val state = _uiState.value
|
||||
val age = state.randomAge.coerceAtLeast(18)
|
||||
val gender = if (state.randomGender.isBlank()) "f" else state.randomGender
|
||||
repository.registerRandomUser(gender, age)
|
||||
.onSuccess { id ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomUserId = id,
|
||||
randomChatRunning = true,
|
||||
randomSearching = true,
|
||||
randomMessages = emptyList(),
|
||||
randomPartner = null,
|
||||
randomLastPartner = null,
|
||||
statusMessage = "Random-Chat gestartet.",
|
||||
errorMessage = null,
|
||||
)
|
||||
startRandomSearchLoop()
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Random-Chat konnte nicht gestartet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRandomChat() {
|
||||
viewModelScope.launch {
|
||||
val userId = _uiState.value.randomUserId.trim()
|
||||
if (userId.isNotBlank()) {
|
||||
repository.leaveRandomChat(userId)
|
||||
repository.exitRandomChat(userId)
|
||||
}
|
||||
stopRandomPolling()
|
||||
stopRandomSearch()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomChatRunning = false,
|
||||
randomSearching = false,
|
||||
randomUserId = "",
|
||||
randomPartner = null,
|
||||
randomMessages = emptyList(),
|
||||
randomInput = "",
|
||||
statusMessage = "Random-Chat beendet.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRandomSearchLoop() {
|
||||
stopRandomSearch()
|
||||
randomSearchJob = viewModelScope.launch {
|
||||
while (isActive && _uiState.value.randomSearching) {
|
||||
val state = _uiState.value
|
||||
val userId = state.randomUserId.trim()
|
||||
if (userId.isBlank()) {
|
||||
_uiState.value = state.copy(randomSearching = false, errorMessage = "Random-Chat-ID fehlt.")
|
||||
return@launch
|
||||
}
|
||||
val genders = buildList {
|
||||
if (state.randomSearchMale) add("m")
|
||||
if (state.randomSearchFemale) add("f")
|
||||
}.ifEmpty { listOf("m", "f") }
|
||||
repository.findRandomMatch(
|
||||
genders = genders,
|
||||
ageFrom = state.randomAgeFrom.coerceAtLeast(18),
|
||||
ageTo = state.randomAgeTo.coerceAtLeast(state.randomAgeFrom.coerceAtLeast(18)),
|
||||
id = userId,
|
||||
).onSuccess { result ->
|
||||
if (result.status == "matched" && result.user != null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomSearching = false,
|
||||
randomPartner = result.user,
|
||||
randomLastPartner = result.user,
|
||||
randomMessages = listOf(
|
||||
RandomChatMessageDto(
|
||||
from = "system",
|
||||
text = "Chatpartner gefunden: ${result.user.gender ?: "?"}, ${result.user.age ?: "?"}",
|
||||
),
|
||||
),
|
||||
statusMessage = "Chatpartner gefunden.",
|
||||
)
|
||||
refreshRandomMessages()
|
||||
startRandomPolling()
|
||||
return@launch
|
||||
}
|
||||
if (!_uiState.value.randomAutosearch) {
|
||||
_uiState.value = _uiState.value.copy(statusMessage = "Warte auf Chatpartner...")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomSearching = false,
|
||||
errorMessage = error.message ?: "Random-Chat-Suche fehlgeschlagen.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRandomSearch() {
|
||||
randomSearchJob?.cancel()
|
||||
randomSearchJob = null
|
||||
}
|
||||
|
||||
private fun startRandomPolling() {
|
||||
stopRandomPolling()
|
||||
randomPollingJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
delay(2500)
|
||||
refreshRandomMessages()
|
||||
if (_uiState.value.randomPartner == null) {
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRandomPolling() {
|
||||
randomPollingJob?.cancel()
|
||||
randomPollingJob = null
|
||||
}
|
||||
|
||||
private fun refreshRandomMessages() {
|
||||
val partner = _uiState.value.randomPartner ?: return
|
||||
val userId = _uiState.value.randomUserId.trim()
|
||||
if (userId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
repository.loadRandomMessages(userId, partner.id)
|
||||
.onSuccess { messages ->
|
||||
val activity = messages.firstOrNull { it.activity?.isNotBlank() == true }?.activity
|
||||
val cleanMessages = messages.filter { it.activity.isNullOrBlank() }
|
||||
val statusMessage = when (activity) {
|
||||
"otheruserleft" -> "Der andere Nutzer hat den Random-Chat verlassen."
|
||||
else -> null
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomMessages = cleanMessages,
|
||||
randomPartner = if (activity == "otheruserleft") null else partner,
|
||||
statusMessage = statusMessage ?: _uiState.value.statusMessage,
|
||||
)
|
||||
if (activity == "otheruserleft") {
|
||||
stopRandomPolling()
|
||||
if (_uiState.value.randomAutosearch) {
|
||||
_uiState.value = _uiState.value.copy(randomSearching = true)
|
||||
startRandomSearchLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendRandomMessage() {
|
||||
val state = _uiState.value
|
||||
val partner = state.randomPartner ?: return
|
||||
val message = state.randomInput.trim()
|
||||
val userId = state.randomUserId.trim()
|
||||
if (userId.isBlank() || message.isBlank()) {
|
||||
_uiState.value = state.copy(errorMessage = "Nachricht und Partner sind erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.sendRandomMessage(userId, partner.id, message)
|
||||
.onSuccess {
|
||||
_uiState.value = _uiState.value.copy(randomInput = "")
|
||||
refreshRandomMessages()
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Nachricht konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun nextRandomUser() {
|
||||
viewModelScope.launch {
|
||||
val userId = _uiState.value.randomUserId.trim()
|
||||
if (userId.isBlank()) return@launch
|
||||
repository.leaveRandomChat(userId)
|
||||
repository.exitRandomChat(userId)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
randomPartner = null,
|
||||
randomMessages = listOf(
|
||||
RandomChatMessageDto(
|
||||
from = "system",
|
||||
text = "Auf der Suche nach dem nächsten Partner.",
|
||||
),
|
||||
),
|
||||
randomSearching = true,
|
||||
)
|
||||
startRandomSearchLoop()
|
||||
}
|
||||
}
|
||||
|
||||
fun reportRandomChat() {
|
||||
val partner = _uiState.value.randomLastPartner ?: return
|
||||
val history = _uiState.value.randomMessages.map {
|
||||
ChatHistoryEntryDto(
|
||||
type = if (it.activity.isNullOrBlank()) "message" else "system",
|
||||
user = if (it.from == _uiState.value.randomUserId) "self" else it.from,
|
||||
text = it.activity ?: it.text,
|
||||
timestamp = it.timestamp,
|
||||
)
|
||||
}.filter { it.text.isNotBlank() }
|
||||
viewModelScope.launch {
|
||||
repository.reportIncident(
|
||||
context = "random_chat",
|
||||
offenderUsername = "Random-${partner.id.take(8)}",
|
||||
chatHistory = history,
|
||||
metadata = mapOf(
|
||||
"randomPartnerId" to partner.id,
|
||||
"randomPartnerGender" to (partner.gender ?: ""),
|
||||
"randomPartnerAge" to (partner.age?.toString() ?: ""),
|
||||
).filterValues { it.isNotBlank() },
|
||||
).onSuccess {
|
||||
_uiState.value = _uiState.value.copy(statusMessage = "Meldung wurde gesendet.")
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Meldung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRoom(roomId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteOwnRoom(roomId)
|
||||
.onSuccess { refresh() }
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Raum konnte nicht geloescht werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reportDirectChat(reason: String) {
|
||||
val partner = _uiState.value.partnerHashId.trim()
|
||||
val history = _uiState.value.directHistory.map {
|
||||
ChatHistoryEntryDto(
|
||||
type = "message",
|
||||
user = it.sender,
|
||||
text = it.message,
|
||||
timestamp = it.timestamp,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.reportIncident(
|
||||
context = "one_to_one",
|
||||
offenderUsername = partner,
|
||||
chatHistory = history,
|
||||
metadata = mapOf("partnerHashId" to partner, "reason" to reason),
|
||||
).onSuccess {
|
||||
_uiState.value = _uiState.value.copy(statusMessage = "Meldung wurde gesendet.")
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Meldung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package de.yourpart.nativeapp.feature.falukant
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
data class FalukantStatusDto(
|
||||
val characterName: String = "",
|
||||
val age: Int? = null,
|
||||
val health: Double? = null,
|
||||
val money: Double? = null,
|
||||
val relationship: String? = null,
|
||||
val childrenCount: Int = 0,
|
||||
val unreadNotifications: Int = 0,
|
||||
val debtorsPrisonActive: Boolean = false,
|
||||
val inDebtorsPrison: Boolean = false,
|
||||
)
|
||||
|
||||
data class FalukantBranchDto(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val cityName: String = "",
|
||||
val typeName: String = "",
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class FalukantNotificationDto(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val createdAt: String = "",
|
||||
val shown: Boolean = false,
|
||||
)
|
||||
|
||||
data class FalukantFamilyDto(
|
||||
val partnerName: String = "",
|
||||
val relationship: String = "",
|
||||
val children: List<String> = emptyList(),
|
||||
val lovers: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class FalukantBankDto(
|
||||
val money: Double? = null,
|
||||
val totalDebt: Double? = null,
|
||||
val maxCredit: Double? = null,
|
||||
val availableCredit: Double? = null,
|
||||
val activeCredits: Int = 0,
|
||||
val debtorsPrisonActive: Boolean = false,
|
||||
val inDebtorsPrison: Boolean = false,
|
||||
)
|
||||
|
||||
internal fun JsonObject.toFalukantStatus(): FalukantStatusDto {
|
||||
val character = objectValue("character")
|
||||
val relationship = character["relationshipsAsCharacter1"].firstObjectOrNull()?.stringValue("relationshipType")
|
||||
?: character["relationshipsAsCharacter2"].firstObjectOrNull()?.stringValue("relationshipType")
|
||||
val prison = objectValue("debtorsPrison")
|
||||
val firstName = character.stringValue("displayName")
|
||||
?: character.stringValue("name")
|
||||
?: listOfNotNull(
|
||||
character.objectValue("definedFirstName")?.stringValue("name") ?: character.stringValue("firstName"),
|
||||
character.objectValue("definedLastName")?.stringValue("name") ?: character.stringValue("lastName"),
|
||||
).joinToString(" ")
|
||||
return FalukantStatusDto(
|
||||
characterName = firstName.orEmpty(),
|
||||
age = character.intValue("age"),
|
||||
health = character.doubleValue("health"),
|
||||
money = doubleValue("money"),
|
||||
relationship = relationship,
|
||||
childrenCount = intValue("childrenCount") ?: 0,
|
||||
unreadNotifications = intValue("unreadNotifications") ?: 0,
|
||||
debtorsPrisonActive = prison.booleanValue("active") ?: false,
|
||||
inDebtorsPrison = prison.booleanValue("inDebtorsPrison") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun JsonObject.toFalukantBranch(): FalukantBranchDto? {
|
||||
val id = longValue("id") ?: return null
|
||||
val city = objectValue("city")
|
||||
val type = objectValue("branchType") ?: objectValue("type")
|
||||
return FalukantBranchDto(
|
||||
id = id,
|
||||
name = stringValue("name") ?: stringValue("title") ?: "Filiale $id",
|
||||
cityName = city.stringValue("name") ?: stringValue("cityName").orEmpty(),
|
||||
typeName = type.stringValue("name") ?: type.stringValue("tr") ?: stringValue("branchTypeName").orEmpty(),
|
||||
raw = this,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun JsonObject.toFalukantBank(): FalukantBankDto {
|
||||
val prison = objectValue("debtorsPrison")
|
||||
val credits = this["activeCredits"]
|
||||
return FalukantBankDto(
|
||||
money = doubleValue("money"),
|
||||
totalDebt = doubleValue("totalDebt"),
|
||||
maxCredit = doubleValue("maxCredit"),
|
||||
availableCredit = doubleValue("availableCredit"),
|
||||
activeCredits = (credits as? kotlinx.serialization.json.JsonArray)?.size ?: intValue("activeCredits") ?: 0,
|
||||
debtorsPrisonActive = prison.booleanValue("active") ?: false,
|
||||
inDebtorsPrison = prison.booleanValue("inDebtorsPrison") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun JsonObject.toFalukantFamily(): FalukantFamilyDto {
|
||||
val relationships = objectValue("relationships")
|
||||
val partner = relationships["relationships"]?.firstObjectOrNull()
|
||||
?: this["relationships"]?.firstObjectOrNull()
|
||||
val partnerCharacter = partner?.objectValue("character2")
|
||||
val children = (this["children"] as? kotlinx.serialization.json.JsonArray).orEmpty().mapNotNull { element ->
|
||||
val child = element as? JsonObject ?: return@mapNotNull null
|
||||
child.stringValue("name") ?: listOfNotNull(child.stringValue("firstName"), child.stringValue("lastName")).joinToString(" ").ifBlank { null }
|
||||
}
|
||||
val lovers = (this["lovers"] as? kotlinx.serialization.json.JsonArray).orEmpty().mapNotNull { element ->
|
||||
val lover = element as? JsonObject ?: return@mapNotNull null
|
||||
lover.stringValue("name") ?: lover.objectValue("character2")?.stringValue("firstName")
|
||||
}
|
||||
return FalukantFamilyDto(
|
||||
partnerName = partnerCharacter?.stringValue("name")
|
||||
?: partnerCharacter?.stringValue("firstName")
|
||||
?: "",
|
||||
relationship = partner?.stringValue("relationshipType").orEmpty(),
|
||||
children = children,
|
||||
lovers = lovers,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun JsonObject.toFalukantNotification(): FalukantNotificationDto? {
|
||||
val id = longValue("id") ?: return null
|
||||
return FalukantNotificationDto(
|
||||
id = id,
|
||||
title = stringValue("title") ?: stringValue("tr") ?: "Ereignis",
|
||||
description = stringValue("description") ?: stringValue("message") ?: "",
|
||||
createdAt = stringValue("createdAt").orEmpty(),
|
||||
shown = booleanValue("shown") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.objectValue(key: String): JsonObject = this[key] as? JsonObject ?: JsonObject(emptyMap())
|
||||
private fun JsonObject.stringValue(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
|
||||
private fun JsonObject.longValue(key: String): Long? = stringValue(key)?.toLongOrNull()
|
||||
private fun JsonObject.intValue(key: String): Int? = stringValue(key)?.toIntOrNull()
|
||||
private fun JsonObject.doubleValue(key: String): Double? = stringValue(key)?.toDoubleOrNull()
|
||||
private fun JsonObject.booleanValue(key: String): Boolean? = stringValue(key)?.toBooleanStrictOrNull()
|
||||
private fun JsonElement?.firstObjectOrNull(): JsonObject? = (this as? kotlinx.serialization.json.JsonArray)?.firstOrNull() as? JsonObject
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.yourpart.nativeapp.feature.falukant
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
@Singleton
|
||||
class FalukantRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
data class FullAction(val id: String, val label: String, val method: String, val path: String, val examplePayload: String)
|
||||
|
||||
val fullActions = listOf(
|
||||
FullAction("family-heir", "Erben setzen", "POST", "/api/falukant/family/set-heir", "{\"childCharacterId\": 1}"),
|
||||
FullAction("family-gift", "Familiengeschenk", "POST", "/api/falukant/family/gift", "{\"childId\": 1, \"giftId\": 1}"),
|
||||
FullAction("health", "Gesundheitsaktion", "POST", "/api/falukant/health", "{\"measureTr\": \"walk\"}"),
|
||||
FullAction("reputation", "Reputationsaktion", "POST", "/api/falukant/reputation/actions", "{\"actionTypeId\": 1}"),
|
||||
FullAction("church-baptise", "Taufe", "POST", "/api/falukant/church/baptise", "{\"childId\": 1, \"firstName\": \"Name\"}"),
|
||||
FullAction("church-apply", "Kirchenamt bewerben", "POST", "/api/falukant/church/positions/apply", "{\"positionId\": 1}"),
|
||||
FullAction("nobility", "Adelsaufstieg", "POST", "/api/falukant/nobility", "{}"),
|
||||
FullAction("house-buy", "Haus kaufen", "POST", "/api/falukant/houses", "{\"houseId\": 1}"),
|
||||
FullAction("house-renovate", "Haus renovieren", "POST", "/api/falukant/houses/renovate", "{\"element\": \"roof\"}"),
|
||||
FullAction("education", "Bildung starten", "POST", "/api/falukant/education", "{\"item\": \"all\", \"student\": \"child\", \"studentId\": 1}"),
|
||||
FullAction("politics-tax", "Regionalsteuer setzen", "PUT", "/api/falukant/politics/region/{regionId}/tax", "{\"percent\": 5}"),
|
||||
FullAction("politics-appointment", "Politisches Amt ernennen", "POST", "/api/falukant/politics/appointments", "{\"targetCharacterId\": 1, \"officeTypeId\": 1, \"regionId\": 1}"),
|
||||
FullAction("politics-vote", "Wahl abstimmen", "POST", "/api/falukant/politics/elections", "{\"votes\": []}"),
|
||||
FullAction("underground", "Untergrundaktion", "POST", "/api/falukant/underground/activities", "{\"typeId\": \"sabotage\", \"target\": 1}"),
|
||||
)
|
||||
suspend fun loadStatus(): Result<FalukantStatusDto> = loadObject("/api/falukant/info").map { it.toFalukantStatus() }
|
||||
suspend fun loadUser(): Result<JsonObject> = loadObject("/api/falukant/user")
|
||||
suspend fun loadBranches(): Result<List<FalukantBranchDto>> = loadElement("/api/falukant/branches").map { element ->
|
||||
element.jsonArray.mapNotNull { (it as? JsonObject)?.toFalukantBranch() }
|
||||
}
|
||||
suspend fun loadBranch(id: Long): Result<JsonObject> = loadObject("/api/falukant/branches/$id")
|
||||
suspend fun loadProduction(id: Long): Result<JsonElement> = loadElement("/api/falukant/production/$id")
|
||||
suspend fun loadStorage(id: Long): Result<JsonObject> = loadObject("/api/falukant/storage/$id")
|
||||
suspend fun loadInventory(id: Long): Result<JsonElement> = loadElement("/api/falukant/inventory/$id")
|
||||
suspend fun loadDirector(id: Long): Result<JsonObject> = loadObject("/api/falukant/director/$id")
|
||||
suspend fun loadTransports(id: Long): Result<JsonElement> = loadElement("/api/falukant/transports/branch/$id")
|
||||
suspend fun loadVehicles(): Result<JsonElement> = loadElement("/api/falukant/vehicles")
|
||||
suspend fun loadProducts(): Result<JsonElement> = loadElement("/api/falukant/products")
|
||||
suspend fun loadStockTypes(): Result<JsonElement> = loadElement("/api/falukant/stocktypes")
|
||||
suspend fun loadBranchTaxes(id: Long): Result<JsonObject> = loadObject("/api/falukant/branches/$id/taxes")
|
||||
suspend fun loadDirectorProposals(id: Long): Result<JsonElement> = postElement("/api/falukant/director/proposal", buildJsonObject { put("branchId", id) })
|
||||
suspend fun loadBank(): Result<FalukantBankDto> = loadObject("/api/falukant/bank/overview").map { it.toFalukantBank() }
|
||||
suspend fun loadBankCredits(): Result<JsonElement> = loadElement("/api/falukant/bank/credits")
|
||||
suspend fun loadFullData(): Result<Map<String, JsonElement>> = withContext(Dispatchers.IO) {
|
||||
val paths = mapOf(
|
||||
"Gesundheit" to "/api/falukant/health", "Reputation" to "/api/falukant/reputation/actions",
|
||||
"Kirche" to "/api/falukant/church/overview", "Adel" to "/api/falukant/nobility",
|
||||
"Haus" to "/api/falukant/houses", "Bildung" to "/api/falukant/education",
|
||||
"Politik" to "/api/falukant/politics/overview", "Untergrund" to "/api/falukant/underground/activities",
|
||||
)
|
||||
runCatching { paths.mapValues { (_, path) -> loadElement(path).getOrElse { throw it } } }
|
||||
}
|
||||
|
||||
suspend fun executeFullAction(action: FullAction, payload: JsonObject, regionId: String): Result<Unit> {
|
||||
val path = action.path.replace("{regionId}", regionId)
|
||||
return when (action.method) {
|
||||
"PUT" -> putUnit(path, payload)
|
||||
else -> postUnit(path, payload)
|
||||
}
|
||||
}
|
||||
suspend fun loadFamily(): Result<FalukantFamilyDto> = loadObject("/api/falukant/family").map { it.toFalukantFamily() }
|
||||
suspend fun loadNotifications(): Result<List<FalukantNotificationDto>> = loadElement("/api/falukant/notifications/all?page=1&size=20").map { element ->
|
||||
val items = (element as? JsonObject)?.get("items")?.jsonArray ?: element.jsonArray
|
||||
items.mapNotNull { (it as? JsonObject)?.toFalukantNotification() }
|
||||
}
|
||||
|
||||
suspend fun startProduction(branchId: Long, productId: Long, quantity: Int): Result<Unit> = postUnit(
|
||||
"/api/falukant/production",
|
||||
buildJsonObject {
|
||||
put("branchId", branchId)
|
||||
put("productId", productId)
|
||||
put("quantity", quantity)
|
||||
},
|
||||
)
|
||||
suspend fun upgradeBranch(branchId: Long): Result<Unit> = postUnit("/api/falukant/branches/upgrade", buildJsonObject { put("branchId", branchId) })
|
||||
suspend fun hireDirector(proposalId: Long): Result<Unit> = postUnit("/api/falukant/director/convertproposal", buildJsonObject { put("proposalId", proposalId) })
|
||||
|
||||
suspend fun buyStorage(branchId: Long, stockTypeId: Long, amount: Int): Result<Unit> = postUnit(
|
||||
"/api/falukant/storage",
|
||||
buildJsonObject {
|
||||
put("branchId", branchId)
|
||||
put("stockTypeId", stockTypeId)
|
||||
put("amount", amount)
|
||||
},
|
||||
)
|
||||
suspend fun sellStorage(branchId: Long, stockTypeId: Long, amount: Int): Result<Unit> = deleteUnit(
|
||||
"/api/falukant/storage",
|
||||
buildJsonObject { put("branchId", branchId); put("stockTypeId", stockTypeId); put("amount", amount) },
|
||||
)
|
||||
|
||||
suspend fun sellAll(branchId: Long): Result<Unit> = postUnit(
|
||||
"/api/falukant/sell/all",
|
||||
buildJsonObject { put("branchId", branchId) },
|
||||
)
|
||||
|
||||
suspend fun sellProduct(branchId: Long, productId: Long, quality: Int, quantity: Int): Result<Unit> = postUnit(
|
||||
"/api/falukant/sell",
|
||||
buildJsonObject {
|
||||
put("branchId", branchId)
|
||||
put("productId", productId)
|
||||
put("quality", quality)
|
||||
put("quantity", quantity)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun createTransport(
|
||||
branchId: Long,
|
||||
vehicleTypeId: Long,
|
||||
productId: Long,
|
||||
quantity: Int,
|
||||
targetBranchId: Long,
|
||||
guardCount: Int,
|
||||
): Result<Unit> = postUnit(
|
||||
"/api/falukant/transports",
|
||||
buildJsonObject {
|
||||
put("branchId", branchId)
|
||||
put("vehicleTypeId", vehicleTypeId)
|
||||
put("productId", productId)
|
||||
put("quantity", quantity)
|
||||
put("targetBranchId", targetBranchId)
|
||||
put("guardCount", guardCount)
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun takeOrPayCredit(height: Double): Result<Unit> = postUnit(
|
||||
"/api/falukant/bank/credits",
|
||||
buildJsonObject { put("height", height) },
|
||||
)
|
||||
|
||||
suspend fun repairAllVehicles(branchId: Long): Result<Unit> = postUnit(
|
||||
"/api/falukant/vehicles/repair-all",
|
||||
buildJsonObject { put("branchId", branchId) },
|
||||
)
|
||||
|
||||
suspend fun updateDirectorIncome(directorId: Long, income: Double): Result<Unit> = postUnit(
|
||||
"/api/falukant/directors",
|
||||
buildJsonObject {
|
||||
put("directorId", directorId)
|
||||
put("income", income)
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun loadObject(path: String): Result<JsonObject> = loadElement(path).map { it.jsonObject }
|
||||
|
||||
private suspend fun loadElement(path: String): Result<JsonElement> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path").get().build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.parseToJsonElement(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postUnit(path: String, payload: JsonObject): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}$path")
|
||||
.post(payload.toString().toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Aktion konnte nicht ausgeführt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postElement(path: String, payload: JsonObject): Result<JsonElement> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path")
|
||||
.post(payload.toString().toRequestBody("application/json".toMediaType())).build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.parseToJsonElement(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteUnit(path: String, payload: JsonObject): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path")
|
||||
.delete(payload.toString().toRequestBody("application/json".toMediaType())).build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Aktion konnte nicht ausgeführt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun putUnit(path: String, payload: JsonObject): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path")
|
||||
.put(payload.toString().toRequestBody("application/json".toMediaType())).build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Aktion konnte nicht ausgeführt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package de.yourpart.nativeapp.feature.falukant
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
@Composable
|
||||
fun FalukantScreen(
|
||||
uiState: FalukantUiState,
|
||||
onSelectSection: (FalukantSection) -> Unit,
|
||||
onSelectBranch: (Long) -> Unit,
|
||||
onUpdateProductionProductId: (String) -> Unit,
|
||||
onUpdateProductionQuantity: (String) -> Unit,
|
||||
onUpdateStorageTypeId: (String) -> Unit,
|
||||
onUpdateStorageAmount: (String) -> Unit,
|
||||
onUpdateDirectorIncome: (String) -> Unit,
|
||||
onStartProduction: () -> Unit,
|
||||
onBuyStorage: () -> Unit,
|
||||
onSellStorage: () -> Unit,
|
||||
onSellAll: () -> Unit,
|
||||
onRepairAllVehicles: () -> Unit,
|
||||
onSaveDirectorIncome: () -> Unit,
|
||||
onUpgradeBranch: () -> Unit,
|
||||
onUpdateDirectorProposalId: (String) -> Unit,
|
||||
onHireDirector: () -> Unit,
|
||||
onUpdateSellProductId: (String) -> Unit,
|
||||
onUpdateSellQuality: (String) -> Unit,
|
||||
onUpdateSellQuantity: (String) -> Unit,
|
||||
onSellProduct: () -> Unit,
|
||||
onUpdateTransportVehicleTypeId: (String) -> Unit,
|
||||
onUpdateTransportTargetBranchId: (String) -> Unit,
|
||||
onUpdateTransportProductId: (String) -> Unit,
|
||||
onUpdateTransportQuantity: (String) -> Unit,
|
||||
onUpdateTransportGuardCount: (String) -> Unit,
|
||||
onCreateTransport: () -> Unit,
|
||||
onUpdateCreditHeight: (String) -> Unit,
|
||||
onTakeOrPayCredit: () -> Unit,
|
||||
fullActions: List<FalukantRepository.FullAction>,
|
||||
onRefreshFullData: () -> Unit,
|
||||
onUpdateFullActionId: (String) -> Unit,
|
||||
onUpdateFullActionPayload: (String) -> Unit,
|
||||
onUpdateFullActionRegionId: (String) -> Unit,
|
||||
onExecuteFullAction: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Falukant",
|
||||
body = uiState.status?.characterName?.takeIf { it.isNotBlank() }
|
||||
?.let { "Spielstand von $it" }
|
||||
?: "Falukant-Spielstand und wichtige Entscheidungen.",
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
FalukantSection.entries.take(3).forEach { section ->
|
||||
SectionChip(section, uiState.section, onSelectSection)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
FalukantSection.entries.drop(3).forEach { section ->
|
||||
SectionChip(section, uiState.section, onSelectSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { YpSecondaryButton("Aktualisieren", onRefresh, Modifier.fillMaxWidth()) }
|
||||
uiState.errorMessage?.let { error -> item { YpEmptyState("Falukant-Fehler", error) } }
|
||||
|
||||
when (uiState.section) {
|
||||
FalukantSection.Overview -> overviewContent(uiState)
|
||||
FalukantSection.Branches -> branchContent(
|
||||
state = uiState,
|
||||
onSelectBranch = onSelectBranch,
|
||||
onUpdateProductionProductId = onUpdateProductionProductId,
|
||||
onUpdateProductionQuantity = onUpdateProductionQuantity,
|
||||
onUpdateStorageTypeId = onUpdateStorageTypeId,
|
||||
onUpdateStorageAmount = onUpdateStorageAmount,
|
||||
onUpdateDirectorIncome = onUpdateDirectorIncome,
|
||||
onStartProduction = onStartProduction,
|
||||
onBuyStorage = onBuyStorage,
|
||||
onSellStorage = onSellStorage,
|
||||
onSellAll = onSellAll,
|
||||
onRepairAllVehicles = onRepairAllVehicles,
|
||||
onSaveDirectorIncome = onSaveDirectorIncome,
|
||||
onUpgradeBranch = onUpgradeBranch,
|
||||
onUpdateDirectorProposalId = onUpdateDirectorProposalId,
|
||||
onHireDirector = onHireDirector,
|
||||
onUpdateSellProductId = onUpdateSellProductId,
|
||||
onUpdateSellQuality = onUpdateSellQuality,
|
||||
onUpdateSellQuantity = onUpdateSellQuantity,
|
||||
onSellProduct = onSellProduct,
|
||||
onUpdateTransportVehicleTypeId = onUpdateTransportVehicleTypeId,
|
||||
onUpdateTransportTargetBranchId = onUpdateTransportTargetBranchId,
|
||||
onUpdateTransportProductId = onUpdateTransportProductId,
|
||||
onUpdateTransportQuantity = onUpdateTransportQuantity,
|
||||
onUpdateTransportGuardCount = onUpdateTransportGuardCount,
|
||||
onCreateTransport = onCreateTransport,
|
||||
)
|
||||
FalukantSection.Bank -> bankContent(uiState, onUpdateCreditHeight, onTakeOrPayCredit)
|
||||
FalukantSection.Family -> familyContent(uiState)
|
||||
FalukantSection.Notifications -> notificationContent(uiState)
|
||||
FalukantSection.Full -> fullContent(uiState, fullActions, onRefreshFullData, onUpdateFullActionId, onUpdateFullActionPayload, onUpdateFullActionRegionId, onExecuteFullAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.fullContent(
|
||||
state: FalukantUiState,
|
||||
actions: List<FalukantRepository.FullAction>,
|
||||
onRefresh: () -> Unit,
|
||||
onAction: (String) -> Unit,
|
||||
onPayload: (String) -> Unit,
|
||||
onRegion: (String) -> Unit,
|
||||
onExecute: () -> Unit,
|
||||
) {
|
||||
item { YpSecondaryButton("Vollausbau-Daten laden", onRefresh, Modifier.fillMaxWidth()) }
|
||||
state.fullData.forEach { (title, data) -> item { YpInfoCard(title, data.toString().take(900), accentColor = YpColors.SurfaceAccent) } }
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(LocalYpSpacing.current.sm)) {
|
||||
actions.forEach { action ->
|
||||
FilterChip(selected = state.selectedFullActionId == action.id, onClick = { onAction(action.id) }, label = { Text(action.label) })
|
||||
}
|
||||
OutlinedTextField(state.fullActionRegionId, onRegion, Modifier.fillMaxWidth(), label = { Text("Region-ID, falls erforderlich") })
|
||||
OutlinedTextField(state.fullActionPayload, onPayload, Modifier.fillMaxWidth(), label = { Text("Aktionsdaten (JSON)") }, minLines = 4)
|
||||
YpPrimaryButton("Aktion ausführen", onExecute, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionChip(section: FalukantSection, selected: FalukantSection, onSelect: (FalukantSection) -> Unit) {
|
||||
FilterChip(
|
||||
selected = section == selected,
|
||||
onClick = { onSelect(section) },
|
||||
label = { Text(section.label) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
selectedContainerColor = YpColors.PrimarySoft,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.overviewContent(state: FalukantUiState) {
|
||||
val status = state.status
|
||||
item {
|
||||
InfoGrid(
|
||||
title = "Status",
|
||||
rows = listOfNotNull(
|
||||
status?.age?.let { "Alter" to it.toString() },
|
||||
status?.health?.let { "Gesundheit" to it.toString() },
|
||||
status?.money?.let { "Vermögen" to it.toString() },
|
||||
status?.relationship?.let { "Beziehung" to it },
|
||||
status?.childrenCount?.let { "Kinder" to it.toString() },
|
||||
status?.unreadNotifications?.let { "Ungelesen" to it.toString() },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (status?.debtorsPrisonActive == true) {
|
||||
item {
|
||||
YpEmptyState(
|
||||
title = if (status.inDebtorsPrison) "Schuldgefängnis" else "Schuldenwarnung",
|
||||
body = "Bank- und Spielaktionen können eingeschränkt sein.",
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
InfoGrid(
|
||||
title = "Spielstand",
|
||||
rows = listOf(
|
||||
"Filialen" to state.branches.size.toString(),
|
||||
"Benachrichtigungen" to state.notifications.size.toString(),
|
||||
"Zertifikat" to state.user?.primitiveValue("certificate").orEmpty(),
|
||||
).filter { it.second.isNotBlank() },
|
||||
)
|
||||
}
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "MVP-Grenze",
|
||||
body = "3D-Charaktere, Karten, Produktion, Lager, Verkauf und Director bleiben Teil des Falukant-Vollausbaus.",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.branchContent(
|
||||
state: FalukantUiState,
|
||||
onSelectBranch: (Long) -> Unit,
|
||||
onUpdateProductionProductId: (String) -> Unit,
|
||||
onUpdateProductionQuantity: (String) -> Unit,
|
||||
onUpdateStorageTypeId: (String) -> Unit,
|
||||
onUpdateStorageAmount: (String) -> Unit,
|
||||
onUpdateDirectorIncome: (String) -> Unit,
|
||||
onStartProduction: () -> Unit,
|
||||
onBuyStorage: () -> Unit,
|
||||
onSellStorage: () -> Unit,
|
||||
onSellAll: () -> Unit,
|
||||
onRepairAllVehicles: () -> Unit,
|
||||
onSaveDirectorIncome: () -> Unit,
|
||||
onUpgradeBranch: () -> Unit,
|
||||
onUpdateDirectorProposalId: (String) -> Unit,
|
||||
onHireDirector: () -> Unit,
|
||||
onUpdateSellProductId: (String) -> Unit,
|
||||
onUpdateSellQuality: (String) -> Unit,
|
||||
onUpdateSellQuantity: (String) -> Unit,
|
||||
onSellProduct: () -> Unit,
|
||||
onUpdateTransportVehicleTypeId: (String) -> Unit,
|
||||
onUpdateTransportTargetBranchId: (String) -> Unit,
|
||||
onUpdateTransportProductId: (String) -> Unit,
|
||||
onUpdateTransportQuantity: (String) -> Unit,
|
||||
onUpdateTransportGuardCount: (String) -> Unit,
|
||||
onCreateTransport: () -> Unit,
|
||||
) {
|
||||
if (!state.isLoading && state.branches.isEmpty()) {
|
||||
item { YpEmptyState("Keine Filialen", "Die Erstellung eines Falukant-Spielstands und neuer Filialen folgt erst nach dem MVP.") }
|
||||
}
|
||||
items(state.branches, key = { it.id }) { branch ->
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
onClick = { onSelectBranch(branch.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(branch.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(listOf(branch.typeName, branch.cityName).filter { it.isNotBlank() }.joinToString(" · ").ifBlank { "Details anzeigen" }, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
state.selectedBranch?.let { branch ->
|
||||
item { InfoGrid("Filialdetails", branch.scalarEntries()) }
|
||||
item {
|
||||
BranchOperations(
|
||||
state = state,
|
||||
onUpdateProductionProductId = onUpdateProductionProductId,
|
||||
onUpdateProductionQuantity = onUpdateProductionQuantity,
|
||||
onUpdateStorageTypeId = onUpdateStorageTypeId,
|
||||
onUpdateStorageAmount = onUpdateStorageAmount,
|
||||
onUpdateDirectorIncome = onUpdateDirectorIncome,
|
||||
onStartProduction = onStartProduction,
|
||||
onBuyStorage = onBuyStorage,
|
||||
onSellStorage = onSellStorage,
|
||||
onSellAll = onSellAll,
|
||||
onRepairAllVehicles = onRepairAllVehicles,
|
||||
onSaveDirectorIncome = onSaveDirectorIncome,
|
||||
onUpgradeBranch = onUpgradeBranch,
|
||||
onUpdateDirectorProposalId = onUpdateDirectorProposalId,
|
||||
onHireDirector = onHireDirector,
|
||||
onUpdateSellProductId = onUpdateSellProductId,
|
||||
onUpdateSellQuality = onUpdateSellQuality,
|
||||
onUpdateSellQuantity = onUpdateSellQuantity,
|
||||
onSellProduct = onSellProduct,
|
||||
onUpdateTransportVehicleTypeId = onUpdateTransportVehicleTypeId,
|
||||
onUpdateTransportTargetBranchId = onUpdateTransportTargetBranchId,
|
||||
onUpdateTransportProductId = onUpdateTransportProductId,
|
||||
onUpdateTransportQuantity = onUpdateTransportQuantity,
|
||||
onUpdateTransportGuardCount = onUpdateTransportGuardCount,
|
||||
onCreateTransport = onCreateTransport,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BranchOperations(
|
||||
state: FalukantUiState,
|
||||
onUpdateProductionProductId: (String) -> Unit,
|
||||
onUpdateProductionQuantity: (String) -> Unit,
|
||||
onUpdateStorageTypeId: (String) -> Unit,
|
||||
onUpdateStorageAmount: (String) -> Unit,
|
||||
onUpdateDirectorIncome: (String) -> Unit,
|
||||
onStartProduction: () -> Unit,
|
||||
onBuyStorage: () -> Unit,
|
||||
onSellStorage: () -> Unit,
|
||||
onSellAll: () -> Unit,
|
||||
onRepairAllVehicles: () -> Unit,
|
||||
onSaveDirectorIncome: () -> Unit,
|
||||
onUpgradeBranch: () -> Unit,
|
||||
onUpdateDirectorProposalId: (String) -> Unit,
|
||||
onHireDirector: () -> Unit,
|
||||
onUpdateSellProductId: (String) -> Unit,
|
||||
onUpdateSellQuality: (String) -> Unit,
|
||||
onUpdateSellQuantity: (String) -> Unit,
|
||||
onSellProduct: () -> Unit,
|
||||
onUpdateTransportVehicleTypeId: (String) -> Unit,
|
||||
onUpdateTransportTargetBranchId: (String) -> Unit,
|
||||
onUpdateTransportProductId: (String) -> Unit,
|
||||
onUpdateTransportQuantity: (String) -> Unit,
|
||||
onUpdateTransportGuardCount: (String) -> Unit,
|
||||
onCreateTransport: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
state.branchProduction?.asObjectOrNull()?.let { InfoGrid("Laufende Produktion", it.scalarEntries()) }
|
||||
state.branchTaxes?.let { taxes -> InfoGrid("Steuern", taxes.scalarEntries()) }
|
||||
YpPrimaryButton("Filiale aufwerten", onUpgradeBranch, Modifier.fillMaxWidth())
|
||||
state.branchStorage?.let { storage ->
|
||||
InfoGrid("Lager", storage.scalarEntries())
|
||||
StorageUsageCard(storage)
|
||||
}
|
||||
state.branchInventory?.asArrayOrNull()?.let { inventory ->
|
||||
YpInfoCard("Inventar", "${inventory.size} Produktposition(en) verfügbar.", accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
state.branchDirector?.let { director -> InfoGrid("Director", director.scalarEntries()) }
|
||||
state.branchTransports?.asArrayOrNull()?.let { transports ->
|
||||
YpInfoCard("Transporte", "${transports.size} laufende(r) Transport(e).", accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
state.vehicles?.asArrayOrNull()?.let { vehicles ->
|
||||
YpInfoCard("Fahrzeuge", "${vehicles.size} Fahrzeug(e) im Spielstand.", accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
|
||||
YpInfoCard("Produktion starten", listLabels(state.products).ifBlank { "Produkt-ID aus dem Backend verwenden." }, accentColor = YpColors.SurfaceAccent)
|
||||
OutlinedTextField(state.productionProductId, onUpdateProductionProductId, Modifier.fillMaxWidth(), label = { Text("Produkt-ID") })
|
||||
OutlinedTextField(state.productionQuantity, onUpdateProductionQuantity, Modifier.fillMaxWidth(), label = { Text("Menge (1-200)") })
|
||||
YpPrimaryButton("Produktion starten", onStartProduction, Modifier.fillMaxWidth())
|
||||
|
||||
YpInfoCard("Lager erweitern", listLabels(state.stockTypes).ifBlank { "Lagertyp-ID aus dem Backend verwenden." }, accentColor = YpColors.SurfaceAccent)
|
||||
OutlinedTextField(state.storageTypeId, onUpdateStorageTypeId, Modifier.fillMaxWidth(), label = { Text("Lagertyp-ID") })
|
||||
OutlinedTextField(state.storageAmount, onUpdateStorageAmount, Modifier.fillMaxWidth(), label = { Text("Kapazität") })
|
||||
YpPrimaryButton("Lager kaufen", onBuyStorage, Modifier.fillMaxWidth())
|
||||
YpSecondaryButton("Lager verkaufen", onSellStorage, Modifier.fillMaxWidth())
|
||||
YpSecondaryButton("Gesamtes Inventar verkaufen", onSellAll, Modifier.fillMaxWidth())
|
||||
YpSecondaryButton("Alle Fahrzeuge reparieren", onRepairAllVehicles, Modifier.fillMaxWidth())
|
||||
|
||||
YpInfoCard("Einzelverkauf", "Produkt-ID, Qualitätsstufe und Menge aus dem Inventar verwenden.", accentColor = YpColors.SurfaceAccent)
|
||||
OutlinedTextField(state.sellProductId, onUpdateSellProductId, Modifier.fillMaxWidth(), label = { Text("Produkt-ID") })
|
||||
OutlinedTextField(state.sellQuality, onUpdateSellQuality, Modifier.fillMaxWidth(), label = { Text("Qualität") })
|
||||
OutlinedTextField(state.sellQuantity, onUpdateSellQuantity, Modifier.fillMaxWidth(), label = { Text("Menge") })
|
||||
YpPrimaryButton("Einzelverkauf ausführen", onSellProduct, Modifier.fillMaxWidth())
|
||||
|
||||
YpInfoCard("Transport erstellen", "Fahrzeugtyp, Produkt und Ziel-Filiale anhand der geladenen Daten auswählen.", accentColor = YpColors.SurfaceAccent)
|
||||
OutlinedTextField(state.transportVehicleTypeId, onUpdateTransportVehicleTypeId, Modifier.fillMaxWidth(), label = { Text("Fahrzeugtyp-ID") })
|
||||
OutlinedTextField(state.transportTargetBranchId, onUpdateTransportTargetBranchId, Modifier.fillMaxWidth(), label = { Text("Ziel-Filial-ID") })
|
||||
OutlinedTextField(state.transportProductId, onUpdateTransportProductId, Modifier.fillMaxWidth(), label = { Text("Produkt-ID") })
|
||||
OutlinedTextField(state.transportQuantity, onUpdateTransportQuantity, Modifier.fillMaxWidth(), label = { Text("Menge") })
|
||||
OutlinedTextField(state.transportGuardCount, onUpdateTransportGuardCount, Modifier.fillMaxWidth(), label = { Text("Wachen") })
|
||||
YpPrimaryButton("Transport starten", onCreateTransport, Modifier.fillMaxWidth())
|
||||
|
||||
state.branchDirector?.directorIdOrNull()?.let { _ ->
|
||||
OutlinedTextField(state.directorIncome, onUpdateDirectorIncome, Modifier.fillMaxWidth(), label = { Text("Director-Einkommen") })
|
||||
YpPrimaryButton("Director-Einkommen speichern", onSaveDirectorIncome, Modifier.fillMaxWidth())
|
||||
}
|
||||
state.directorProposals?.asArrayOrNull()?.let { proposals ->
|
||||
YpInfoCard("Director-Proposals", proposals.take(5).joinToString(" · ") { proposal ->
|
||||
val item = proposal as? JsonObject
|
||||
"${item?.primitiveValue("id").orEmpty()}: ${item?.primitiveValue("name").orEmpty()}"
|
||||
}, accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
OutlinedTextField(state.directorProposalId, onUpdateDirectorProposalId, Modifier.fillMaxWidth(), label = { Text("Director-Proposal-ID") })
|
||||
YpPrimaryButton("Director einstellen", onHireDirector, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StorageUsageCard(storage: JsonObject) {
|
||||
val usage = storage["usageByType"] as? JsonArray ?: return
|
||||
YpInfoCard("Lagernutzung", usage.joinToString(" · ") { item ->
|
||||
val value = item as? JsonObject ?: return@joinToString ""
|
||||
"${value.primitiveValue("stockTypeLabelTr").orEmpty()}: ${value.primitiveValue("used").orEmpty()}/${value.primitiveValue("totalCapacity").orEmpty()}"
|
||||
}, accentColor = YpColors.SurfaceAccent)
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.bankContent(
|
||||
state: FalukantUiState,
|
||||
onUpdateCreditHeight: (String) -> Unit,
|
||||
onTakeOrPayCredit: () -> Unit,
|
||||
) {
|
||||
val bank = state.bank
|
||||
item {
|
||||
InfoGrid(
|
||||
title = "Bank",
|
||||
rows = listOfNotNull(
|
||||
bank?.money?.let { "Guthaben" to it.toString() },
|
||||
bank?.totalDebt?.let { "Schulden" to it.toString() },
|
||||
bank?.maxCredit?.let { "Maximaler Kredit" to it.toString() },
|
||||
bank?.availableCredit?.let { "Verfügbarer Kredit" to it.toString() },
|
||||
bank?.activeCredits?.let { "Aktive Kredite" to it.toString() },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (bank?.debtorsPrisonActive == true) {
|
||||
item { YpEmptyState(if (bank.inDebtorsPrison) "Schuldgefängnis" else "Schuldenwarnung", "Kreditaktionen sind im nativen MVP nur lesbar.") }
|
||||
}
|
||||
state.bankCredits?.asArrayOrNull()?.let { credits ->
|
||||
item { YpInfoCard("Aktive Kredite", "${credits.size} Kredit(e) aktiv.", accentColor = YpColors.SurfaceAccent) }
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(LocalYpSpacing.current.sm)) {
|
||||
OutlinedTextField(state.creditHeight, onUpdateCreditHeight, Modifier.fillMaxWidth(), label = { Text("Kreditbetrag") })
|
||||
YpPrimaryButton("Kredit aufnehmen", onTakeOrPayCredit, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.familyContent(state: FalukantUiState) {
|
||||
val family = state.family
|
||||
item {
|
||||
InfoGrid(
|
||||
title = "Familie",
|
||||
rows = listOf(
|
||||
"Partner" to family?.partnerName.orEmpty().ifBlank { "Keine Angabe" },
|
||||
"Beziehungsstatus" to family?.relationship.orEmpty().ifBlank { "Keine Angabe" },
|
||||
"Kinder" to family?.children?.size?.toString().orEmpty(),
|
||||
"Liebesbeziehungen" to family?.lovers?.size?.toString().orEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
family?.children?.takeIf { it.isNotEmpty() }?.let { children -> item { YpInfoCard("Kinder", children.joinToString(", "), accentColor = YpColors.SurfaceAccent) } }
|
||||
family?.lovers?.takeIf { it.isNotEmpty() }?.let { lovers -> item { YpInfoCard("Liebesbeziehungen", lovers.joinToString(", "), accentColor = YpColors.SurfaceAccent) } }
|
||||
item { YpInfoCard("MVP-Grenze", "Beziehungs-, Erb- und Geschenkeaktionen bleiben im Falukant-Vollausbau.", accentColor = YpColors.SurfaceAccent) }
|
||||
}
|
||||
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.notificationContent(state: FalukantUiState) {
|
||||
if (!state.isLoading && state.notifications.isEmpty()) {
|
||||
item { YpEmptyState("Keine Nachrichten", "Es liegen keine Falukant-Benachrichtigungen vor.") }
|
||||
}
|
||||
items(state.notifications, key = { it.id }) { notification ->
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(notification.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
if (notification.description.isNotBlank()) Text(notification.description)
|
||||
if (notification.createdAt.isNotBlank()) Text(notification.createdAt, style = MaterialTheme.typography.bodySmall, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoGrid(title: String, rows: List<Pair<String, String>>) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
rows.forEach { (label, value) ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, color = YpColors.TextSecondary)
|
||||
Text(value, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.primitiveValue(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull
|
||||
private fun JsonObject.directorIdOrNull(): Long? = (this["director"] as? JsonObject)?.primitiveValue("id")?.toLongOrNull()
|
||||
private fun JsonObject.scalarEntries(): List<Pair<String, String>> = entries.mapNotNull { (key, value) ->
|
||||
(value as? JsonPrimitive)?.contentOrNull?.let { key to it }
|
||||
}.take(12)
|
||||
private fun JsonElement.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
private fun JsonElement.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
private fun listLabels(element: JsonElement?): String = element?.asArrayOrNull()?.take(5)?.joinToString(" · ") { item ->
|
||||
val value = item as? JsonObject ?: return@joinToString ""
|
||||
"${value.primitiveValue("id").orEmpty()}: ${value.primitiveValue("labelTr") ?: value.primitiveValue("name").orEmpty()}"
|
||||
}.orEmpty()
|
||||
@@ -0,0 +1,287 @@
|
||||
package de.yourpart.nativeapp.feature.falukant
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class FalukantSection(val label: String) {
|
||||
Overview("Übersicht"),
|
||||
Branches("Filialen"),
|
||||
Bank("Bank"),
|
||||
Family("Familie"),
|
||||
Notifications("Nachrichten"),
|
||||
Full("Vollausbau"),
|
||||
}
|
||||
|
||||
data class FalukantUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val section: FalukantSection = FalukantSection.Overview,
|
||||
val status: FalukantStatusDto? = null,
|
||||
val user: JsonObject? = null,
|
||||
val branches: List<FalukantBranchDto> = emptyList(),
|
||||
val selectedBranch: JsonObject? = null,
|
||||
val selectedBranchId: Long? = null,
|
||||
val branchProduction: JsonElement? = null,
|
||||
val branchStorage: JsonObject? = null,
|
||||
val branchInventory: JsonElement? = null,
|
||||
val branchDirector: JsonObject? = null,
|
||||
val branchTransports: JsonElement? = null,
|
||||
val vehicles: JsonElement? = null,
|
||||
val products: JsonElement? = null,
|
||||
val stockTypes: JsonElement? = null,
|
||||
val branchTaxes: JsonObject? = null,
|
||||
val directorProposals: JsonElement? = null,
|
||||
val directorProposalId: String = "",
|
||||
val bankCredits: JsonElement? = null,
|
||||
val productionProductId: String = "",
|
||||
val productionQuantity: String = "1",
|
||||
val storageTypeId: String = "",
|
||||
val storageAmount: String = "1",
|
||||
val directorIncome: String = "",
|
||||
val sellProductId: String = "",
|
||||
val sellQuality: String = "50",
|
||||
val sellQuantity: String = "1",
|
||||
val transportVehicleTypeId: String = "",
|
||||
val transportTargetBranchId: String = "",
|
||||
val transportProductId: String = "",
|
||||
val transportQuantity: String = "1",
|
||||
val transportGuardCount: String = "0",
|
||||
val creditHeight: String = "",
|
||||
val fullData: Map<String, JsonElement> = emptyMap(),
|
||||
val selectedFullActionId: String = "family-heir",
|
||||
val fullActionPayload: String = "{\"childCharacterId\": 1}",
|
||||
val fullActionRegionId: String = "",
|
||||
val bank: FalukantBankDto? = null,
|
||||
val family: FalukantFamilyDto? = null,
|
||||
val notifications: List<FalukantNotificationDto> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FalukantViewModel @Inject constructor(
|
||||
private val repository: FalukantRepository,
|
||||
realtimeManager: RealtimeManager,
|
||||
) : ViewModel() {
|
||||
val fullActions: List<FalukantRepository.FullAction> = repository.fullActions
|
||||
private val _uiState = MutableStateFlow(FalukantUiState())
|
||||
val uiState: StateFlow<FalukantUiState> = _uiState
|
||||
|
||||
init {
|
||||
refresh()
|
||||
viewModelScope.launch {
|
||||
realtimeManager.events.collect { event ->
|
||||
if (event.eventName in falukantRealtimeEvents) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSection(section: FalukantSection) {
|
||||
_uiState.value = _uiState.value.copy(section = section)
|
||||
}
|
||||
|
||||
fun selectBranch(id: Long) {
|
||||
viewModelScope.launch {
|
||||
val branch = repository.loadBranch(id)
|
||||
val production = repository.loadProduction(id)
|
||||
val storage = repository.loadStorage(id)
|
||||
val inventory = repository.loadInventory(id)
|
||||
val director = repository.loadDirector(id)
|
||||
val transports = repository.loadTransports(id)
|
||||
val vehicles = repository.loadVehicles()
|
||||
val products = repository.loadProducts()
|
||||
val stockTypes = repository.loadStockTypes()
|
||||
val taxes = repository.loadBranchTaxes(id)
|
||||
val proposals = repository.loadDirectorProposals(id)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedBranchId = id,
|
||||
selectedBranch = branch.getOrNull(),
|
||||
branchProduction = production.getOrNull(),
|
||||
branchStorage = storage.getOrNull(),
|
||||
branchInventory = inventory.getOrNull(),
|
||||
branchDirector = director.getOrNull(),
|
||||
branchTransports = transports.getOrNull(),
|
||||
vehicles = vehicles.getOrNull(),
|
||||
products = products.getOrNull(),
|
||||
stockTypes = stockTypes.getOrNull(),
|
||||
branchTaxes = taxes.getOrNull(),
|
||||
directorProposals = proposals.getOrNull(),
|
||||
errorMessage = listOf(branch, production, storage, inventory, director, transports, vehicles, products, stockTypes)
|
||||
.firstNotNullOfOrNull { it.exceptionOrNull()?.message },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProductionProductId(value: String) { _uiState.value = _uiState.value.copy(productionProductId = value) }
|
||||
fun updateProductionQuantity(value: String) { _uiState.value = _uiState.value.copy(productionQuantity = value) }
|
||||
fun updateStorageTypeId(value: String) { _uiState.value = _uiState.value.copy(storageTypeId = value) }
|
||||
fun updateStorageAmount(value: String) { _uiState.value = _uiState.value.copy(storageAmount = value) }
|
||||
fun updateDirectorIncome(value: String) { _uiState.value = _uiState.value.copy(directorIncome = value) }
|
||||
fun updateDirectorProposalId(value: String) { _uiState.value = _uiState.value.copy(directorProposalId = value) }
|
||||
fun updateSellProductId(value: String) { _uiState.value = _uiState.value.copy(sellProductId = value) }
|
||||
fun updateSellQuality(value: String) { _uiState.value = _uiState.value.copy(sellQuality = value) }
|
||||
fun updateSellQuantity(value: String) { _uiState.value = _uiState.value.copy(sellQuantity = value) }
|
||||
fun updateTransportVehicleTypeId(value: String) { _uiState.value = _uiState.value.copy(transportVehicleTypeId = value) }
|
||||
fun updateTransportTargetBranchId(value: String) { _uiState.value = _uiState.value.copy(transportTargetBranchId = value) }
|
||||
fun updateTransportProductId(value: String) { _uiState.value = _uiState.value.copy(transportProductId = value) }
|
||||
fun updateTransportQuantity(value: String) { _uiState.value = _uiState.value.copy(transportQuantity = value) }
|
||||
fun updateTransportGuardCount(value: String) { _uiState.value = _uiState.value.copy(transportGuardCount = value) }
|
||||
fun updateCreditHeight(value: String) { _uiState.value = _uiState.value.copy(creditHeight = value) }
|
||||
fun updateFullActionId(value: String) {
|
||||
val action = repository.fullActions.firstOrNull { it.id == value } ?: return
|
||||
_uiState.value = _uiState.value.copy(selectedFullActionId = value, fullActionPayload = action.examplePayload)
|
||||
}
|
||||
fun updateFullActionPayload(value: String) { _uiState.value = _uiState.value.copy(fullActionPayload = value) }
|
||||
fun updateFullActionRegionId(value: String) { _uiState.value = _uiState.value.copy(fullActionRegionId = value) }
|
||||
fun refreshFullData() {
|
||||
viewModelScope.launch {
|
||||
repository.loadFullData().onSuccess { _uiState.value = _uiState.value.copy(fullData = it) }
|
||||
.onFailure { fail(it.message ?: "Falukant-Vollausbau konnte nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
fun executeFullAction() {
|
||||
val state = _uiState.value
|
||||
val action = repository.fullActions.firstOrNull { it.id == state.selectedFullActionId } ?: return
|
||||
val payload = runCatching { kotlinx.serialization.json.Json.parseToJsonElement(state.fullActionPayload).jsonObject }
|
||||
.getOrElse { return fail("Payload muss ein gültiges JSON-Objekt sein.") }
|
||||
if (action.path.contains("{regionId}") && state.fullActionRegionId.toLongOrNull() == null) return fail("Region-ID ist erforderlich.")
|
||||
viewModelScope.launch {
|
||||
repository.executeFullAction(action, payload, state.fullActionRegionId).onSuccess { refreshFullData(); refresh() }
|
||||
.onFailure { fail(it.message ?: "Aktion konnte nicht ausgeführt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun startProduction() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val productId = _uiState.value.productionProductId.toLongOrNull()
|
||||
val quantity = _uiState.value.productionQuantity.toIntOrNull()
|
||||
if (productId == null || quantity == null || quantity !in 1..200) return fail("Produkt-ID und Menge von 1 bis 200 sind erforderlich.")
|
||||
runBranchAction { repository.startProduction(branchId, productId, quantity) }
|
||||
}
|
||||
fun upgradeBranch() { val id = _uiState.value.selectedBranchId ?: return; runBranchAction { repository.upgradeBranch(id) } }
|
||||
fun hireDirector() {
|
||||
val proposalId = _uiState.value.directorProposalId.toLongOrNull() ?: return fail("Director-Proposal-ID eingeben.")
|
||||
runBranchAction { repository.hireDirector(proposalId) }
|
||||
}
|
||||
|
||||
fun buyStorage() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val typeId = _uiState.value.storageTypeId.toLongOrNull()
|
||||
val amount = _uiState.value.storageAmount.toIntOrNull()
|
||||
if (typeId == null || amount == null || amount < 1) return fail("Lagertyp-ID und Menge sind erforderlich.")
|
||||
runBranchAction { repository.buyStorage(branchId, typeId, amount) }
|
||||
}
|
||||
fun sellStorage() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val typeId = _uiState.value.storageTypeId.toLongOrNull()
|
||||
val amount = _uiState.value.storageAmount.toIntOrNull()
|
||||
if (typeId == null || amount == null || amount < 1) return fail("Lagertyp-ID und Menge sind erforderlich.")
|
||||
runBranchAction { repository.sellStorage(branchId, typeId, amount) }
|
||||
}
|
||||
|
||||
fun sellAll() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
runBranchAction { repository.sellAll(branchId) }
|
||||
}
|
||||
|
||||
fun repairAllVehicles() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
runBranchAction { repository.repairAllVehicles(branchId) }
|
||||
}
|
||||
|
||||
fun saveDirectorIncome() {
|
||||
val directorId = _uiState.value.branchDirector?.primitiveLong("director", "id")
|
||||
?: _uiState.value.branchDirector?.primitiveLong("id")
|
||||
?: return fail("Für diese Filiale ist kein Director verfügbar.")
|
||||
val income = _uiState.value.directorIncome.toDoubleOrNull() ?: return fail("Ein gültiges Director-Einkommen ist erforderlich.")
|
||||
runBranchAction { repository.updateDirectorIncome(directorId, income) }
|
||||
}
|
||||
|
||||
fun sellProduct() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val productId = _uiState.value.sellProductId.toLongOrNull()
|
||||
val quality = _uiState.value.sellQuality.toIntOrNull()
|
||||
val quantity = _uiState.value.sellQuantity.toIntOrNull()
|
||||
if (productId == null || quality == null || quantity == null || quantity < 1) return fail("Produkt-ID, Qualität und Menge sind erforderlich.")
|
||||
runBranchAction { repository.sellProduct(branchId, productId, quality, quantity) }
|
||||
}
|
||||
|
||||
fun createTransport() {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
val vehicleTypeId = _uiState.value.transportVehicleTypeId.toLongOrNull()
|
||||
val targetBranchId = _uiState.value.transportTargetBranchId.toLongOrNull()
|
||||
val productId = _uiState.value.transportProductId.toLongOrNull()
|
||||
val quantity = _uiState.value.transportQuantity.toIntOrNull()
|
||||
val guardCount = _uiState.value.transportGuardCount.toIntOrNull() ?: 0
|
||||
if (vehicleTypeId == null || targetBranchId == null || productId == null || quantity == null || quantity < 1 || guardCount < 0) return fail("Transportdaten sind unvollständig.")
|
||||
runBranchAction { repository.createTransport(branchId, vehicleTypeId, productId, quantity, targetBranchId, guardCount) }
|
||||
}
|
||||
|
||||
fun takeOrPayCredit() {
|
||||
val height = _uiState.value.creditHeight.toDoubleOrNull() ?: return fail("Gültigen Kreditbetrag eingeben.")
|
||||
viewModelScope.launch {
|
||||
repository.takeOrPayCredit(height).onSuccess { refresh() }
|
||||
.onFailure { fail(it.message ?: "Kreditaktion konnte nicht ausgeführt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
val status = repository.loadStatus()
|
||||
val user = repository.loadUser()
|
||||
val branches = repository.loadBranches()
|
||||
val bank = repository.loadBank()
|
||||
val bankCredits = repository.loadBankCredits()
|
||||
val family = repository.loadFamily()
|
||||
val notifications = repository.loadNotifications()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
status = status.getOrNull(),
|
||||
user = user.getOrNull(),
|
||||
branches = branches.getOrDefault(emptyList()),
|
||||
bank = bank.getOrNull(),
|
||||
bankCredits = bankCredits.getOrNull(),
|
||||
family = family.getOrNull(),
|
||||
notifications = notifications.getOrDefault(emptyList()),
|
||||
errorMessage = listOf(status, user, branches, bank, bankCredits, family, notifications)
|
||||
.firstNotNullOfOrNull { it.exceptionOrNull()?.message },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runBranchAction(action: suspend () -> Result<Unit>) {
|
||||
val branchId = _uiState.value.selectedBranchId ?: return
|
||||
viewModelScope.launch {
|
||||
action().onSuccess { selectBranch(branchId); refresh() }
|
||||
.onFailure { fail(it.message ?: "Aktion konnte nicht ausgeführt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(message: String) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.primitiveLong(parentKey: String, key: String): Long? = (this[parentKey] as? JsonObject)?.primitiveLong(key)
|
||||
private fun JsonObject.primitiveLong(key: String): Long? = (this[key] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.toLongOrNull()
|
||||
|
||||
private val falukantRealtimeEvents = setOf(
|
||||
"falukantUpdateStatus",
|
||||
"falukantUpdateFamily",
|
||||
"falukantUpdateChurch",
|
||||
"falukantUpdateDebt",
|
||||
"children_update",
|
||||
"falukantUpdateProductionCertificate",
|
||||
"falukantBranchUpdate",
|
||||
"stock_change",
|
||||
"familychanged",
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.yourpart.nativeapp.feature.forum
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ForumDto(
|
||||
val id: Long,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumTopicSummaryDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
val createdBy: String = "",
|
||||
val createdByHash: String = "",
|
||||
val createdAt: String? = null,
|
||||
val numberOfItems: Int = 0,
|
||||
val lastMessageDate: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumPageDto(
|
||||
val name: String = "",
|
||||
val titles: List<ForumTopicSummaryDto> = emptyList(),
|
||||
val page: Int = 1,
|
||||
val totalTopics: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumAuthorDto(
|
||||
val hashedId: String = "",
|
||||
val username: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumMessageDto(
|
||||
val id: Long,
|
||||
val text: String = "",
|
||||
val createdAt: String? = null,
|
||||
val lastMessageUser: ForumAuthorDto = ForumAuthorDto(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumTopicDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
val forum: ForumDto,
|
||||
val messages: List<ForumMessageDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateForumTopicRequest(
|
||||
val forumId: Long,
|
||||
val title: String,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateForumMessageRequest(
|
||||
val content: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForumModerationReportRequest(
|
||||
val targetType: String = "forum_message",
|
||||
val targetId: Long,
|
||||
val reason: String,
|
||||
val details: String = "",
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.yourpart.nativeapp.feature.forum
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ForumRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun loadForums(): Result<List<ForumDto>> = get("/api/forum/")
|
||||
|
||||
suspend fun loadForum(forumId: Long, page: Int): Result<ForumPageDto> =
|
||||
get("/api/forum/$forumId/$page")
|
||||
|
||||
suspend fun loadTopic(topicId: Long): Result<ForumTopicDto> = get("/api/forum/topic/$topicId")
|
||||
|
||||
suspend fun createTopic(
|
||||
forumId: Long,
|
||||
title: String,
|
||||
content: String,
|
||||
): Result<ForumPageDto> = post(
|
||||
path = "/api/forum/topic",
|
||||
payload = CreateForumTopicRequest(forumId, title, content),
|
||||
)
|
||||
|
||||
suspend fun createMessage(topicId: Long, content: String): Result<ForumTopicDto> = post(
|
||||
path = "/api/forum/topic/$topicId/message",
|
||||
payload = CreateForumMessageRequest(content),
|
||||
)
|
||||
|
||||
suspend fun reportMessage(messageId: Long, reason: String, details: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(
|
||||
ForumModerationReportRequest(
|
||||
targetId = messageId,
|
||||
reason = reason,
|
||||
details = details,
|
||||
),
|
||||
).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/moderation/reports")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Meldung konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> get(path: String): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path").get().build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<T>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T, reified P> post(path: String, payload: P): Result<T> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = json.encodeToString(payload).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}$path").post(body).build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<T>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package de.yourpart.nativeapp.feature.forum
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun ForumListScreen(
|
||||
uiState: ForumListUiState,
|
||||
onRefresh: () -> Unit,
|
||||
onOpenForum: (Long) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Forum",
|
||||
body = "Themen, Antworten und Meldungen laufen nativ gegen das Community-Backend.",
|
||||
)
|
||||
}
|
||||
item { YpSecondaryButton("Aktualisieren", onRefresh, Modifier.fillMaxWidth()) }
|
||||
uiState.errorMessage?.let { error -> item { YpEmptyState("Forum-Fehler", error) } }
|
||||
if (!uiState.isLoading && uiState.forums.isEmpty()) {
|
||||
item { YpEmptyState("Keine Foren", "Für dein Konto sind derzeit keine Foren zugänglich.") }
|
||||
}
|
||||
items(uiState.forums, key = { it.id }) { forum ->
|
||||
Card(
|
||||
onClick = { onOpenForum(forum.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(forum.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text("Themen anzeigen", style = MaterialTheme.typography.bodySmall, color = YpColors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ForumTopicsScreen(
|
||||
forumId: Long,
|
||||
uiState: ForumTopicsUiState,
|
||||
onLoad: (Long, Int) -> Unit,
|
||||
onToggleCreateTopic: () -> Unit,
|
||||
onUpdateTitle: (String) -> Unit,
|
||||
onUpdateContent: (String) -> Unit,
|
||||
onCreateTopic: () -> Unit,
|
||||
onOpenTopic: (Long) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
LaunchedEffect(forumId) { onLoad(forumId, 1) }
|
||||
val totalPages = maxOf(1, (uiState.totalTopics + 24) / 25)
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = uiState.forumName.ifBlank { "Forum" },
|
||||
body = "${uiState.totalTopics} Thema/Themen. Änderungen werden bei Forum-Updates neu geladen.",
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpSecondaryButton("Zurück", onBack)
|
||||
YpPrimaryButton(
|
||||
label = if (uiState.showCreateTopic) "Erstellen schließen" else "Neues Thema",
|
||||
onClick = onToggleCreateTopic,
|
||||
)
|
||||
}
|
||||
}
|
||||
uiState.errorMessage?.let { error -> item { YpEmptyState("Forum-Fehler", error) } }
|
||||
uiState.statusMessage?.let { message -> item { YpInfoCard("Hinweis", message, accentColor = YpColors.SurfaceAccent) } }
|
||||
if (uiState.showCreateTopic) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.newTopicTitle,
|
||||
onValueChange = onUpdateTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Titel") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.newTopicContent,
|
||||
onValueChange = onUpdateContent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Erster Beitrag") },
|
||||
minLines = 5,
|
||||
)
|
||||
YpPrimaryButton("Thema veröffentlichen", onCreateTopic, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uiState.isLoading && uiState.topics.isEmpty()) {
|
||||
item { YpEmptyState("Noch keine Themen", "Eröffne das erste Thema in diesem Forum.") }
|
||||
}
|
||||
items(uiState.topics, key = { it.id }) { topic ->
|
||||
Card(
|
||||
onClick = { onOpenTopic(topic.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text(topic.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Von ${topic.createdBy.ifBlank { "Community" }} · ${topic.numberOfItems} Beitrag/Beiträge",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpSecondaryButton("Vorherige", { onLoad(forumId, uiState.page - 1) }, enabled = uiState.page > 1)
|
||||
Text("Seite ${uiState.page} von $totalPages", modifier = Modifier.padding(top = spacing.sm))
|
||||
YpSecondaryButton("Nächste", { onLoad(forumId, uiState.page + 1) }, enabled = uiState.page < totalPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ForumTopicScreen(
|
||||
topicId: Long,
|
||||
uiState: ForumTopicUiState,
|
||||
onLoad: (Long) -> Unit,
|
||||
onUpdateReply: (String) -> Unit,
|
||||
onSendReply: () -> Unit,
|
||||
onReportMessage: (Long, String) -> Unit,
|
||||
onBack: (Long) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
var reportMessageId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
var reportReason by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(topicId) { onLoad(topicId) }
|
||||
val topic = uiState.topic
|
||||
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = topic?.title ?: "Thema",
|
||||
body = topic?.forum?.name ?: "Thema wird geladen.",
|
||||
)
|
||||
}
|
||||
topic?.let { loadedTopic ->
|
||||
item { YpSecondaryButton("Zurück zum Forum", { onBack(loadedTopic.forum.id) }, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
uiState.errorMessage?.let { error -> item { YpEmptyState("Thema-Fehler", error) } }
|
||||
uiState.statusMessage?.let { message -> item { YpInfoCard("Hinweis", message, accentColor = YpColors.SurfaceAccent) } }
|
||||
items(topic?.messages.orEmpty(), key = { it.id }) { message ->
|
||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent)) {
|
||||
Column(modifier = Modifier.padding(spacing.md), verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
Text(message.text.asPlainText(), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"${message.lastMessageUser.username.ifBlank { "Community" }}${message.createdAt?.let { " · $it" }.orEmpty()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
YpSecondaryButton("Beitrag melden", { reportMessageId = message.id }, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uiState.isLoading && topic?.messages.isNullOrEmpty()) {
|
||||
item { YpEmptyState("Keine Beiträge", "Dieses Thema enthält noch keine Beiträge.") }
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.reply,
|
||||
onValueChange = onUpdateReply,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Antwort") },
|
||||
minLines = 4,
|
||||
)
|
||||
YpPrimaryButton("Antwort senden", onSendReply, Modifier.fillMaxWidth(), enabled = uiState.reply.isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportMessageId?.let { messageId ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { reportMessageId = null },
|
||||
title = { Text("Beitrag melden") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = reportReason,
|
||||
onValueChange = { reportReason = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Grund") },
|
||||
minLines = 3,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onReportMessage(messageId, reportReason)
|
||||
reportReason = ""
|
||||
reportMessageId = null
|
||||
},
|
||||
enabled = reportReason.trim().length >= 3,
|
||||
) { Text("Melden") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { reportMessageId = null }) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.asPlainText(): String = replace(Regex("<[^>]*>"), " ")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
@@ -0,0 +1,201 @@
|
||||
package de.yourpart.nativeapp.feature.forum
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.yourpart.nativeapp.core.realtime.RealtimeManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ForumListUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val forums: List<ForumDto> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ForumListViewModel @Inject constructor(
|
||||
private val repository: ForumRepository,
|
||||
realtimeManager: RealtimeManager,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ForumListUiState())
|
||||
val uiState: StateFlow<ForumListUiState> = _uiState
|
||||
|
||||
init {
|
||||
refresh()
|
||||
viewModelScope.launch {
|
||||
realtimeManager.events.collect { event ->
|
||||
if (event.eventName in forumRealtimeEvents) refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadForums()
|
||||
.onSuccess { _uiState.value = ForumListUiState(isLoading = false, forums = it) }
|
||||
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = it.message ?: "Foren konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ForumTopicsUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val forumName: String = "",
|
||||
val topics: List<ForumTopicSummaryDto> = emptyList(),
|
||||
val page: Int = 1,
|
||||
val totalTopics: Int = 0,
|
||||
val showCreateTopic: Boolean = false,
|
||||
val newTopicTitle: String = "",
|
||||
val newTopicContent: String = "",
|
||||
val errorMessage: String? = null,
|
||||
val statusMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ForumTopicsViewModel @Inject constructor(
|
||||
private val repository: ForumRepository,
|
||||
realtimeManager: RealtimeManager,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ForumTopicsUiState())
|
||||
val uiState: StateFlow<ForumTopicsUiState> = _uiState
|
||||
private var forumId: Long? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
realtimeManager.events.collect { event ->
|
||||
if (event.eventName in forumRealtimeEvents) forumId?.let { load(it, _uiState.value.page) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(id: Long, page: Int = 1) {
|
||||
forumId = id
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadForum(id, page)
|
||||
.onSuccess { result ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
forumName = result.name,
|
||||
topics = result.titles,
|
||||
page = result.page,
|
||||
totalTopics = result.totalTopics,
|
||||
)
|
||||
}
|
||||
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = it.message ?: "Themen konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleCreateTopic() {
|
||||
_uiState.value = _uiState.value.copy(showCreateTopic = !_uiState.value.showCreateTopic)
|
||||
}
|
||||
|
||||
fun updateNewTopicTitle(value: String) { _uiState.value = _uiState.value.copy(newTopicTitle = value) }
|
||||
fun updateNewTopicContent(value: String) { _uiState.value = _uiState.value.copy(newTopicContent = value) }
|
||||
|
||||
fun createTopic() {
|
||||
val id = forumId ?: return
|
||||
val state = _uiState.value
|
||||
val title = state.newTopicTitle.trim()
|
||||
val content = state.newTopicContent.trim()
|
||||
if (title.length < 3 || content.isBlank()) {
|
||||
_uiState.value = state.copy(errorMessage = "Titel und Beitrag sind erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.createTopic(id, title, content)
|
||||
.onSuccess { page ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
forumName = page.name,
|
||||
topics = page.titles,
|
||||
page = page.page,
|
||||
totalTopics = page.totalTopics,
|
||||
showCreateTopic = false,
|
||||
newTopicTitle = "",
|
||||
newTopicContent = "",
|
||||
statusMessage = "Thema erstellt.",
|
||||
)
|
||||
}
|
||||
.onFailure { _uiState.value = _uiState.value.copy(errorMessage = it.message ?: "Thema konnte nicht erstellt werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ForumTopicUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val topic: ForumTopicDto? = null,
|
||||
val reply: String = "",
|
||||
val errorMessage: String? = null,
|
||||
val statusMessage: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ForumTopicViewModel @Inject constructor(
|
||||
private val repository: ForumRepository,
|
||||
realtimeManager: RealtimeManager,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ForumTopicUiState())
|
||||
val uiState: StateFlow<ForumTopicUiState> = _uiState
|
||||
private var topicId: Long? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
realtimeManager.events.collect { event ->
|
||||
if (event.eventName in forumRealtimeEvents) topicId?.let(::load)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(id: Long) {
|
||||
topicId = id
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
repository.loadTopic(id)
|
||||
.onSuccess { _uiState.value = _uiState.value.copy(isLoading = false, topic = it) }
|
||||
.onFailure { _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = it.message ?: "Thema konnte nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateReply(value: String) { _uiState.value = _uiState.value.copy(reply = value) }
|
||||
|
||||
fun sendReply() {
|
||||
val id = topicId ?: return
|
||||
val content = _uiState.value.reply.trim()
|
||||
if (content.isBlank()) return
|
||||
val messageCountBeforeRequest = _uiState.value.topic?.messages?.size ?: 0
|
||||
viewModelScope.launch {
|
||||
repository.createMessage(id, content)
|
||||
.onSuccess { _uiState.value = _uiState.value.copy(topic = it, reply = "", statusMessage = "Antwort gesendet.") }
|
||||
.onFailure { error ->
|
||||
// The current backend can persist a message before its realtime notification fails.
|
||||
repository.loadTopic(id).onSuccess { refreshedTopic ->
|
||||
if (refreshedTopic.messages.size > messageCountBeforeRequest) {
|
||||
_uiState.value = _uiState.value.copy(topic = refreshedTopic, reply = "", statusMessage = "Antwort gesendet.")
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Antwort konnte nicht gesendet werden.")
|
||||
}
|
||||
}.onFailure {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Antwort konnte nicht gesendet werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reportMessage(messageId: Long, reason: String) {
|
||||
if (reason.trim().length < 3) {
|
||||
_uiState.value = _uiState.value.copy(errorMessage = "Bitte nenne einen Grund mit mindestens drei Zeichen.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.reportMessage(messageId, reason.trim(), "")
|
||||
.onSuccess { _uiState.value = _uiState.value.copy(statusMessage = "Meldung wurde gesendet.") }
|
||||
.onFailure { _uiState.value = _uiState.value.copy(errorMessage = it.message ?: "Meldung konnte nicht gesendet werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val forumRealtimeEvents = setOf("forumschanged", "topicschanged", "messageschanged")
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GalleryVisibilityDto(
|
||||
val id: Long,
|
||||
val description: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryFolderDto(
|
||||
val id: Long,
|
||||
val name: String = "",
|
||||
val children: List<GalleryFolderDto> = emptyList(),
|
||||
val visibilityTypeIds: List<Long> = emptyList(),
|
||||
val selectedUsers: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryImageDto(
|
||||
val id: Long,
|
||||
val title: String = "",
|
||||
val hash: String = "",
|
||||
val folderId: Long = 0,
|
||||
val visibilities: List<GalleryVisibilityDto> = emptyList(),
|
||||
val selectedUsers: List<String> = emptyList(),
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryImageUpdateDto(
|
||||
val title: String,
|
||||
val visibilities: List<GalleryVisibilityDto>,
|
||||
val selectedUsers: List<String>,
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.MultipartUploadHelper
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Request
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GalleryRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
private val uploadHelper = MultipartUploadHelper(context.contentResolver)
|
||||
|
||||
suspend fun loadFolders(): Result<GalleryFolderDto> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/folders")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<GalleryFolderDto>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadImages(folderId: Long): Result<List<GalleryImageDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/folder/$folderId")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<GalleryImageDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadVisibilities(): Result<List<GalleryVisibilityDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/imagevisibilities")
|
||||
.get()
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<GalleryVisibilityDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createFolder(
|
||||
parentFolderId: Long,
|
||||
name: String,
|
||||
visibilities: List<Long>,
|
||||
selectedUsers: List<String> = emptyList(),
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = buildJsonObject {
|
||||
put("name", JsonPrimitive(name))
|
||||
put("parentId", JsonPrimitive(parentFolderId))
|
||||
put("visibilities", buildJsonArray { visibilities.forEach { add(JsonPrimitive(it)) } })
|
||||
put("selectedUsers", buildJsonArray { selectedUsers.forEach { add(JsonPrimitive(it)) } })
|
||||
}.toString()
|
||||
val body = payload.toRequestBody(
|
||||
"application/json".toMediaType(),
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/folders/$parentFolderId")
|
||||
.post(body)
|
||||
.build()
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Ordner konnte nicht erstellt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
folderId: Long,
|
||||
title: String,
|
||||
visibilityIds: List<Long>,
|
||||
selectedUsers: List<String>,
|
||||
uri: Uri,
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val part = uploadHelper.toMultipartPart(uri, "image", fileName = fileNameFromUri(uri))
|
||||
val request = GalleryUploadRequestFactory.create(
|
||||
apiBaseUrl = appConfig.apiBaseUrl,
|
||||
imagePart = part,
|
||||
folderId = folderId,
|
||||
title = title,
|
||||
visibilityJson = json.encodeToString(visibilityIds),
|
||||
selectedUsersJson = json.encodeToString(selectedUsers),
|
||||
)
|
||||
when (requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Bild konnte nicht hochgeladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateImage(
|
||||
imageId: Long,
|
||||
title: String,
|
||||
visibilities: List<GalleryVisibilityDto>,
|
||||
selectedUsers: List<String>,
|
||||
): Result<List<GalleryImageDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = json.encodeToString(
|
||||
GalleryImageUpdateDto(
|
||||
title = title,
|
||||
visibilities = visibilities,
|
||||
selectedUsers = selectedUsers,
|
||||
),
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/socialnetwork/images/$imageId")
|
||||
.put(payload.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<GalleryImageDto>>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException("Bild konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun imageUrl(hash: String): String = "${appConfig.apiBaseUrl}/api/socialnetwork/image/$hash"
|
||||
|
||||
suspend fun reportImage(imageId: Long): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val body = "{\"targetType\":\"image\",\"targetId\":$imageId,\"reason\":\"inappropriate_content\",\"details\":\"Meldung aus der nativen Galerie\"}"
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("${appConfig.apiBaseUrl}/api/moderation/reports").post(body).build()
|
||||
when (requestExecutor.execute(request)) { is ApiResult.Success -> Unit; is ApiResult.Failure -> throw IllegalStateException("Bild konnte nicht gemeldet werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileNameFromUri(uri: Uri): String {
|
||||
return uri.lastPathSegment?.substringAfterLast('/')?.ifBlank { null } ?: "upload.jpg"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import de.yourpart.nativeapp.core.auth.model.AuthSession
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun GalleryScreen(
|
||||
uiState: GalleryUiState,
|
||||
session: AuthSession?,
|
||||
onSelectFolder: (GalleryFolderDto) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onUpdateUploadTitle: (String) -> Unit,
|
||||
onSetUploadUri: (android.net.Uri?) -> Unit,
|
||||
onToggleVisibility: (Long) -> Unit,
|
||||
onUpdateCreateFolderName: (String) -> Unit,
|
||||
onToggleCreateFolderExpanded: () -> Unit,
|
||||
onUpdateSelectedUsersText: (String) -> Unit,
|
||||
onCreateFolder: () -> Unit,
|
||||
onUploadImage: () -> Unit,
|
||||
onOpenImageDetail: (GalleryImageDto) -> Unit,
|
||||
onCloseImageDetail: () -> Unit,
|
||||
onUpdateDetailTitle: (String) -> Unit,
|
||||
onToggleDetailVisibility: (Long) -> Unit,
|
||||
onUpdateDetailSelectedUsersText: (String) -> Unit,
|
||||
onSaveImageDetail: () -> Unit,
|
||||
onReportImage: () -> Unit,
|
||||
imageUrlResolver: (String) -> String,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val pickImageLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia(),
|
||||
) { uri ->
|
||||
onSetUploadUri(uri)
|
||||
}
|
||||
|
||||
val selectedFolder = uiState.rootFolder?.let { root ->
|
||||
findFolder(root, uiState.selectedFolderId) ?: root
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Galerie",
|
||||
body = "Ordner, Bilder und Uploads laufen jetzt nativ gegen das Socialnetwork-Backend.",
|
||||
)
|
||||
}
|
||||
|
||||
uiState.errorMessage?.let { error ->
|
||||
item { YpEmptyState(title = "Galerie-Fehler", body = error) }
|
||||
}
|
||||
|
||||
uiState.statusMessage?.let { message ->
|
||||
item { YpInfoCard(title = "Hinweis", body = message, accentColor = YpColors.SurfaceAccent) }
|
||||
}
|
||||
|
||||
item {
|
||||
YpSecondaryButton(label = "Aktualisieren", onClick = onRefresh, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Ordner",
|
||||
body = selectedFolder?.name?.takeIf { it.isNotBlank() } ?: "Root",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
FolderTree(
|
||||
folder = uiState.rootFolder,
|
||||
selectedFolderId = uiState.selectedFolderId,
|
||||
onSelectFolder = onSelectFolder,
|
||||
)
|
||||
|
||||
YpSecondaryButton(
|
||||
label = if (uiState.createFolderExpanded) "Ordnerformular schließen" else "Ordner anlegen",
|
||||
onClick = onToggleCreateFolderExpanded,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (uiState.createFolderExpanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.createFolderName,
|
||||
onValueChange = onUpdateCreateFolderName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Ordnername") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = uiState.selectedUsersText,
|
||||
onValueChange = onUpdateSelectedUsersText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Sichtbar für Nutzer") },
|
||||
placeholder = { Text("anna, bert, clara") },
|
||||
)
|
||||
VisibilitySelector(
|
||||
visibilities = uiState.visibilities,
|
||||
selectedVisibilityIds = uiState.selectedVisibilityIds,
|
||||
onToggleVisibility = onToggleVisibility,
|
||||
)
|
||||
YpPrimaryButton(
|
||||
label = "Ordner speichern",
|
||||
onClick = onCreateFolder,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Upload",
|
||||
body = if (uiState.uploadUri != null) {
|
||||
"Datei ausgewählt: ${uiState.uploadUri.lastPathSegment ?: uiState.uploadUri}"
|
||||
} else {
|
||||
"Noch keine Bilddatei gewählt."
|
||||
},
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
OutlinedTextField(
|
||||
value = uiState.uploadTitle,
|
||||
onValueChange = onUpdateUploadTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Bildtitel") },
|
||||
)
|
||||
VisibilitySelector(
|
||||
visibilities = uiState.visibilities,
|
||||
selectedVisibilityIds = uiState.selectedVisibilityIds,
|
||||
onToggleVisibility = onToggleVisibility,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpSecondaryButton(
|
||||
label = "Bild wählen",
|
||||
onClick = {
|
||||
pickImageLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
YpPrimaryButton(
|
||||
label = "Hochladen",
|
||||
onClick = onUploadImage,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = uiState.uploadTitle.isNotBlank() && uiState.uploadUri != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
YpInfoCard(
|
||||
title = "Bilder",
|
||||
body = "${uiState.images.size} Bild(er) im aktuellen Ordner",
|
||||
accentColor = YpColors.SurfaceAccent,
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.images.isEmpty()) {
|
||||
item { YpEmptyState(title = "Keine Bilder", body = "In diesem Ordner liegen noch keine Bilder.") }
|
||||
} else {
|
||||
items(uiState.images, key = { it.id }) { image ->
|
||||
GalleryImageCard(
|
||||
image = image,
|
||||
session = session,
|
||||
imageUrl = imageUrlResolver(image.hash),
|
||||
onOpenDetail = { onOpenImageDetail(image) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.detailImage?.let { image ->
|
||||
GalleryImageDetailDialog(
|
||||
image = image,
|
||||
imageUrl = imageUrlResolver(image.hash),
|
||||
session = session,
|
||||
title = uiState.detailTitle,
|
||||
visibilities = uiState.visibilities,
|
||||
selectedVisibilityIds = uiState.detailVisibilityIds,
|
||||
selectedUsersText = uiState.detailSelectedUsersText,
|
||||
onDismiss = onCloseImageDetail,
|
||||
onUpdateTitle = onUpdateDetailTitle,
|
||||
onToggleVisibility = onToggleDetailVisibility,
|
||||
onUpdateSelectedUsers = onUpdateDetailSelectedUsersText,
|
||||
onSave = onSaveImageDetail,
|
||||
onReport = onReportImage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryImageCard(
|
||||
image: GalleryImageDto,
|
||||
session: AuthSession?,
|
||||
imageUrl: String?,
|
||||
onOpenDetail: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = YpColors.SurfaceAccent),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
Text(image.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
if (!imageUrl.isNullOrBlank() && session != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(),
|
||||
contentDescription = image.title,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
YpEmptyState(title = "Vorschau nicht verfügbar", body = "Es fehlen Bild- oder Session-Daten.")
|
||||
}
|
||||
Text(
|
||||
"Datei: ${image.hash}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
if (image.visibilities.isNotEmpty()) {
|
||||
Text(
|
||||
"Sichtbarkeiten: ${image.visibilities.joinToString { it.description }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
YpSecondaryButton(
|
||||
label = "Details bearbeiten",
|
||||
onClick = onOpenDetail,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryImageDetailDialog(
|
||||
image: GalleryImageDto,
|
||||
imageUrl: String,
|
||||
session: AuthSession?,
|
||||
title: String,
|
||||
visibilities: List<GalleryVisibilityDto>,
|
||||
selectedVisibilityIds: Set<Long>,
|
||||
selectedUsersText: String,
|
||||
onDismiss: () -> Unit,
|
||||
onUpdateTitle: (String) -> Unit,
|
||||
onToggleVisibility: (Long) -> Unit,
|
||||
onUpdateSelectedUsers: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onReport: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Bilddetails") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
if (session != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(),
|
||||
contentDescription = image.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = onUpdateTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Bildtitel") },
|
||||
)
|
||||
VisibilitySelector(
|
||||
visibilities = visibilities,
|
||||
selectedVisibilityIds = selectedVisibilityIds,
|
||||
onToggleVisibility = onToggleVisibility,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = selectedUsersText,
|
||||
onValueChange = onUpdateSelectedUsers,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Sichtbar für Nutzer") },
|
||||
placeholder = { Text("anna, bert, clara") },
|
||||
)
|
||||
Text(
|
||||
"Die Bilddatei bleibt unverändert; der MVP bearbeitet nur Titel und Sichtbarkeit.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row { TextButton(onClick = onReport) { Text("Melden") }; TextButton(onClick = onSave) { Text("Speichern") } }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Schließen") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderTree(
|
||||
folder: GalleryFolderDto?,
|
||||
selectedFolderId: Long?,
|
||||
onSelectFolder: (GalleryFolderDto) -> Unit,
|
||||
depth: Int = 0,
|
||||
) {
|
||||
if (folder == null) return
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
FolderRow(
|
||||
folder = folder,
|
||||
selected = folder.id == selectedFolderId,
|
||||
depth = depth,
|
||||
onSelectFolder = onSelectFolder,
|
||||
)
|
||||
folder.children.forEach { child ->
|
||||
FolderTree(
|
||||
folder = child,
|
||||
selectedFolderId = selectedFolderId,
|
||||
onSelectFolder = onSelectFolder,
|
||||
depth = depth + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderRow(
|
||||
folder: GalleryFolderDto,
|
||||
selected: Boolean,
|
||||
depth: Int,
|
||||
onSelectFolder: (GalleryFolderDto) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Card(
|
||||
onClick = { onSelectFolder(folder) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) YpColors.PrimarySoft else YpColors.SurfaceStrong,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = spacing.md + (spacing.sm * depth), top = spacing.sm, end = spacing.md, bottom = spacing.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Text("▸", fontWeight = FontWeight.Bold)
|
||||
Text(folder.name, fontWeight = FontWeight.SemiBold)
|
||||
if (folder.visibilityTypeIds.isNotEmpty()) {
|
||||
Text(
|
||||
folder.visibilityTypeIds.joinToString(prefix = "[", postfix = "]"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = YpColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisibilitySelector(
|
||||
visibilities: List<GalleryVisibilityDto>,
|
||||
selectedVisibilityIds: Set<Long>,
|
||||
onToggleVisibility: (Long) -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
Text("Sichtbarkeit")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
visibilities.forEach { visibility ->
|
||||
FilterChip(
|
||||
selected = selectedVisibilityIds.contains(visibility.id),
|
||||
onClick = { onToggleVisibility(visibility.id) },
|
||||
label = { Text(visibility.description) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = YpColors.SurfaceStrong,
|
||||
selectedContainerColor = YpColors.PrimarySoft,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFolder(folder: GalleryFolderDto, id: Long?): GalleryFolderDto? {
|
||||
if (id == null) return folder
|
||||
if (folder.id == id) return folder
|
||||
for (child in folder.children) {
|
||||
val found = findFolder(child, id)
|
||||
if (found != null) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
|
||||
internal object GalleryUploadRequestFactory {
|
||||
fun create(
|
||||
apiBaseUrl: String,
|
||||
imagePart: MultipartBody.Part,
|
||||
folderId: Long,
|
||||
title: String,
|
||||
visibilityJson: String,
|
||||
selectedUsersJson: String,
|
||||
): Request {
|
||||
val body = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addPart(imagePart)
|
||||
.addFormDataPart("folderId", folderId.toString())
|
||||
.addFormDataPart("title", title)
|
||||
.addFormDataPart("visibility", visibilityJson)
|
||||
.addFormDataPart("selectedUsers", selectedUsersJson)
|
||||
.build()
|
||||
|
||||
return Request.Builder()
|
||||
.url("$apiBaseUrl/api/socialnetwork/images")
|
||||
.post(body)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package de.yourpart.nativeapp.feature.gallery
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class GalleryUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val errorMessage: String? = null,
|
||||
val statusMessage: String? = null,
|
||||
val rootFolder: GalleryFolderDto? = null,
|
||||
val selectedFolderId: Long? = null,
|
||||
val selectedFolderName: String = "",
|
||||
val images: List<GalleryImageDto> = emptyList(),
|
||||
val visibilities: List<GalleryVisibilityDto> = emptyList(),
|
||||
val selectedVisibilityIds: Set<Long> = emptySet(),
|
||||
val uploadTitle: String = "",
|
||||
val uploadUri: Uri? = null,
|
||||
val createFolderName: String = "",
|
||||
val createFolderExpanded: Boolean = false,
|
||||
val selectedUsersText: String = "",
|
||||
val detailImage: GalleryImageDto? = null,
|
||||
val detailTitle: String = "",
|
||||
val detailVisibilityIds: Set<Long> = emptySet(),
|
||||
val detailSelectedUsersText: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class GalleryViewModel @Inject constructor(
|
||||
private val repository: GalleryRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = kotlinx.coroutines.flow.MutableStateFlow(GalleryUiState())
|
||||
val uiState: kotlinx.coroutines.flow.StateFlow<GalleryUiState> = _uiState
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
val folders = repository.loadFolders()
|
||||
val visibilities = repository.loadVisibilities()
|
||||
val root = folders.getOrNull()
|
||||
val selectedFolderId = _uiState.value.selectedFolderId ?: root?.id
|
||||
val selectedFolder = findFolder(root, selectedFolderId)
|
||||
val folderName = selectedFolder?.name ?: root?.name.orEmpty()
|
||||
val images = if (selectedFolderId != null) {
|
||||
repository.loadImages(selectedFolderId).getOrDefault(emptyList())
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
rootFolder = root,
|
||||
selectedFolderId = selectedFolderId,
|
||||
selectedFolderName = folderName,
|
||||
images = images,
|
||||
visibilities = visibilities.getOrDefault(emptyList()),
|
||||
selectedVisibilityIds = _uiState.value.selectedVisibilityIds.ifEmpty {
|
||||
visibilities.getOrDefault(emptyList()).map { it.id }.toSet()
|
||||
},
|
||||
errorMessage = folders.exceptionOrNull()?.message
|
||||
?: visibilities.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun imageUrl(hash: String): String = repository.imageUrl(hash)
|
||||
|
||||
fun selectFolder(folder: GalleryFolderDto) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedFolderId = folder.id,
|
||||
selectedFolderName = folder.name,
|
||||
selectedVisibilityIds = folder.visibilityTypeIds.toSet().ifEmpty { _uiState.value.selectedVisibilityIds },
|
||||
)
|
||||
loadImages(folder.id)
|
||||
}
|
||||
|
||||
fun updateUploadTitle(value: String) {
|
||||
_uiState.value = _uiState.value.copy(uploadTitle = value)
|
||||
}
|
||||
|
||||
fun setUploadUri(uri: Uri?) {
|
||||
_uiState.value = _uiState.value.copy(uploadUri = uri)
|
||||
}
|
||||
|
||||
fun toggleVisibility(id: Long) {
|
||||
val current = _uiState.value.selectedVisibilityIds.toMutableSet()
|
||||
if (!current.add(id)) {
|
||||
current.remove(id)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(selectedVisibilityIds = current)
|
||||
}
|
||||
|
||||
fun updateCreateFolderName(value: String) {
|
||||
_uiState.value = _uiState.value.copy(createFolderName = value)
|
||||
}
|
||||
|
||||
fun toggleCreateFolderExpanded() {
|
||||
_uiState.value = _uiState.value.copy(createFolderExpanded = !_uiState.value.createFolderExpanded)
|
||||
}
|
||||
|
||||
fun updateSelectedUsersText(value: String) {
|
||||
_uiState.value = _uiState.value.copy(selectedUsersText = value)
|
||||
}
|
||||
|
||||
fun openImageDetail(image: GalleryImageDto) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
detailImage = image,
|
||||
detailTitle = image.title,
|
||||
detailVisibilityIds = image.visibilities.map { it.id }.toSet(),
|
||||
detailSelectedUsersText = image.selectedUsers.joinToString(", "),
|
||||
)
|
||||
}
|
||||
|
||||
fun closeImageDetail() {
|
||||
_uiState.value = _uiState.value.copy(detailImage = null)
|
||||
}
|
||||
|
||||
fun updateDetailTitle(value: String) {
|
||||
_uiState.value = _uiState.value.copy(detailTitle = value)
|
||||
}
|
||||
|
||||
fun updateDetailSelectedUsersText(value: String) {
|
||||
_uiState.value = _uiState.value.copy(detailSelectedUsersText = value)
|
||||
}
|
||||
|
||||
fun toggleDetailVisibility(id: Long) {
|
||||
val current = _uiState.value.detailVisibilityIds.toMutableSet()
|
||||
if (!current.add(id)) current.remove(id)
|
||||
_uiState.value = _uiState.value.copy(detailVisibilityIds = current)
|
||||
}
|
||||
|
||||
fun saveImageDetail() {
|
||||
val state = _uiState.value
|
||||
val image = state.detailImage ?: return
|
||||
val title = state.detailTitle.trim()
|
||||
val visibilityIds = state.detailVisibilityIds
|
||||
if (title.isBlank() || visibilityIds.isEmpty()) {
|
||||
_uiState.value = state.copy(errorMessage = "Titel und mindestens eine Sichtbarkeit sind erforderlich.")
|
||||
return
|
||||
}
|
||||
val visibilities = state.visibilities.filter { it.id in visibilityIds }
|
||||
viewModelScope.launch {
|
||||
repository.updateImage(
|
||||
imageId = image.id,
|
||||
title = title,
|
||||
visibilities = visibilities,
|
||||
selectedUsers = parseUsers(state.detailSelectedUsersText),
|
||||
).onSuccess { images ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
images = images,
|
||||
detailImage = null,
|
||||
statusMessage = "Bilddetails gespeichert.",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Bild konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reportDetailImage() {
|
||||
val image = _uiState.value.detailImage ?: return
|
||||
viewModelScope.launch { repository.reportImage(image.id).onSuccess { _uiState.value = _uiState.value.copy(statusMessage = "Bild wurde gemeldet.", detailImage = null) }.onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } }
|
||||
}
|
||||
|
||||
fun createFolder() {
|
||||
val state = _uiState.value
|
||||
val folderName = state.createFolderName.trim()
|
||||
val parentId = state.selectedFolderId ?: state.rootFolder?.id
|
||||
if (folderName.isBlank() || parentId == null) {
|
||||
_uiState.value = state.copy(errorMessage = "Ordnername oder Elternordner fehlt.")
|
||||
return
|
||||
}
|
||||
val visibilities = state.selectedVisibilityIds.toList()
|
||||
if (visibilities.isEmpty()) {
|
||||
_uiState.value = state.copy(errorMessage = "Mindestens eine Sichtbarkeit ist erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.createFolder(
|
||||
parentFolderId = parentId,
|
||||
name = folderName,
|
||||
visibilities = visibilities,
|
||||
selectedUsers = parseUsers(state.selectedUsersText),
|
||||
).onSuccess {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
createFolderName = "",
|
||||
selectedUsersText = "",
|
||||
statusMessage = "Ordner erstellt.",
|
||||
)
|
||||
refresh()
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Ordner konnte nicht erstellt werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadImage() {
|
||||
val state = _uiState.value
|
||||
val folderId = state.selectedFolderId ?: state.rootFolder?.id
|
||||
val uri = state.uploadUri
|
||||
val title = state.uploadTitle.trim()
|
||||
if (folderId == null || uri == null || title.isBlank()) {
|
||||
_uiState.value = state.copy(errorMessage = "Titel, Ordner und Bild sind erforderlich.")
|
||||
return
|
||||
}
|
||||
val visibilities = state.selectedVisibilityIds.toList()
|
||||
if (visibilities.isEmpty()) {
|
||||
_uiState.value = state.copy(errorMessage = "Mindestens eine Sichtbarkeit ist erforderlich.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.uploadImage(
|
||||
folderId = folderId,
|
||||
title = title,
|
||||
visibilityIds = visibilities,
|
||||
selectedUsers = parseUsers(state.selectedUsersText),
|
||||
uri = uri,
|
||||
).onSuccess {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
uploadTitle = "",
|
||||
uploadUri = null,
|
||||
statusMessage = "Bild hochgeladen.",
|
||||
)
|
||||
loadImages(folderId)
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Bild konnte nicht hochgeladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadImages(folderId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.loadImages(folderId)
|
||||
.onSuccess { images ->
|
||||
_uiState.value = _uiState.value.copy(images = images)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(errorMessage = error.message ?: "Bilder konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFolder(folder: GalleryFolderDto?, id: Long?): GalleryFolderDto? {
|
||||
if (folder == null || id == null) return folder
|
||||
if (folder.id == id) return folder
|
||||
for (child in folder.children) {
|
||||
val found = findFolder(child, id)
|
||||
if (found != null) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseUsers(text: String): List<String> {
|
||||
return text.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.yourpart.nativeapp.feature.home
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DashboardWidgetTypeDto(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val endpoint: String,
|
||||
val description: String? = null,
|
||||
val orderId: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardConfigDto(
|
||||
val widgets: List<DashboardConfigWidgetDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardConfigWidgetDto(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val endpoint: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CalendarUpcomingEventDto(
|
||||
val id: Int,
|
||||
val titel: String,
|
||||
val datum: String,
|
||||
val beschreibung: String? = null,
|
||||
val categoryId: String? = null,
|
||||
val allDay: Boolean = false,
|
||||
val startTime: String? = null,
|
||||
val endDate: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CalendarBirthdayDto(
|
||||
val username: String,
|
||||
val hashedId: String,
|
||||
val date: String,
|
||||
val nextDate: String,
|
||||
val daysUntil: Int,
|
||||
val turningAge: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FalukantDashboardDto(
|
||||
val characterName: String = "",
|
||||
val titleLabelTr: String? = null,
|
||||
val nameWithoutTitle: String = "",
|
||||
val gender: String? = null,
|
||||
val age: Int? = null,
|
||||
val money: Double = 0.0,
|
||||
val unreadNotificationsCount: Int = 0,
|
||||
val childrenCount: Int = 0,
|
||||
val debtorsPrison: FalukantDebtorsPrisonDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FalukantDebtorsPrisonDto(
|
||||
val active: Boolean = false,
|
||||
val inDebtorsPrison: Boolean = false,
|
||||
val daysOverdue: Int? = null,
|
||||
val nextForcedAction: String? = null,
|
||||
val creditworthiness: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VocabDashboardDto(
|
||||
val courses: List<VocabDashboardCourseDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VocabDashboardCourseDto(
|
||||
val courseId: Int,
|
||||
val title: String,
|
||||
val currentLesson: VocabDashboardLessonDto? = null,
|
||||
val allLessonsCompleted: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VocabDashboardLessonDto(
|
||||
val id: Int,
|
||||
val lessonNumber: Int,
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.yourpart.nativeapp.feature.home
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Request
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class HomeDashboardData(
|
||||
val widgetTypes: List<DashboardWidgetTypeDto> = emptyList(),
|
||||
val config: DashboardConfigDto = DashboardConfigDto(),
|
||||
val upcomingEvents: List<CalendarUpcomingEventDto> = emptyList(),
|
||||
val birthdays: List<CalendarBirthdayDto> = emptyList(),
|
||||
val falukant: FalukantDashboardDto? = null,
|
||||
val vocab: VocabDashboardDto? = null,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(
|
||||
private val requestExecutor: NetworkRequestExecutor,
|
||||
private val json: Json,
|
||||
private val appConfig: AppConfig,
|
||||
) {
|
||||
suspend fun loadDashboard(): Result<HomeDashboardData> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val widgetTypes = loadWidgetTypes()
|
||||
val config = loadDashboardConfig()
|
||||
val upcomingEvents = loadUpcomingEvents()
|
||||
val birthdays = loadBirthdays()
|
||||
val falukant = loadFalukantDashboard()
|
||||
val vocab = loadVocabDashboard()
|
||||
|
||||
HomeDashboardData(
|
||||
widgetTypes = widgetTypes,
|
||||
config = config,
|
||||
upcomingEvents = upcomingEvents,
|
||||
birthdays = birthdays,
|
||||
falukant = falukant,
|
||||
vocab = vocab,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadWidgetTypes(): List<DashboardWidgetTypeDto> {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/dashboard/widgets")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<DashboardWidgetTypeDto>>(result.value)
|
||||
is ApiResult.Failure -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadDashboardConfig(): DashboardConfigDto {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/dashboard/config")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<DashboardConfigDto>(result.value)
|
||||
is ApiResult.Failure -> DashboardConfigDto()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadUpcomingEvents(): List<CalendarUpcomingEventDto> {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/calendar/widget/upcoming?limit=5")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<CalendarUpcomingEventDto>>(result.value)
|
||||
is ApiResult.Failure -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBirthdays(): List<CalendarBirthdayDto> {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/calendar/widget/birthdays?limit=5")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<List<CalendarBirthdayDto>>(result.value)
|
||||
is ApiResult.Failure -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadFalukantDashboard(): FalukantDashboardDto? {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/falukant/dashboard-widget")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<FalukantDashboardDto>(result.value)
|
||||
is ApiResult.Failure -> null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadVocabDashboard(): VocabDashboardDto? {
|
||||
val request = Request.Builder()
|
||||
.url("${appConfig.apiBaseUrl}/api/vocab/dashboard-widget")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return when (val result = requestExecutor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<VocabDashboardDto>(result.value)
|
||||
is ApiResult.Failure -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package de.yourpart.nativeapp.feature.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpEmptyState
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpServiceStatus
|
||||
import de.yourpart.nativeapp.ui.components.YpStatusChip
|
||||
import de.yourpart.nativeapp.ui.components.YpSecondaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
import de.yourpart.nativeapp.ui.theme.YpColors
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
uiState: HomeUiState,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
val dashboard = uiState.dashboard
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.sm),
|
||||
) {
|
||||
YpInfoCard(
|
||||
title = "Dashboard",
|
||||
body = "Die native Startseite fasst Termine, Geburtstage, Falukant und Vokabeln zusammen.",
|
||||
)
|
||||
|
||||
if (uiState.errorMessage != null) {
|
||||
YpEmptyState(
|
||||
title = "Dashboard konnte nicht geladen werden",
|
||||
body = uiState.errorMessage,
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
YpStatusChip("Backend", YpServiceStatus.Connected)
|
||||
YpStatusChip("Daemon", YpServiceStatus.Connected)
|
||||
}
|
||||
|
||||
if (uiState.isLoading) {
|
||||
YpInfoCard(
|
||||
title = "Lade Daten",
|
||||
body = "Dashboard-Widgets werden aus dem Backend geladen.",
|
||||
)
|
||||
}
|
||||
|
||||
YpPrimaryButton(
|
||||
label = "Aktualisieren",
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
YpInfoCard(
|
||||
title = "Widgets",
|
||||
body = "Aktive Widgets: ${dashboard.config.widgets.size}, verfügbare Module: ${dashboard.widgetTypes.size}",
|
||||
)
|
||||
|
||||
if (dashboard.falukant != null) {
|
||||
YpInfoCard(
|
||||
title = "Falukant",
|
||||
body = buildString {
|
||||
append(dashboard.falukant.characterName)
|
||||
dashboard.falukant.age?.let { append(" · ").append(it).append(" Jahre") }
|
||||
append("\n")
|
||||
append("Geld: ").append(dashboard.falukant.money.toInt())
|
||||
append(" · Nachrichten: ").append(dashboard.falukant.unreadNotificationsCount)
|
||||
append(" · Kinder: ").append(dashboard.falukant.childrenCount)
|
||||
dashboard.falukant.debtorsPrison?.let {
|
||||
append("\n")
|
||||
append(if (it.inDebtorsPrison) "Schuldgefängnis aktiv" else "Kein Schuldgefängnis")
|
||||
it.daysOverdue?.let { days -> append(" · Überfällig: ").append(days).append(" Tage") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (dashboard.upcomingEvents.isNotEmpty()) {
|
||||
YpInfoCard(
|
||||
title = "Termine",
|
||||
body = "Nächste Termine aus dem Kalender.",
|
||||
)
|
||||
dashboard.upcomingEvents.take(3).forEach { event ->
|
||||
YpInfoCard(
|
||||
title = event.titel,
|
||||
body = listOfNotNull(
|
||||
event.datum,
|
||||
event.startTime?.let { "Start $it" },
|
||||
event.beschreibung,
|
||||
).joinToString(" · "),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
YpInfoCard(
|
||||
title = "Termine",
|
||||
body = "Keine bevorstehenden Termine gefunden.",
|
||||
)
|
||||
}
|
||||
|
||||
if (dashboard.birthdays.isNotEmpty()) {
|
||||
YpInfoCard(
|
||||
title = "Geburtstage",
|
||||
body = "Nächste Geburtstage aus dem Freundeskreis.",
|
||||
)
|
||||
dashboard.birthdays.take(3).forEach { birthday ->
|
||||
YpInfoCard(
|
||||
title = birthday.username,
|
||||
body = "${birthday.daysUntil} Tage bis ${birthday.nextDate} · wird ${birthday.turningAge}",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
YpInfoCard(
|
||||
title = "Geburtstage",
|
||||
body = "Keine sichtbaren Geburtstage gefunden.",
|
||||
)
|
||||
}
|
||||
|
||||
if (dashboard.vocab?.courses.orEmpty().isNotEmpty()) {
|
||||
YpInfoCard(
|
||||
title = "Vokabeln",
|
||||
body = "Aktive Kurse und die naechste Lektion.",
|
||||
)
|
||||
dashboard.vocab?.courses.orEmpty().take(3).forEach { course ->
|
||||
YpInfoCard(
|
||||
title = course.title,
|
||||
body = course.currentLesson?.let {
|
||||
"Nächste Lektion ${it.lessonNumber}: ${it.title}" + if (course.allLessonsCompleted) " · abgeschlossen" else ""
|
||||
} ?: "Keine Lektion gefunden",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
YpInfoCard(
|
||||
title = "Vokabeln",
|
||||
body = "Keine eingeschriebenen Kurse gefunden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.yourpart.nativeapp.feature.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class HomeUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val errorMessage: String? = null,
|
||||
val dashboard: HomeDashboardData = HomeDashboardData(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val homeRepository: HomeRepository,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
homeRepository.loadDashboard().onSuccess { dashboard ->
|
||||
_uiState.value = HomeUiState(isLoading = false, dashboard = dashboard)
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Dashboard konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.yourpart.nativeapp.feature.legal
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton class LegalRepository @Inject constructor(private val executor: NetworkRequestExecutor, private val json: Json, private val config: AppConfig) {
|
||||
suspend fun sendContact(email: String, name: String, message: String): Result<Unit> = withContext(Dispatchers.IO) { runCatching {
|
||||
val body = json.encodeToString(mapOf("email" to email, "name" to name, "message" to message, "acceptDataSave" to true)).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/contact").post(body).build()
|
||||
when (executor.execute(request)) { is ApiResult.Success -> Unit; is ApiResult.Failure -> error("Kontaktanfrage konnte nicht gesendet werden.") }
|
||||
} }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.yourpart.nativeapp.feature.legal
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.components.YpTextField
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
@Composable fun LegalScreen(kind: String, onContact: (String, String, String) -> Unit) {
|
||||
val spacing = LocalYpSpacing.current; var email by remember { mutableStateOf("") }; var name by remember { mutableStateOf("") }; var message by remember { mutableStateOf("") }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { when (kind) {
|
||||
"imprint" -> YpInfoCard("Impressum", "Diensteanbieter: Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt, Deutschland. Kontakt: kontakt@your-part.de.")
|
||||
"privacy" -> YpInfoCard("Datenschutzerklärung", "YourPart verarbeitet Konto-, Kontakt-, Inhalts- und technische Nutzungsdaten zur Bereitstellung der Community. Betroffenenrechte, einschließlich Löschung, können über den Kontaktweg geltend gemacht werden. Die vollständige, versionierte Erklärung wird mit der Web-Anwendung gepflegt.")
|
||||
else -> { YpInfoCard("Kontakt und Moderation", "Meldungen in Chat, Forum, Profil, Gästebuch und Galerie gehen direkt an die Moderation. Für sonstige Anliegen, Datenschutzrechte und Kontolöschung nutze dieses Formular."); YpTextField(email, { email = it }, "E-Mail-Adresse"); YpTextField(name, { name = it }, "Name"); YpTextField(message, { message = it }, "Nachricht"); YpPrimaryButton("Nachricht senden", { onContact(email, name, message) }, Modifier.fillMaxWidth(), email.isNotBlank() && name.isNotBlank() && message.isNotBlank()) }
|
||||
} }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.yourpart.nativeapp.feature.legal
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@HiltViewModel class LegalViewModel @Inject constructor(private val repository: LegalRepository) : ViewModel() { fun contact(email: String, name: String, message: String) { viewModelScope.launch { repository.sendContact(email, name, message) } } }
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.yourpart.nativeapp.feature.match3
|
||||
|
||||
import de.yourpart.nativeapp.core.config.AppConfig
|
||||
import de.yourpart.nativeapp.core.network.ApiResult
|
||||
import de.yourpart.nativeapp.core.network.NetworkRequestExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable data class Match3LevelDto(val id: Long, val boardLayout: String = "", val boardWidth: Int = 8, val boardHeight: Int = 8, val moveLimit: Int = 20)
|
||||
@Serializable data class Match3CampaignDto(val id: Long, val levels: List<Match3LevelDto> = emptyList())
|
||||
@Serializable data class Match3CampaignsEnvelope(val success: Boolean, val data: List<Match3CampaignDto> = emptyList())
|
||||
@Serializable data class Match3ProgressPayload(val score: Int, val moves: Int, val time: Int, val stars: Int, val securityHash: String, val timestamp: Long)
|
||||
|
||||
@Singleton class Match3Repository @Inject constructor(private val executor: NetworkRequestExecutor, private val json: Json, private val config: AppConfig) {
|
||||
suspend fun firstLevel(): Result<Pair<Long, Match3LevelDto>> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/match3/campaigns").get().build()
|
||||
val response = when (val result = executor.execute(request)) {
|
||||
is ApiResult.Success -> json.decodeFromString<Match3CampaignsEnvelope>(result.value)
|
||||
is ApiResult.Failure -> throw IllegalStateException(result.error.message)
|
||||
}
|
||||
val campaign = response.data.firstOrNull() ?: error("Keine Match3-Kampagne verfügbar.")
|
||||
campaign.id to (campaign.levels.firstOrNull() ?: error("Kein Match3-Level verfügbar."))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveProgress(campaignId: Long, level: Match3LevelDto, score: Int, moves: Int, stars: Int): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = Match3ProgressPayload(score, moves, 0, stars, progressHash(level, score, moves, stars), System.currentTimeMillis())
|
||||
val body = json.encodeToString(payload).toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder().url("${config.apiBaseUrl}/api/match3/campaigns/$campaignId/levels/${level.id}/progress").post(body).build()
|
||||
when (executor.execute(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Failure -> throw IllegalStateException("Match3-Fortschritt konnte nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun progressHash(level: Match3LevelDto, score: Int, moves: Int, stars: Int): String {
|
||||
val data = "${level.id}|$score|$moves|$stars|true|${level.boardLayout}|${level.moveLimit}"
|
||||
var hash = 0
|
||||
data.forEach { hash = (hash shl 5) - hash + it.code }
|
||||
var saltedHash = 0
|
||||
"$data|YourPart3_Match3_Security_2024".forEach { saltedHash = (saltedHash shl 5) - hash + it.code }
|
||||
return kotlin.math.abs(saltedHash).toString(16)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.yourpart.nativeapp.feature.match3
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.changedToDown
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
private val tileColors = listOf(Color(0xFFE9724C), Color(0xFFFFB347), Color(0xFF4CA6A8), Color(0xFF789D4A), Color(0xFF7D75B8), Color(0xFFC95D93))
|
||||
|
||||
@Composable
|
||||
fun Match3Screen(state: Match3UiState, onTap: (Int) -> Unit, onRestart: () -> Unit) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard("Match3", "Native Canvas-Implementierung: zwei benachbarte Steine antippen, um sie zu tauschen.")
|
||||
Text("Punkte: ${state.score} · Züge: ${state.moves}/${state.moveLimit} · Aufgelöste Steine: ${state.matches}", style = MaterialTheme.typography.titleMedium)
|
||||
Match3Board(state.board, state.selected, onTap)
|
||||
Text(state.message, style = MaterialTheme.typography.bodyMedium)
|
||||
YpPrimaryButton("Level neu starten", onRestart, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Match3Board(board: List<Int>, selected: Int?, onTap: (Int) -> Unit) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 280.dp, max = 560.dp)
|
||||
.pointerInput(board, selected) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.changedToDown() } ?: continue
|
||||
val point = change.position
|
||||
val cell = size.width / 8f
|
||||
val col = (point.x / cell).toInt().coerceIn(0, 7)
|
||||
val row = (point.y / cell).toInt().coerceIn(0, 7)
|
||||
onTap(row * 8 + col)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
val cell = size.minDimension / 8f
|
||||
val origin = Offset((size.width - cell * 8) / 2f, (size.height - cell * 8) / 2f)
|
||||
board.forEachIndexed { index, tile ->
|
||||
val row = index / 8; val col = index % 8
|
||||
val center = Offset(origin.x + col * cell + cell / 2f, origin.y + row * cell + cell / 2f)
|
||||
drawCircle(tileColors[tile.coerceIn(0, tileColors.lastIndex)], cell * .40f, center)
|
||||
if (index == selected) drawCircle(Color.White, cell * .45f, center, style = androidx.compose.ui.graphics.drawscope.Stroke(width = cell * .06f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.yourpart.nativeapp.feature.match3
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val Size = 8
|
||||
private const val Empty = -1
|
||||
|
||||
data class Match3UiState(
|
||||
val board: List<Int> = newBoard(),
|
||||
val selected: Int? = null,
|
||||
val score: Int = 0,
|
||||
val moves: Int = 0,
|
||||
val moveLimit: Int = 20,
|
||||
val matches: Int = 0,
|
||||
val campaignId: Long? = null,
|
||||
val level: Match3LevelDto? = null,
|
||||
val message: String = "Wähle zwei benachbarte Steine.",
|
||||
val completed: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel class Match3ViewModel @Inject constructor(private val repository: Match3Repository) : ViewModel() {
|
||||
private val _state = MutableStateFlow(Match3UiState())
|
||||
val state: StateFlow<Match3UiState> = _state.asStateFlow()
|
||||
init { loadLevel() }
|
||||
|
||||
fun tap(index: Int) {
|
||||
val state = _state.value
|
||||
if (state.completed || state.moves >= state.moveLimit) return
|
||||
val first = state.selected
|
||||
if (first == null) { _state.value = state.copy(selected = index, message = "Jetzt einen benachbarten Stein wählen."); return }
|
||||
if (!adjacent(first, index)) { _state.value = state.copy(selected = index, message = "Bitte nur benachbarte Steine tauschen."); return }
|
||||
val swapped = state.board.toMutableList().also { val tile = it[first]; it[first] = it[index]; it[index] = tile }
|
||||
val found = matches(swapped)
|
||||
if (found.isEmpty()) { _state.value = state.copy(selected = null, message = "Kein Match. Wähle einen neuen Zug."); return }
|
||||
val resolved = resolve(swapped, found)
|
||||
val scoreGain = found.size * 10
|
||||
val nextMoves = state.moves + 1
|
||||
val completed = nextMoves >= state.moveLimit
|
||||
_state.value = state.copy(board = resolved, selected = null, score = state.score + scoreGain, moves = nextMoves, matches = state.matches + found.size, completed = completed, message = if (completed) "Level beendet. Fortschritt wird gespeichert." else "+$scoreGain Punkte. Kaskade aufgelöst.")
|
||||
if (completed) saveProgress()
|
||||
}
|
||||
|
||||
fun restart() { _state.value = Match3UiState(moveLimit = _state.value.moveLimit, campaignId = _state.value.campaignId, level = _state.value.level) }
|
||||
|
||||
private fun loadLevel() = viewModelScope.launch {
|
||||
repository.firstLevel().onSuccess { (campaignId, level) ->
|
||||
_state.value = Match3UiState(moveLimit = level.moveLimit.coerceIn(5, 100), campaignId = campaignId, level = level, message = "Level geladen. Wähle zwei benachbarte Steine.")
|
||||
}.onFailure { _state.value = _state.value.copy(message = "Lokales Trainingsboard: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun saveProgress() {
|
||||
val state = _state.value; val campaign = state.campaignId ?: return; val level = state.level ?: return
|
||||
viewModelScope.launch {
|
||||
val stars = when { state.score >= 500 -> 3; state.score >= 250 -> 2; else -> 1 }
|
||||
repository.saveProgress(campaign, level, state.score, state.moves, stars).onSuccess { _state.value = _state.value.copy(message = "Level beendet und Fortschritt gespeichert.") }.onFailure { _state.value = _state.value.copy(message = "Level beendet. Fortschritt konnte nicht gespeichert werden.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun adjacent(a: Int, b: Int): Boolean = (a / Size == b / Size && kotlin.math.abs(a - b) == 1) || kotlin.math.abs(a - b) == Size
|
||||
private fun matches(board: List<Int>): Set<Int> {
|
||||
val result = mutableSetOf<Int>()
|
||||
for (row in 0 until Size) for (col in 0 until Size) {
|
||||
val index = row * Size + col; val tile = board[index]
|
||||
if (tile == Empty) continue
|
||||
if (col <= Size - 3 && board[index + 1] == tile && board[index + 2] == tile) { result += index; result += index + 1; result += index + 2 }
|
||||
if (row <= Size - 3 && board[index + Size] == tile && board[index + 2 * Size] == tile) { result += index; result += index + Size; result += index + 2 * Size }
|
||||
}
|
||||
return result
|
||||
}
|
||||
private fun resolve(initial: List<Int>, found: Set<Int>): List<Int> {
|
||||
val board = initial.toMutableList(); found.forEach { board[it] = Empty }
|
||||
for (col in 0 until Size) {
|
||||
val kept = (Size - 1 downTo 0).map { board[it * Size + col] }.filter { it != Empty }
|
||||
for (row in Size - 1 downTo 0) board[row * Size + col] = kept.getOrNull(Size - 1 - row) ?: Random.nextInt(6)
|
||||
}
|
||||
return board
|
||||
}
|
||||
private fun newBoard(): List<Int> {
|
||||
while (true) { val board = List(Size * Size) { Random.nextInt(6) }; if (matches(board).isEmpty()) return board }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.yourpart.nativeapp.feature.minigames
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.yourpart.nativeapp.ui.components.YpInfoCard
|
||||
import de.yourpart.nativeapp.ui.components.YpPrimaryButton
|
||||
import de.yourpart.nativeapp.ui.theme.LocalYpSpacing
|
||||
|
||||
@Composable
|
||||
fun MinigamesScreen(onOpenMatch3: () -> Unit, onOpenTaxi: () -> Unit) {
|
||||
val spacing = LocalYpSpacing.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
|
||||
YpInfoCard("Minispiele", "Native Spiele werden beim Verlassen angehalten. Match3 speichert abgeschlossene Level, Taxi speichert den Zwischenstand.")
|
||||
Text("Match3", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Tausche benachbarte Steine und löse Reihen auf.")
|
||||
YpPrimaryButton("Match3 starten", onOpenMatch3, Modifier.fillMaxWidth())
|
||||
Text("Taxi", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Hole Fahrgäste ab, fahre sie zum Ziel und vermeide Kollisionen.")
|
||||
YpPrimaryButton("Taxi starten", onOpenTaxi, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.yourpart.nativeapp.feature.personal
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CalendarEventDto(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val categoryId: String = "personal",
|
||||
val startDate: String,
|
||||
val endDate: String? = null,
|
||||
val startTime: String? = null,
|
||||
val endTime: String? = null,
|
||||
val allDay: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CalendarEventPayload(
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val categoryId: String = "personal",
|
||||
val startDate: String,
|
||||
val endDate: String? = null,
|
||||
val startTime: String? = null,
|
||||
val endTime: String? = null,
|
||||
val allDay: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DiaryEntryDto(
|
||||
val id: Long,
|
||||
val text: String,
|
||||
val createdAt: String? = null,
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DiaryPageDto(
|
||||
val entries: List<DiaryEntryDto> = emptyList(),
|
||||
val totalPages: Int = 1,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user