commit 1eaa7d089601dcbfdbf66406920e0e948e98e62b Author: Theo Tappe Date: Wed May 13 14:19:41 2026 +0200 First Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c9bfc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# IDE / Editor Specific +.idea/ +*.iml +.settings/ +.classpath +.project +*/bin/ +.kotlin/ +kls_database.db +lsp.txt +.jdk + +# Build and Artifacts +**/build/ +!src/**/build/ +.gradle/ +android.aab +android.abb +kotlin-js-store/ +node_modules/ +package-lock.json +package.json + +# Operating System +.DS_Store +thumbs.db + +# Secrets and Local Configs +local.properties +secrets.properties +*.keystore +*.jks +captures +.externalNativeBuild +.cxx + +# Databases (Local/Testing) +*.db +data.db +test.db + +# Xcode (Multiplatform) +xcuserdata +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings + diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ffe06e --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +Bugs: + +- state management in Challenge (also asset select and user select) +- cash updating +- favorize system + +Concept: + +- Challenge with other people with set amount of money and time with the goal goal to make as much money as possible +- + +Features: + +- Search Stocks +- “Buy / Sell” Stocks +- Save / Get Data vie Alpaka API (probably the Broker API) +- Comparison with average +- Random Avatars using https://robohash.org/ + +Optional Features: + +- Leverage +- Analytics +- Challenge in specific Region / Sector etc +- Comparison with all-world etf + +This is a Kotlin Multiplatform project targeting Android, Web, Desktop (JVM), Server. + +* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications. + It contains several subfolders: + - [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets. + - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For + example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, + the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls. Similarly, if you + want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin) folder is the + appropriate location. +* [/server](./server/src/main/kotlin) is for the Ktor server application. +* [/shared](./shared/src) is for the code that will be shared between all targets in the project. + The most important subfolder is [commonMain](./shared/src/commonMain/kotlin). If preferred, you + can add code to the platform-specific folders here too. + +### Build and Run Android Application + +To build and run the development version of the Android app, use the run configuration from the run widget +in your IDE’s toolbar or build it directly from the terminal: + +- on macOS/Linux + + ```shell + ./gradlew :composeApp:assembleDebug + ``` +- on Windows + + ```shell + .\gradlew.bat :composeApp:assembleDebug + ``` + +### Build and Run Desktop (JVM) Application + +To build and run the development version of the desktop app, use the run configuration from the run widget +in your IDE’s toolbar or run it directly from the terminal: + +- on macOS/Linux + + ```shell + ./gradlew :composeApp:run + ``` +- on Windows + + ```shell + .\gradlew.bat :composeApp:run + ``` + +### Build and Run Server + +To build and run the development version of the server, use the run configuration from the run widget +in your IDE’s toolbar or run it directly from the terminal: + +- on macOS/Linux + + ```shell + ./gradlew :server:run + ``` +- on Windows + + ```shell + .\gradlew.bat :server:run + ``` + +### Build and Run Web Application + +To build and run the development version of the web app, use the run configuration from the run widget +in your IDE's toolbar or run it directly from the terminal: + +- for the Wasm target (faster, modern browsers): + - on macOS/Linux + + ```shell + ./gradlew :composeApp:wasmJsBrowserDevelopmentRun + ``` + - on Windows + + ```shell + .\gradlew.bat :composeApp:wasmJsBrowserDevelopmentRun + ``` +- for the JS target (slower, supports older browsers): + - on macOS/Linux + + ```shell + ./gradlew :composeApp:jsBrowserDevelopmentRun + ``` + - on Windows + + ```shell + .\gradlew.bat :composeApp:jsBrowserDevelopmentRun + ``` + +--- + +Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html), +[Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform), +[Kotlin/Wasm](https://kotl.in/wasm/)… + +We would appreciate your feedback on Compose/Web and Kotlin/Wasm in the public Slack +channel [#compose-web](https://slack-chats.kotlinlang.org/c/compose-web). +If you face any issues, please report them on [YouTrack](https://youtrack.jetbrains.com/newIssue?project=CMP). \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4a9d0a1 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,65 @@ +import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin +import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnPlugin + +plugins { + // this is necessary to avoid the plugins to be loaded multiple times + // in each subproject's classloader + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.composeHotReload) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinJvm) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.ktor) apply false + + alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.jib) apply false +} + +rootProject.plugins.withType { +// rootProject.the().download = false +// rootProject.extensions.configure { +// download.set(false) +// } +} + +project.plugins.withType { +// project.the().download = false +// project.the().command = "node" +} + +tasks.register("dockerBuildAll") { + group = "deployment" + dependsOn(":server:serverDockerBuild") + dependsOn(":composeApp:wasmJsDockerBuild") +} + +tasks.register("dockerPushAll") { + group = "deployment" + dependsOn(":server:serverDockerPush") + dependsOn(":composeApp:wasmJsDockerPush") +} + +tasks.register("dockerDeployAll") { + group = "deployment" + dependsOn(":server:serverDockerDeploy") + dependsOn(":composeApp:wasmJsDockerDeploy") +} + +tasks.register("deployAll") { + group = "deployment" + dependsOn(":dockerDeployAll") + dependsOn(":composeApp:androidDeploy") +} + +//tasks.register("checkJsStack") { +// val nodeExt = extensions.findByType() +// val yarnExt = extensions.findByType() +// doLast { +// // Access the resolved paths from the extensions +// println("--- JS Stack Check ---") +// println("Node executable: ${nodeExt.toString()}") +// println("Yarn executable: ${yarnExt.toString()}") +// } +//} \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts new file mode 100644 index 0000000..97036de --- /dev/null +++ b/composeApp/build.gradle.kts @@ -0,0 +1,173 @@ +import org.gradle.kotlin.dsl.support.serviceOf +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import java.util.* + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.composeHotReload) + + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + // androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + androidTarget() + + jvm() + + js { + browser() + binaries.executable() + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + binaries.executable() + } + + sourceSets { + androidMain.dependencies { + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.activity.compose) + implementation(libs.ktor.client.okhttp) + implementation(libs.markdown.renderer.android) + } + commonMain.dependencies { + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui) + implementation(libs.compose.components.resources) + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(projects.shared) + + implementation(libs.bundles.ktor) + implementation(libs.multiplatform.settings) + implementation(libs.material.icons.core) + implementation(libs.material.icons.extended) + implementation(libs.compose.navigation) + implementation(libs.markdown.renderer) + implementation(libs.markdown.renderer.m3) + } + commonTest.dependencies { implementation(libs.kotlin.test) } + jsMain.dependencies { implementation(libs.ktor.client.js) } + wasmJsMain.dependencies { implementation(libs.ktor.client.js) } + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutinesSwing) + implementation(libs.ktor.client.cio) + implementation(libs.markdown.renderer.jvm) + } + } +} + +android { + namespace = "win.tap_tap.papertrader" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + signingConfigs { + create("release") { + val localProperties = Properties() + val localPropertiesFile = rootProject.projectDir.resolve("secrets.properties") + localProperties.load(localPropertiesFile.inputStream()) + storeFile = project.file("upload-key.keystore") + storePassword = localProperties.getProperty("KEYSTORE_PASSWORD") + keyAlias = localProperties.getProperty("KEY_ALIAS") + keyPassword = localProperties.getProperty("KEY_PASSWORD") + } + } + + defaultConfig { + applicationId = "win.tap_tap.papertrader" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 21 + versionName = "1.1.2" + } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + signingConfig = signingConfigs.getByName("release") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { debugImplementation(libs.compose.uiTooling) } + +compose.desktop { + application { + mainClass = "win.tap_tap.papertrader.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "win.tap_tap.papertrader" + packageVersion = "1.0.0" + } + } +} + +allprojects { version = "1.1.2" } + +tasks.register("wasmJsDockerBuild") { + group = "deployment" + dependsOn("wasmJsBrowserDistribution") + workingDir = projectDir + commandLine("docker", "build", "-t", "papertrader-browser", "-f", "src/webMain/Dockerfile", ".") +} + +tasks.register("wasmJsDockerPush") { + group = "deployment" + val version = project.version + val execOps = project.serviceOf() + doLast { + fun execute(args: List) { + execOps.exec { commandLine(args) } + } + execute( + listOf( + "docker", + "tag", + "papertrader-browser", + "taptap1/papertrader-browser:$version" + ) + ) + execute( + listOf("docker", "tag", "papertrader-browser", "taptap1/papertrader-browser:latest") + ) + execute(listOf("docker", "push", "taptap1/papertrader-browser:$version")) + execute(listOf("docker", "push", "taptap1/papertrader-browser:latest")) + println("Pushed: papertrader-browser:$version and papertrader-browser:latest") + } +} + +tasks.register("wasmJsDockerDeploy") { + group = "deployment" + dependsOn("wasmJsDockerBuild") + dependsOn("wasmJsDockerPush") +} + +tasks.named("wasmJsDockerPush") { mustRunAfter("wasmJsDockerBuild") } + +tasks.register("androidDeploy") { + group = "deployment" + dependsOn("bundleRelease") + commandLine( + "cp", + "-f", + "build/outputs/bundle/release/composeApp-release.aab", + "./../android.aab" + ) +} diff --git a/composeApp/de.xml b/composeApp/de.xml new file mode 100644 index 0000000..689c490 --- /dev/null +++ b/composeApp/de.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..60d8831 --- /dev/null +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/win/tap_tap/papertrader/MainActivity.kt b/composeApp/src/androidMain/kotlin/win/tap_tap/papertrader/MainActivity.kt new file mode 100644 index 0000000..47fa936 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/win/tap_tap/papertrader/MainActivity.kt @@ -0,0 +1,23 @@ +package win.tap_tap.papertrader + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setContent { + App() + } + } +} + +//@Preview +//@Composable +//fun AppAndroidPreview() { +// App() +//} \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..bda706c --- /dev/null +++ b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..3c40f44 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a571e60 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..61da551 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c41dd28 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..db5080a Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..6dba46d Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da31a87 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..15ac681 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b216f2d Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f25a419 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e96783c Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..7818b13 --- /dev/null +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -0,0 +1,3 @@ + + papertrader + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml new file mode 100644 index 0000000..eba956a --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/de.xml b/composeApp/src/commonMain/composeResources/drawable/de.xml new file mode 100644 index 0000000..689c490 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/de.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/gb.xml b/composeApp/src/commonMain/composeResources/drawable/gb.xml new file mode 100644 index 0000000..02eae97 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/gb.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/App.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/App.kt new file mode 100644 index 0000000..54489d9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/App.kt @@ -0,0 +1,337 @@ +package win.tap_tap.papertrader + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import androidx.savedstate.serialization.SavedStateConfiguration +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import win.tap_tap.papertrader.pages.* +import win.tap_tap.papertrader.routes.Routes + + +val LocalServer = staticCompositionLocalOf { + error("No Server provided!") +} +val LocalCache = staticCompositionLocalOf { Cache() } +val LocalSpacing = staticCompositionLocalOf { Spacing() } +val LocalMessageHandler = staticCompositionLocalOf { MessageHandler() } +val LocalBackStack = staticCompositionLocalOf> { + error("No Back Stack provided!") +} +val LocalTexts = staticCompositionLocalOf { Texts() } +val LocalCustomColors = staticCompositionLocalOf { error("No custom colors provided!") } +val MaterialTheme.spacing: Spacing + @Composable @ReadOnlyComposable get() = LocalSpacing.current + +@Composable +fun App() { + val cache = LocalCache.current + val snackbarHostState by remember { mutableStateOf(SnackbarHostState()) } + val messageHandler = LocalMessageHandler.current + val texts = LocalTexts.current + val firstRoute = if (!cache.getIgnoreWarning()) { + Routes.LegalWarning + } else if (cache.getLoginResponse() == null) { + Routes.Login + } else { + Routes.Home(0) + } + val backStack = rememberNavBackStack( + SavedStateConfiguration { + serializersModule = SerializersModule { + polymorphic(NavKey::class) { + subclass(Routes.LegalWarning::class, Routes.LegalWarning.serializer()) + subclass(Routes.LegalNotice::class, Routes.LegalNotice.serializer()) + subclass(Routes.PrivacyPolicy::class, Routes.PrivacyPolicy.serializer()) + subclass(Routes.Login::class, Routes.Login.serializer()) + subclass(Routes.Register::class, Routes.Register.serializer()) + subclass(Routes.Home::class, Routes.Home.serializer()) + subclass(Routes.Profile::class, Routes.Profile.serializer()) + subclass(Routes.CreateChallenge::class, Routes.CreateChallenge.serializer()) + subclass(Routes.Challenge::class, Routes.Challenge.serializer()) + subclass(Routes.AssetSelect::class, Routes.AssetSelect.serializer()) + subclass(Routes.UserSelect::class, Routes.UserSelect.serializer()) + } + } + }, + firstRoute + ) + val server = remember { + Server(cache) { message -> + backStack.clear() + backStack.add(Routes.Login) + messageHandler.triggerError(message) + } + } + + LaunchedEffect(Unit) { messageHandler.handleErrors(snackbarHostState) } + + val (colorScheme, customColors) = if (isSystemInDarkTheme()) { + darkTheme to DarkCustomColors + } else { + lightTheme to LightCustomColors + } + MaterialTheme(colorScheme = colorScheme, shapes = appShapes) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background) + ) { + CompositionLocalProvider( + LocalServer provides server, + LocalCache provides cache, + LocalBackStack provides backStack, + LocalTexts provides texts, + LocalCustomColors provides customColors + ) { + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { data -> + val visuals = data.visuals as? CustomSnackbarVisuals + val eventType = visuals?.type ?: SnackbarEvents.ERROR + + val color = + if (eventType == SnackbarEvents.SUCCESS) customColors.success else MaterialTheme.colorScheme.errorContainer + val colorOn = + if (eventType == SnackbarEvents.SUCCESS) customColors.onSuccess else MaterialTheme.colorScheme.onErrorContainer + Snackbar( + snackbarData = data, + containerColor = color, + shape = RoundedCornerShape(MaterialTheme.spacing.medium), + modifier = Modifier.padding(MaterialTheme.spacing.small), + actionColor = colorOn + ) + } + }) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + Box( + modifier = Modifier.fillMaxSize().padding( + start = innerPadding.calculateStartPadding(layoutDirection), + end = innerPadding.calculateEndPadding(layoutDirection), + bottom = innerPadding.calculateBottomPadding() + ) + ) { + NavDisplay(backStack, entryProvider = { key -> + when (key) { + + is Routes.LegalWarning -> { + NavEntry(key) { + Page(heading = texts.getLegalWarningHeading()) { + LegalWarning(onAgreeRedirect = { + if (cache.getLoginResponse() == null) { + backStack.add(Routes.Login) + } else { + backStack.add(Routes.Home(0)) + } + }) + } + } + } + + is Routes.LegalNotice -> { + NavEntry(key) { + Page("Legal Notice", onBackButton = { backStack.removeLast() }) { + LegalNotice() + } + } + } + + is Routes.PrivacyPolicy -> { + NavEntry(key) { + Page("Privacy Policy", onBackButton = { backStack.removeLast() }) { + PrivacyPolicy() + } + } + } + + is Routes.About -> { + NavEntry(key) { + Page("About", onBackButton = { backStack.removeLast() }) { + About() + } + } + } + + is Routes.Login -> { + NavEntry(key) { + val viewModel = viewModel { LoginViewModel(server, messageHandler, cache) } + Page("Login") { + Login( + viewModel = viewModel, + onSuccessfulLogin = { backStack.add(Routes.Home(0)) }, + onRegisterRedirect = { backStack.add(Routes.Register) } + ) + } + } + } + + is Routes.Register -> { + NavEntry(key) { + val viewModel = viewModel { RegisterViewModel(server, messageHandler) } + Page("Register") { + Register( + viewModel = viewModel, + onSuccessfulRegister = { backStack.add(Routes.Login) }, + onLoginRedirect = { backStack.add(Routes.Login) }) + } + } + } + + is Routes.Home -> { + NavEntry(key) { + val viewModel = viewModel(key = key.updaterId.toString()) { + HomeViewModel( + server, + messageHandler + ) + } + Page("Home", onRefresh = { viewModel.reset() }) { + Home( + viewModel = viewModel, + onCreateRedirect = { backStack.add(Routes.CreateChallenge) }, + onProfileRedirect = { backStack.add(Routes.Profile) }, + onChallengeClick = { id -> backStack.add(Routes.Challenge(id)) } + ) + } + } + } + + is Routes.CreateChallenge -> { + NavEntry(key) { + val viewModel = viewModel { CreateChallengeViewModel(server, messageHandler) } + Page("Create Challenge", onBackButton = { + backStack.removeLastOrNull() + viewModel.reset() + }) { + CreateChallenge( + viewModel = viewModel, + onSuccessfulCreation = { backStack.add(Routes.Home(1)) }) + } + } + } + + is Routes.Profile -> { + NavEntry(key) { + val viewModel = viewModel { + ProfileViewModel( + server = server, + messageHandler = messageHandler, + cache = cache, + onSuccessfulLogout = { backStack.add(Routes.Login) }) + } + Page("Profile", onBackButton = { + backStack.removeLastOrNull() + viewModel.reset() + }) { + Profile(viewModel = viewModel) + } + } + } + + is Routes.Challenge -> { + NavEntry(key) { + val viewModel = + viewModel(key = key.challengeId.toString()) { + ChallengeViewModel( + server, + messageHandler, + onChallengeLeave = { backStack.add(Routes.Home(2)) } + ) + } + Page("Challenge", onRefresh = { viewModel.reset() }, onBackButton = { + backStack.removeLastOrNull() + viewModel.reset() + }) { + ChallengeView( + viewModel = viewModel, + key.challengeId, + onUserSearch = { userList, callback -> + backStack.add(Routes.UserSelect(userList, onSelect = callback)) + }, + onAssetSelect = { data -> + backStack.add( + Routes.AssetSelect(data) + ) + }) + } + } + } + + is Routes.UserSelect -> { + NavEntry(key) { + val viewModel = + viewModel(key = key.toString()) { + UserSelectViewModel( + server, + messageHandler, + key.users + ) + } + Page( + "Select User", + onBackButton = { + backStack.removeLastOrNull() + viewModel.reset() + }, + ) { + UserSelect( + viewModel = viewModel, + onFinish = { backStack.removeLastOrNull() }, + onSelect = key.onSelect + ) + } + } + } + + is Routes.AssetSelect -> { + NavEntry(key) { + val viewModel = viewModel(key = key.data.toString()) { + StockSelectViewModel( + server, + messageHandler, + key.data + ) + } + Page( + "Asset Selection", + onBackButton = { + backStack.removeLastOrNull() + viewModel.reset() + } + ) { + StockSelect( + viewModel = viewModel, + onSuccessfulSelect = { backStack.removeLastOrNull() }) + } + } + } + + else -> { + NavEntry(key) { + messageHandler.triggerError("Internal Error: Could not find the route.") + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + backStack.add(Routes.Login) + } + } + } + } + }) + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Cache.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Cache.kt new file mode 100644 index 0000000..15a0d6a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Cache.kt @@ -0,0 +1,54 @@ +package win.tap_tap.papertrader + +import com.russhwolf.settings.Settings + +const val KEY_ACCESS_TOKEN = "accessToken" +const val KEY_REFRESH_TOKEN = "refreshToken" +const val KEY_TOKEN_EXPIRES_IN = "tokenExpiresIn" +const val KEY_LEGAL_WARNING = "legalWarning" + +class Cache() { + + val settings = Settings() + + fun clear() { + for (key in listOf(KEY_ACCESS_TOKEN, KEY_REFRESH_TOKEN, KEY_TOKEN_EXPIRES_IN, KEY_LEGAL_WARNING)) { + settings.remove(key) + } + } + + fun saveLoginResponse(responseLogin: ResponseLogin) { + settings.putString(KEY_ACCESS_TOKEN, responseLogin.accessToken) + settings.putString(KEY_REFRESH_TOKEN, responseLogin.refreshToken) + settings.putLong(KEY_TOKEN_EXPIRES_IN, responseLogin.expiresIn) + } + + fun getLoginResponse(): ResponseLogin? { + val accessToken = settings.getStringOrNull(KEY_ACCESS_TOKEN) + val refreshToken = settings.getStringOrNull(KEY_REFRESH_TOKEN) + val expiresIn = settings.getLongOrNull(KEY_TOKEN_EXPIRES_IN) + if (accessToken == null || refreshToken == null || expiresIn == null) { + return null + } + return ResponseLogin(accessToken, refreshToken, expiresIn) + } + + fun updateAccessToken(accessToken: String, expiresIn: Long) { + settings.putString(KEY_ACCESS_TOKEN, accessToken) + settings.putLong(KEY_TOKEN_EXPIRES_IN, expiresIn) + } + + fun clearLoginResponse() { + settings.remove(KEY_ACCESS_TOKEN) + settings.remove(KEY_REFRESH_TOKEN) + settings.remove(KEY_TOKEN_EXPIRES_IN) + } + + fun saveIgnoreWarning(ignore: Boolean) { + settings.putBoolean(KEY_LEGAL_WARNING, ignore) + } + + fun getIgnoreWarning(): Boolean { + return settings.getBooleanOrNull(KEY_LEGAL_WARNING) ?: return false + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/MessageHandler.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/MessageHandler.kt new file mode 100644 index 0000000..0b83baf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/MessageHandler.kt @@ -0,0 +1,67 @@ +package win.tap_tap.papertrader + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarVisuals +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +enum class SnackbarEvents { + ERROR, SUCCESS +} + +data class CustomSnackbarVisuals( + override val message: String, + override val actionLabel: String? = null, + override val withDismissAction: Boolean = false, + override val duration: SnackbarDuration = SnackbarDuration.Short, + val type: SnackbarEvents +) : SnackbarVisuals + +data class SnackbarEvent( + val message: String, val type: SnackbarEvents +) + +class MessageHandler : ViewModel() { + private val _messageEvents = Channel(Channel.BUFFERED) + val messageEvents = _messageEvents.receiveAsFlow() + + fun triggerError(message: String?) { + var message = message + if (message == null) { + message = "Unknown Error" + } + if (message.isNotEmpty()) { + viewModelScope.launch { + _messageEvents.send(SnackbarEvent(message, SnackbarEvents.ERROR)) + } + } + } + + fun triggerSuccess(message: String?) { + if (!message.isNullOrEmpty()) { + viewModelScope.launch { + _messageEvents.send(SnackbarEvent(message, SnackbarEvents.SUCCESS)) + } + } + } + + suspend fun handleErrors(snackbarHost: SnackbarHostState) { + messageEvents.collect { event -> + snackbarHost.showSnackbar( + CustomSnackbarVisuals( + message = event.message, + type = event.type, + duration = SnackbarDuration.Short, + actionLabel = "✕", + ) + ) +// if (result == SnackbarResult.ActionPerformed) { +// snackbarHost.currentSnackbarData?.dismiss() +// } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Networking.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Networking.kt new file mode 100644 index 0000000..72bd52a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Networking.kt @@ -0,0 +1,113 @@ +package win.tap_tap.papertrader + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.network.sockets.* +import io.ktor.client.plugins.auth.* +import io.ktor.client.plugins.auth.providers.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +class Server(cache: Cache, onUnauthorized: (String?) -> Unit) { + + val server = HttpClient { + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + install(ContentNegotiation) { + json( + json = Json { + ignoreUnknownKeys = true + allowStructuredMapKeys = true + }) + } + install(Auth) { + bearer { + loadTokens { + val loginResponse = cache.getLoginResponse() ?: return@loadTokens null + return@loadTokens BearerTokens(loginResponse.accessToken, loginResponse.refreshToken) + } + refreshTokens { + val loginResponse = cache.getLoginResponse() ?: return@refreshTokens null + val response = sendPostSerializable( + Endpoints.REFRESH_TOKEN, + RequestTokenRefresh(loginResponse.refreshToken) + ) + var token: BearerTokens? = null + response.onFailure { error -> + cache.clearLoginResponse() + onUnauthorized(error.message) + } + response.onSuccess { result -> + cache.updateAccessToken(result.accessToken, result.expiresIn) + val updatedLoginResponse = cache.getLoginResponse() + if (updatedLoginResponse != null) { + token = BearerTokens(updatedLoginResponse.accessToken, updatedLoginResponse.refreshToken) + } + } + return@refreshTokens token + } + } + } + } + + + suspend inline fun safeApiCall( + block: suspend () -> HttpResponse + ): Result { + try { + val response = block() + return when (response.status) { + HttpStatusCode.BadRequest -> { + Result.failure(Exception(response.body())) + } + + HttpStatusCode.InternalServerError -> { + Result.failure(Exception("Something on the server went wrong: ${response.body()}")) + } + + HttpStatusCode.OK -> { + Result.success(response.body()) + } + + else -> { + Result.failure(Exception("Got an unexpected status code ${response.status}: ${response.body()}")) + } + } + } catch (_: ConnectTimeoutException) { + return Result.failure(ConnectTimeoutException("Connection timeout: couldn't reach the server, do have internet?")) + } catch (e: Exception) { + e.printStackTrace() + return Result.failure(Exception("Got an unexpected error: ${e.message}")) + } + } + + + suspend inline fun sendPostSerializable( + endpoint: Endpoints, + request: @Serializable Any? + ): Result { + return safeApiCall { + server.post(endpoint.full()) { + contentType(ContentType.Application.Json) + if (request != null) { + setBody(request) + } + } + } + } + + suspend inline fun sendGet(endpoint: Endpoints): Result { + return safeApiCall { + server.get(endpoint.full()) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Texts.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Texts.kt new file mode 100644 index 0000000..42f448d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Texts.kt @@ -0,0 +1,21 @@ +package win.tap_tap.papertrader + +import org.jetbrains.compose.resources.DrawableResource +import papertrader.composeapp.generated.resources.Res +import papertrader.composeapp.generated.resources.de +import papertrader.composeapp.generated.resources.gb +import win.tap_tap.papertrader.components.LanguageSelectState + +enum class Languages(val flag: DrawableResource, val short: String) { + ENGLISH(Res.drawable.gb, "EN"), + GERMAN(Res.drawable.de, "DE"); +} + + +class Texts { + val languageState = LanguageSelectState() + + fun selectedLanguage(): Languages { + return languageState.selectedLanguage.value + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Theme.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Theme.kt new file mode 100644 index 0000000..b711d70 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/Theme.kt @@ -0,0 +1,92 @@ +package win.tap_tap.papertrader + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +val background = Color.White +val button = ButtonColors(Color(0xFF788AFF), Color.White, Color(0xFF788AFF), Color.White) +val buttonText = Color.White +val primaryText = Color.Black +val secondaryText = Color.Black +val tertiaryText = Color(0xFF8A8A8A) +val greenText = Color(0xFF46FF21) +val redText = Color(0xFFFF4141) +val border = Color(0xFF8A8A8A) + + +val lightTheme = lightColorScheme( + primary = Color(0xFF8178FF), + onPrimary = Color(0xFFFFFFFF), + secondary = Color(0xFFE4FBFF), + onSecondary = Color(0xFF000000), + secondaryContainer = Color(0xFF63B9E5), + onSecondaryContainer = Color(0xFFFFFFFF), + errorContainer = Color(0xFFBB0000), + onErrorContainer = Color(0xFFFFFFFF), + tertiary = Color(0xFF8A8A8A), + background = Color(0xFFFFFFFF), +) + +val darkTheme = darkColorScheme( + primary = Color(0xFFA79BFF), + onPrimary = Color(0xFF000000), + secondary = Color(0xFF00476D), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFF00223E), + onSecondaryContainer = Color(0xFFE4FBFF), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + tertiary = Color(0xFFB4B4B4), + background = Color(0xFF000000), +) + +@Immutable +data class CustomColors( + val success: Color, + val onSuccess: Color, + val greenUp: Color, + val redDown: Color, + val warning: Color +) + +val LightCustomColors = CustomColors( + success = Color(0xFF13BB00), + onSuccess = Color.White, + greenUp = Color(0xFF00C40C), + redDown = Color(0xFFDC0000), + warning = Color(0xFFF5D78B) +) + +val DarkCustomColors = CustomColors( + success = Color(0xFF0F9D00), + onSuccess = Color.Black, + greenUp = Color(0xFF00E60E), + redDown = Color(0xFFFF4D4D), + warning = Color(0xFF634C00) +) + +@Immutable +data class Spacing( + val default: Dp = 0.dp, + val extraSmall: Dp = 4.dp, + val small: Dp = 8.dp, + val medium: Dp = 16.dp, + val large: Dp = 32.dp, + val extraLarge: Dp = 48.dp, + val huge: Dp = 64.dp +) + +val appShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(16.dp), + medium = RoundedCornerShape(20.dp), + large = RoundedCornerShape(24.dp) +) + +val typography = Typography( + +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Buttons.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Buttons.kt new file mode 100644 index 0000000..fec763d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Buttons.kt @@ -0,0 +1,112 @@ +package win.tap_tap.papertrader.components + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import win.tap_tap.papertrader.spacing + +@Composable +fun ButtonIcon(imageVector: ImageVector?) { + if (imageVector != null) { + Icon(imageVector = imageVector, contentDescription = null) + Spacer(modifier = Modifier.size(MaterialTheme.spacing.small)) + } +} + +@Composable +fun AppButton( + text: String? = null, + onClick: () -> Unit = {}, + shape: Shape = MaterialTheme.shapes.small, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + leadingIcon: ImageVector? = null, + content: @Composable () -> Unit = {}, +) { + Button( + onClick = onClick, + enabled = !isLoading, + shape = shape, + modifier = modifier, + ) { + ButtonIcon(imageVector = leadingIcon) + if (isLoading) { + AppProgressIndicator() + } else { + if (text != null) { + Text( + text, color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.bodyMedium + ) + } + content() + } + } +} + +@Composable +fun AppTonalButton( + onClick: () -> Unit, + text: String? = null, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + shape: Shape = MaterialTheme.shapes.small, + leadingIcon: ImageVector? = null, + content: @Composable () -> Unit = {}, +) { + FilledTonalButton( + onClick = onClick, + enabled = !isLoading, + shape = shape, + modifier = modifier + ) { + if (leadingIcon != null) { + ButtonIcon(imageVector = leadingIcon) + } + if (isLoading) { + AppProgressIndicator() + } else if (text != null) { + Text(text) + } + content() + } +} + +@Composable +fun AppOutlinedButton( + onClick: () -> Unit, + text: String? = null, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + shape: Shape = MaterialTheme.shapes.small, + leadingIcon: ImageVector? = null, + content: @Composable () -> Unit = {} +) { + OutlinedButton( + onClick = onClick, + enabled = !isLoading, + shape = shape, + modifier = modifier + ) { + if (leadingIcon != null) { + ButtonIcon(imageVector = leadingIcon) + } + if (isLoading) { + AppProgressIndicator() + } else if (text != null) { + Text(text) + } + content() + } +} + +@Composable +fun ButtonSpacer( + spacing: Dp = MaterialTheme.spacing.small +) { + Spacer(modifier = Modifier.size(spacing)) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Components.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Components.kt new file mode 100644 index 0000000..2a49014 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Components.kt @@ -0,0 +1,213 @@ +package win.tap_tap.papertrader.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource +import win.tap_tap.papertrader.Cash +import win.tap_tap.papertrader.Languages +import win.tap_tap.papertrader.LocalTexts +import win.tap_tap.papertrader.spacing + + +@Composable +fun AppOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + enabled: Boolean = true, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions(), +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.small).then(modifier), + enabled = enabled, + label = { Text(label) }, + shape = RoundedCornerShape(MaterialTheme.spacing.medium), + singleLine = true, + keyboardOptions = keyboardOptions + ) +} + +@Composable +fun AppProgressIndicator( + size: Dp = MaterialTheme.spacing.large, + color: Color = MaterialTheme.colorScheme.onPrimary, + center: Boolean = false +) { + if (center) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(size), color = color) + } + } else { + CircularProgressIndicator(modifier = Modifier.size(size), color = color) + } +} + + +@Composable +fun OutlinedColumn( + modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit +) { + Column( + content = content, + modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.small) + .border( + 1.dp, MaterialTheme.colorScheme.tertiary, shape = RoundedCornerShape(MaterialTheme.spacing.small) + ).background(MaterialTheme.colorScheme.secondary, shape = RoundedCornerShape(MaterialTheme.spacing.small)) + .padding(MaterialTheme.spacing.small).then(modifier), + ) +} + + +//@OptIn(ExperimentalMaterial3Api::class) +//@Composable +//fun AppDropdown(options: List, onSelect: (String) -> Unit) { +// var expanded by remember { mutableStateOf(false) } +// ExposedDropdownMenuBox( +// expanded = expanded, +// onExpandedChange = { expanded = !expanded }, +// modifier = Modifier +// ) { +// AppButton( +// "Select Person", modifier = Modifier.fillMaxWidth().menuAnchor( +// MenuAnchorType.PrimaryEditable +// ) +// ) +// ExposedDropdownMenu( +// expanded, onDismissRequest = {}) { +// options.forEach { text -> +// DropdownMenuItem({ AppText(text) }, onClick = {}) +// } +// } +// } +//} + + +@Composable +fun AppPerformanceOverview( + cash: Cash, + currentValue: Cash, + startValue: Cash, + subheading: String? = null +) { + OutlinedColumn { + if (subheading != null) SubHeading(subheading) + val verticalAlignment = Alignment.CenterVertically + Row { + AppText("Capital Allocation", modifier = Modifier.weight(1f)) + AppText("Performance", modifier = Modifier.weight(1f)) + } + Row { + Row(verticalAlignment = verticalAlignment, modifier = Modifier.weight(1f)) { + AppSecondaryText("Cash: ") + AppTextEmphasized(cash.toString()) + } + Row(verticalAlignment = verticalAlignment, modifier = Modifier.weight(1f)) { + AppSecondaryText("Investment: ") + AppTextPerformance(currentValue, startValue) + } + } + Row { + Row(verticalAlignment = verticalAlignment, modifier = Modifier.weight(1f)) { + AppSecondaryText("Invested: ") + AppTextEmphasized(currentValue.toString()) + } + Row(verticalAlignment = verticalAlignment, modifier = Modifier.weight(1f)) { + AppSecondaryText("Total: ") + AppTextPerformance( + currentValue = currentValue + cash, + startValue = startValue + cash + ) + } + } + } +} + + +class LanguageSelectState { + val selectedLanguage = mutableStateOf(Languages.ENGLISH) +} + +@Composable +fun Flag(language: Languages) { + Icon( + painter = painterResource(language.flag), + contentDescription = language.short, + tint = Color.Unspecified, + modifier = Modifier.size(MaterialTheme.spacing.large) + ) +} + +@Composable +fun LanguageSelect() { + val state = LocalTexts.current.languageState + LazyRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + itemsIndexed(Languages.entries) { index, language -> + if (language == state.selectedLanguage.value) { + AppButton(onClick = { state.selectedLanguage.value = language }) { Flag(language) } + } else { + AppOutlinedButton(onClick = { state.selectedLanguage.value = language }) { Flag(language) } + } + if (index != Languages.entries.lastIndex) { + Spacer(modifier = Modifier.size(MaterialTheme.spacing.small)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppAlertDialog( + show: MutableState, + isLoading: MutableState, + onProceed: () -> Unit, + heading: String, + text: String, +) { + if (show.value) { + BasicAlertDialog(onDismissRequest = { show.value = false }) { + Column( + modifier = Modifier + .background( + MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(MaterialTheme.spacing.medium) + ).padding(MaterialTheme.spacing.medium), + ) { + AppTextEmphasized(heading) + Spacer(Modifier.size(MaterialTheme.spacing.small)) + AppText(text) + ButtonSpacer() + Row { + AppButton( + text = "Cancel", + isLoading = isLoading.value, + modifier = Modifier.weight(0.5f), + onClick = { show.value = false }) + ButtonSpacer() + AppTonalButton( + text = "Confirm", + isLoading = isLoading.value, + modifier = Modifier.weight(0.5f), + onClick = onProceed + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Input.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Input.kt new file mode 100644 index 0000000..8d15f74 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Input.kt @@ -0,0 +1,113 @@ +package win.tap_tap.papertrader.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.lifecycle.ViewModel +import win.tap_tap.papertrader.Cash + +class AppCashInputViewModel : ViewModel() { + var cashInput by mutableStateOf("") + var cash = Cash.zero() + + fun reset() { + cashInput = "" + cash = Cash.zero() + } + + fun updateCash(input: String) { + var cleaned = input.replace(".", "").replace("€", "").trim() + if (cleaned.isEmpty()) { + cash = Cash.zero() + cashInput = "" + return + } + if (cleaned == ",") { + cleaned = "0,00" + } + if (cleaned.replace(",", "").toLongOrNull() == null) { + return + } + val comma = cleaned.lastIndexOf(",") + var euros = "" + var pennies = "" + if (comma == -1) { + euros = cleaned + } else { + pennies = cleaned.substring(comma + 1) + pennies = pennies.padEnd(2, '0') + if (pennies.length > 2) { + pennies = pennies.substring(0, 2) + } + euros = cleaned.substring(0, comma).replace(",", "").trim() + } + cash = Cash(euros.toLongOrNull() ?: 0, pennies.toLongOrNull() ?: 0) + // euros = euros.reversed().chunked(3).joinToString(".").reversed() + if (euros.isEmpty()) { + euros = "0" + } + cashInput = if (pennies.isEmpty()) euros + "€" else euros + "," + pennies + "€" + } +} + +@Composable +fun AppCashInput(viewModel: AppCashInputViewModel) { + AppOutlinedTextField( + value = viewModel.cashInput, + onValueChange = { viewModel.updateCash(it) }, + label = "Cash Amount", + modifier = Modifier, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal + ), + ) +} + +@Composable +fun PasswordInput(password: MutableState, enabled: Boolean) { + var passwordVisible by remember { mutableStateOf(false) } + OutlinedTextField( + value = password.value, + onValueChange = { password.value = it }, + enabled = enabled, + singleLine = true, + label = { Text("Password") }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton( + onClick = { passwordVisible = !passwordVisible } + ) { + Icon( + imageVector = if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) +} + +@Composable +fun UsernameInput(username: MutableState, enabled: Boolean) { + OutlinedTextField( + value = username.value, + onValueChange = { username.value = it }, + enabled = enabled, + singleLine = true, + label = { Text("Username") }, + modifier = Modifier.fillMaxWidth() + ) +} + diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Text.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Text.kt new file mode 100644 index 0000000..e30ca46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/components/Text.kt @@ -0,0 +1,92 @@ +package win.tap_tap.papertrader.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import win.tap_tap.papertrader.Cash +import win.tap_tap.papertrader.LocalCustomColors +import win.tap_tap.papertrader.getRelativePercentage + +@Composable +fun AppTextLink( + text: String, + onClick: () -> Unit, + color: Color = MaterialTheme.colorScheme.primary, + style: TextStyle = MaterialTheme.typography.bodyMedium +) { + TextButton(onClick = onClick) { + Text(text, color = color, style = style) + } +} + +@Composable +fun AppText( + text: String, + color: Color = MaterialTheme.colorScheme.onSecondary, + textAlign: TextAlign = TextAlign.Center, + modifier: Modifier = Modifier +) { + Text( + text, modifier = modifier.then(modifier), + color = color, + style = MaterialTheme.typography.bodyMedium, + textAlign = textAlign, + ) +} + +@Composable +fun AppTextPerformance( + currentValue: Cash, + startValue: Cash +) { + val customColors = LocalCustomColors.current + val performance = getRelativePercentage(currentValue, startValue) + var color = if (performance.startsWith("-")) customColors.redDown else customColors.greenUp + if (performance.startsWith("0.00")) { + color = MaterialTheme.colorScheme.tertiary + } + AppTextEmphasized(performance, color = color) +} + +@Composable +fun AppTextEmphasized( + text: String, + color: Color = MaterialTheme.colorScheme.onSecondary, + textAlign: TextAlign = TextAlign.Center +) { + Text( + text, color = color, + style = MaterialTheme.typography.bodyLarge, + textAlign = textAlign, + fontWeight = FontWeight.Bold + ) +} + +@Composable +fun AppSecondaryText(text: String, textAlign: TextAlign = TextAlign.Center, modifier: Modifier = Modifier) { + Text( + text, + textAlign = textAlign, + modifier = modifier, + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +fun SubHeading(text: String) { + Text( + text, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondary, + textAlign = TextAlign.Center + ) +} diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/About.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/About.kt new file mode 100644 index 0000000..fec0e69 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/About.kt @@ -0,0 +1,165 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.runtime.Composable +import com.mikepenz.markdown.m3.Markdown +import win.tap_tap.papertrader.Languages +import win.tap_tap.papertrader.LocalTexts +import win.tap_tap.papertrader.Texts +import win.tap_tap.papertrader.components.ButtonSpacer +import win.tap_tap.papertrader.components.LanguageSelect + +fun Texts.getAbout(): String { + return when (selectedLanguage()) { +// Languages.ENGLISH -> """ +//#### The Vision +// +//This paper trading platform was designed to bridge the gap between "responsible investing" and the fun of the market without the actual risk of losing capital. +//To do that you can compete against your friends in Challenges, to see who can generate the highest return with a given cash on the markets. +//The app supports Android, Web (via WebAssembly and JS) and Desktop. +// +//#### Motivation +// +// I am Theo Tappe, currently studying informatics and wanted to learn KMP and having some fun with friends using the app. +// +//#### Technologies Used +// +//- **Kotlin Multiplatform (KMP)**: an open-source technology that allows you to use a single codebase for all supported platforms including the server while still running natively +//- **K3S (Kubernetes)**: lightweight Kubernetes distribution for managing the docker server +//- **AlpacaAPI**: an trading API where the server gets all the stock data from +//- **Ktor**: asynchronous framework for datatransfer between server and client +//- **NixOS**: the operating system K3S runs on which is defined declaratively and therefore reproducable and reliable +//- **Proxmox**: type 1 hypervisor that allows to run multiple services on my personal server +//- **SqlDelight**: typsafe API to interact with a sqlight database +// +//#### Roll of Technologies +// +//- **KMP**: +// - Native Performance: KMP compiles to native binaries ensuring that the app stays responsive. +// - Shared Code: All of the frontend code is shared between the supportet platforms, so that the project can be implemented by a single developer. +// - Shared Data: All Data-Transfer-Objects are shared between the frontend and backend which reduces bugs. +//- **Server Managing**: +// - **Proxmox**: has multple virtual maschines and one runs NixOS with Kubernetes +// - **NixOS**: Operating System of the kubernetes (single-node) cluster, with high maintalibility and ... in mind +// - **K3S**: Manages multliple servers with this backend, nextcloud and vaultwarden among other things +//- **Backend**: +// - **Ktor**: handles all the network traffic and data serialisation +// - **SqlDelight**: Typesafe API which is used to store all data +//- **Frontend / KMP**: +// - **Jetpack Compose**: UI implementation which is shared accross all supportet +//""" + + Languages.ENGLISH -> """ +#### Motivation + +This application was developed by Theo Tappe as a comprehensive technical showcase for my application and to have some fun with friends. +It demonstrates a full-stack approach, combining cross-platform development with a robust, automated infrastructure. + + +#### Frontend Architecture + +- **Native Performance via KMP**: +By leveraging Kotlin Multiplatform, the app compiles to native binaries. +This ensures the UI remains fluid and responsive, meeting the high performance standards of modern applications. + +- **Maximum Code Reuse with Compose Multiplatform**: +The entire UI layer is built using Compose Multiplatform, sharing code across all supported platforms. +This ensures feature parity and allows a single developer to maintain a multi-platform ecosystem efficiently. + +- **End-to-End Type Safety**: +To eliminate integration errors, Data Transfer Objects (DTOs) are shared between the frontend and backend. +This creates a "Single Source of Truth," ensuring the app and server are always in sync and reducing bugs during API communication. + + +#### Backend Setup + +- **Zero-Trust Validation**: +While the client provides instant feedback for a smooth UX, all data is strictly validated on the backend. +This ensures the system remains secure and resilient against manipulated client-side requests. + +- **Financial Data Integrity**: +To avoid the precision errors common with floating-point math, +money is handled via a dedicated Cash class using Long values for both major and minor units, +which ensures accuracy for all financial calculations. + +- **Real-World Data Integration**: +The application integrates with the Alpaca API to fetch market data. + +#### Infrastructure & DevOps + +The application is deployed on a self-managed production environment that I have maintained for several years, +hosting services like Nextcloud, GitLab, Homeassistant or Vaultwarden (Bitwarden server). +This project follows Infrastructure as Code principles: + +- **Orchestration with Kubernetes via K3S**: +Backend services are containerized and managed within a K3S cluster. +This setup ensures high availability, automated rollouts, and efficient resource management. + +- **Reproducible Systems via NixOS**: +K3S runs on NixOS, allowing for entirely declarative and reproducible system configurations. +This minimizes "it works on my machine" issues and ensures a stable production environment. + +- **Virtualization with Proxmox**: +The entire stack is hosted on a Proxmox VE cluster. +This allows me to manage an internal network including TrueNAS for storage, OPNsense for networking and NixOS for deploying. + """ + + Languages.GERMAN -> """ +#### Motivation + +Diese Anwendung wurde von Theo Tappe als umfassendes technisches Showcase für meine Bewerbung sowie als Freizeitprojekt für Freunde entwickelt. +Sie demonstriert einen Full-Stack-Ansatz, der moderne plattformübergreifende Entwicklung mit einer robusten, automatisierten Infrastruktur kombiniert. + +#### Frontend-Architektur + +- **Native Performance via KMP**: +Durch die Nutzung von Kotlin Multiplatform kompiliert die App in native Binärdateien. +Dies stellt sicher, dass die Benutzeroberfläche flüssig und reaktionsschnell bleibt und die hohen Leistungsstandards moderner Anwendungen erfüllt. + +- **Maximale Code-Wiederverwendung mit Compose Multiplatform**: +Die gesamte UI-Schicht wurde mit Compose Multiplatform erstellt, wodurch der Code über alle unterstützten Plattformen hinweg geteilt wird. +Dies garantiert Feature-Parität und ermöglicht es einem einzelnen Entwickler, ein plattformübergreifendes Ökosystem effizient zu warten. + +- **End-to-End Typsicherheit**: +Um Integrationsfehler auszuschließen, werden Daten-Transfer-Objekte (DTOs) zwischen Frontend und Backend geteilt. +Dies schafft eine „Single Source of Truth“ und stellt sicher, dass App und Server stets synchron sind, was Fehler bei der API-Kommunikation reduziert. + +#### Backend-Setup + +- **Zero-Trust-Validierung**: +Während der Client sofortiges Feedback für eine optimale UX liefert, werden alle Daten auf dem Backend streng validiert. +Dies stellt sicher, dass das System sicher bleibt und resistent gegen manipulierte Client-Anfragen ist. + +- **Finanzielle Datenintegrität**: +Um Präzisionsfehler zu vermeiden, die bei Fließkommazahlen (Floating-Point) üblich sind, wird Geld über eine dedizierte Cash-Klasse verarbeitet. +Diese nutzt Long-Werte für Haupt- und Untereinheiten (Euro/Cent), was absolute Genauigkeit bei allen Finanzkalkulationen garantiert. + +- **Echtzeit-Datenintegration**: +Die Anwendung integriert die Alpaca-API, um aktuelle Marktdaten abzurufen. + +#### Infrastruktur & DevOps + +Die Anwendung wird in einer selbstverwalteten Produktivumgebung gehostet, die ich seit mehreren Jahren betreibe und auf der Dienste wie Nextcloud, GitLab, Homeassistant oder Vaultwarden laufen. +Das Projekt folgt den Prinzipien von „Infrastructure as Code“: + +- **Orchestrierung mit Kubernetes via K3S**: +Die Backend-Dienste sind containerisiert und werden in einem K3S-Cluster verwaltet. +Dieses Setup gewährleistet Hochverfügbarkeit, automatisierte Rollouts und effizientes Ressourcenmanagement. + +- **Reproduzierbare Systeme via NixOS**: +K3S läuft auf NixOS, was eine vollständig deklarative und reproduzierbare Systemkonfiguration ermöglicht. +Dies minimiert „It works on my machine“-Probleme und sorgt für eine stabile Produktionsumgebung. + +- **Virtualisierung mit Proxmox**: +Der gesamte Stack wird auf einem Proxmox VE-Cluster gehostet. +Dies ermöglicht die Verwaltung eines internen Netzwerks inklusive TrueNAS für Speicherlösungen, OPNsense für das Networking und NixOS für das Deployment. + """ + } +} + +@Composable +fun About() { + val texts = LocalTexts.current + LanguageSelect() + ButtonSpacer() + Markdown(texts.getAbout()) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/AssetSelect.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/AssetSelect.kt new file mode 100644 index 0000000..7a97a80 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/AssetSelect.kt @@ -0,0 +1,179 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.ktor.client.statement.* +import io.ktor.http.* +import win.tap_tap.papertrader.* +import win.tap_tap.papertrader.components.* + +enum class AssetActions { + BUY, + SELL +} + +class StockSelectViewModel( + server: Server, + messageHandler: MessageHandler, + val data: AssetSelectData +) : + PageViewModel(server, messageHandler) { + var search by mutableStateOf("") + var currentAsset by mutableStateOf?>(null) + var currentPositions by mutableStateOf?>(null) + val isLoading = mutableStateOf(false) + val actionWasSuccessful = mutableStateOf(false) + val cashInputModel = AppCashInputViewModel() + + override fun reset() { + search = "" + cashInputModel.reset() + currentAsset = null + currentPositions = null + actionWasSuccessful.value = false + } + + fun updateSearch(input: String, allAssets: List?, allPositions: List?) { + search = input + currentAsset = allAssets?.filter { it.name.contains(search, ignoreCase = true) } ?: emptyList() + currentPositions = allPositions?.filter { it.asset.name.contains(search, ignoreCase = true) } ?: emptyList() + } + + fun buyAsset(asset: Asset, cash: Cash, challengeId: Long, onSuccessfulSelect: () -> Unit) { + handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.ASSET_BUY, + request = RequestAssetBuy(asset.id, cash, challengeId), + isLoading = isLoading, + resultBoolean = actionWasSuccessful, + successMessage = "Bought: ${asset.name}", + onSuccess = { + onSuccessfulSelect() + data.onFinish() + } + ) + } + + fun sellAsset(position: Position, onSuccessfulSelect: () -> Unit) { + handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.ASSET_SELL, + isLoading = isLoading, + request = RequestAssetSell(position.id), + resultBoolean = actionWasSuccessful, + successMessage = "Sold: ${position.asset.name}", + onSuccess = { + onSuccessfulSelect() + data.onFinish() + } + ) + } + + fun handleClick( + asset: Asset?, + position: Position?, + assetAction: AssetActions, + challengeId: Long, + onSuccessfulSelect: () -> Unit + ) { + when (assetAction) { + AssetActions.BUY -> { + if (cashInputModel.cash.asCents() <= 0) { + messageHandler.triggerError("Please select an cash amount above 0.00€") + } else if (asset == null) { + messageHandler.triggerError("Could not find the asset you want to buy") + } else { + buyAsset(asset, cashInputModel.cash, challengeId, onSuccessfulSelect) + } + } + + AssetActions.SELL -> { + if (position == null) { + messageHandler.triggerError("Could not find the position you want to sell") + } else { + sellAsset(position, onSuccessfulSelect) + } + } + } + } +} + +@Composable +fun StockSelect( + viewModel: StockSelectViewModel, + onSuccessfulSelect: () -> Unit +) { + AppOutlinedTextField( + value = viewModel.search, + onValueChange = { viewModel.updateSearch(it, viewModel.data.assets, viewModel.data.positions) }, + label = "Search" + ) + AppCashInput(viewModel.cashInputModel) + OutlinedColumn { + LazyColumn(modifier = Modifier.heightIn(max = 3000.dp)) { + val currentAssets = viewModel.currentAsset ?: viewModel.data.assets ?: emptyList() + val currentPositions = viewModel.currentPositions ?: viewModel.data.positions ?: emptyList() + if (currentAssets.isEmpty() && currentPositions.isEmpty()) { + item { + AppText("Could not find the asset you are looking for.") + } + } else { + itemsIndexed(currentAssets) { index, asset -> + AppTonalButton( + text = asset.name, + onClick = { + viewModel.handleClick( + asset = asset, + position = null, + assetAction = viewModel.data.assetAction, + challengeId = viewModel.data.challengeId, + onSuccessfulSelect = { + onSuccessfulSelect() + viewModel.data.onFinish() + viewModel.reset() + } + ) + }, + isLoading = viewModel.isLoading.value, + modifier = Modifier.fillMaxWidth() + ) + if (index != currentAssets.lastIndex) { + Spacer(modifier = Modifier.size(MaterialTheme.spacing.extraSmall)) + } + } + itemsIndexed(currentPositions) { index, position -> + AppTonalButton( + text = position.asset.name, + onClick = { + viewModel.handleClick( + asset = null, position = position, assetAction = viewModel.data.assetAction, + challengeId = viewModel.data.challengeId, onSuccessfulSelect = { + onSuccessfulSelect() + viewModel.reset() + } + ) + }, + isLoading = viewModel.isLoading.value, + modifier = Modifier.fillMaxWidth() + ) { + AppTextPerformance(position.currentMarketValue(), position.buyMarketValue()) + } + if (index != currentAssets.lastIndex) { + Spacer(modifier = Modifier.size(MaterialTheme.spacing.extraSmall)) + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Challenge.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Challenge.kt new file mode 100644 index 0000000..2f39ecf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Challenge.kt @@ -0,0 +1,381 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.TrendingDown +import androidx.compose.material.icons.automirrored.rounded.TrendingUp +import androidx.compose.material.icons.rounded.PersonAdd +import androidx.compose.material.icons.rounded.PersonRemove +import androidx.compose.material.icons.rounded.PersonSearch +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.ktor.client.statement.* +import io.ktor.http.* +import win.tap_tap.papertrader.* +import win.tap_tap.papertrader.components.* + + +class ChallengeViewModel(server: Server, messageHandler: MessageHandler, val onChallengeLeave: () -> Unit) : + PageViewModel(server, messageHandler) { + val selectedParticipant: MutableState = mutableStateOf(null) + + val data: MutableState = mutableStateOf(null) + val dataIsLoading = mutableStateOf(false) + + val missingUsers: MutableState = mutableStateOf(null) + val missingUsersLoading = mutableStateOf(false) + + val responseGetBuyStocks = mutableStateOf(null) + val buyStockLoading = mutableStateOf(false) + + var requestSellStocks by mutableStateOf(false) + val leaveLoading = mutableStateOf(false) + val kickLoading = mutableStateOf(false) + val showLeaveDialog = mutableStateOf(false) + val userToKick = mutableStateOf(null) + val showKickDialog = mutableStateOf(false) + + override fun reset() { + selectedParticipant.value = null + data.value = null + missingUsers.value = null + responseGetBuyStocks.value = null + requestSellStocks = false + showKickDialog.value = false + showLeaveDialog.value = false + userToKick.value = null + } + + fun loadMissingUsers(challengeId: Long) { + this.handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.GET_MISSING_PARTICIPANTS, + isLoading = missingUsersLoading, + request = RequestGetMissingParticipants(challengeId), + result = missingUsers, + ) + } + + + fun loadData(challengeId: Long) { + this.handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.GET_CHALLENGE_DATA, + isLoading = dataIsLoading, + request = RequestGetParticipants(challengeId), + result = data, + onSuccess = { response -> selectedParticipant.value = response.user } + ) + } + + fun loadBuyStocks() { + this.handleRequest( + method = HttpMethod.Get, + endpoint = Endpoints.GET_STOCKS, + isLoading = buyStockLoading, + result = responseGetBuyStocks, + ) + } + + fun leave(challengeId: Long) { + this.handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.CHALLENGE_LEAVE, + isLoading = leaveLoading, + request = RequestLeaveChallenge(challengeId), + successMessage = "Left the challenge successfully.", + onSuccess = { + showLeaveDialog.value = false + onChallengeLeave() + } + ) + } + + fun kick(challengeId: Long, userId: Long) { + this.handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.CHALLENGE_KICK, + isLoading = kickLoading, + request = RequestChallengeKick(challengeId = challengeId, userId = userId), + successMessage = "Kicked the user Successfully", + onSuccess = { reset() } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChallengeView( + viewModel: ChallengeViewModel, + challengeId: Long, + onUserSearch: (List, (User, MutableState) -> Unit) -> Unit, + onAssetSelect: (AssetSelectData) -> Unit, +) { + val customColors = LocalCustomColors.current + LaunchedEffect(viewModel.data.value) { if (viewModel.data.value == null) viewModel.loadData(challengeId) } + LaunchedEffect(viewModel.missingUsers.value) { + val missingUsers = viewModel.missingUsers.value + if (missingUsers != null) { + onUserSearch(missingUsers.users) { user, isLoading -> + viewModel.handleRequest( + HttpMethod.Post, + Endpoints.ADD_PARTICIPANT, + isLoading = isLoading, + request = RequestAddParticipant(user.id, challengeId), + onSuccess = { viewModel.reset() }, + successMessage = "Added user: ${user.name}" + ) + } + viewModel.missingUsers.value = null + } + } + LaunchedEffect(viewModel.responseGetBuyStocks.value) { + val stocks = viewModel.responseGetBuyStocks.value + if (stocks != null) { + onAssetSelect( + AssetSelectData( + assets = stocks.stocks.sortedBy { it.name }, + positions = null, + assetAction = AssetActions.BUY, + challengeId = challengeId, + onFinish = { viewModel.reset() } + ) + ) + viewModel.responseGetBuyStocks.value = null + } + } + LaunchedEffect(viewModel.requestSellStocks, viewModel.data, viewModel.selectedParticipant) { + val data = viewModel.data.value + val participant = viewModel.selectedParticipant.value + if (viewModel.requestSellStocks && data != null && participant != null) { + onAssetSelect( + AssetSelectData( + assets = null, + positions = data.positions[participant], + assetAction = AssetActions.SELL, + challengeId = challengeId, + onFinish = { viewModel.reset() } + ) + ) + viewModel.requestSellStocks = false + } + } + Column { + // Buttons + Row(modifier = Modifier.fillMaxWidth()) { + AppButton( + "Buy", + modifier = Modifier.weight(0.5F), + isLoading = viewModel.buyStockLoading.value, + onClick = { viewModel.loadBuyStocks() }, + leadingIcon = Icons.AutoMirrored.Rounded.TrendingUp + ) + ButtonSpacer() + AppButton( + "Sell", + modifier = Modifier.weight(0.5F), + isLoading = viewModel.requestSellStocks, + onClick = { viewModel.requestSellStocks = true }, + leadingIcon = Icons.AutoMirrored.Rounded.TrendingDown + ) + } + ButtonSpacer() + val data = viewModel.data.value + val selectedParticipant = viewModel.selectedParticipant.value + val positionsOfSelected = data?.positions?.get(selectedParticipant) + val userIsCreator = data?.userIsCreator ?: false + // Buttons + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + val text = if (selectedParticipant != null) ": ${selectedParticipant.user.name}" else "" + if (data == null || selectedParticipant == null) { + AppProgressIndicator(color = MaterialTheme.colorScheme.onSurface, center = true) + } else { + AppTonalButton( + text = "Select User$text", + onClick = { + onUserSearch( + data.positions.keys.toList().filter { it.user.id != selectedParticipant.user.id } + .map { participant -> participant.user }) { user, _ -> + viewModel.selectedParticipant.value = data.positions.keys.find { it.user == user } + } + }, + modifier = Modifier.weight(0.5F), + leadingIcon = Icons.Rounded.PersonSearch + ) + ButtonSpacer() + AppTonalButton( + text = "Leave Challenge", onClick = { + viewModel.showLeaveDialog.value = true + }, + modifier = Modifier.weight(0.5f), + leadingIcon = Icons.Rounded.Stop + ) + AppAlertDialog( + show = viewModel.showLeaveDialog, + isLoading = viewModel.leaveLoading, + onProceed = { + viewModel.leave(challengeId) + }, + heading = "Confirm Leaving Challenge", + text = "Do you really want to leave the challenge?" + ) + } + } + if (userIsCreator) { + ButtonSpacer() + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + AppTonalButton( + text = "Add Participant", + onClick = { viewModel.loadMissingUsers(challengeId) }, + isLoading = viewModel.missingUsersLoading.value, + modifier = Modifier.weight(0.5f), + leadingIcon = Icons.Rounded.PersonAdd + ) + ButtonSpacer() + AppTonalButton( + text = "Kick User", + onClick = { + onUserSearch(data.positions.keys.map { it.user }) { user, isLoading -> + viewModel.userToKick.value = user + viewModel.showKickDialog.value = true + } + }, + leadingIcon = Icons.Rounded.PersonRemove, + modifier = Modifier.weight(0.5f) + ) + val userToKick = viewModel.userToKick.value + if (userToKick != null) { + AppAlertDialog( + show = viewModel.showKickDialog, + isLoading = viewModel.kickLoading, + heading = "Confirm Kicking User", + text = "Are you sure that you want to kick ${userToKick.name}", + onProceed = { viewModel.kick(challengeId = challengeId, userId = userToKick.id) }) + } + } + } + ButtonSpacer() + // Overview + if (data != null && selectedParticipant != null && positionsOfSelected != null) { + AppPerformanceOverview( + cash = selectedParticipant.cash, + currentValue = positionsOfSelected.currentValue(), + startValue = positionsOfSelected.buyValue(), + subheading = "Overview" + ) + } else { + OutlinedColumn { + SubHeading("Overview") + AppProgressIndicator(color = MaterialTheme.colorScheme.onSecondary, center = true) + } + } + // Leaderboard + OutlinedColumn { + SubHeading("Leaderboard") + if (data != null && selectedParticipant != null) { + val leaderboard = data.positions.map { (participant, positions) -> + Triple( + participant, + positions.buyValue() + participant.cash, + positions.currentValue() + participant.cash + ) + }.sortedByDescending { it.third.asCents() } + LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) { + itemsIndexed(leaderboard) { index, data -> + AppOutlinedButton(onClick = { viewModel.selectedParticipant.value = data.first }) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AppTextEmphasized("${index + 1}.", color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.size(MaterialTheme.spacing.small)) + AppText(data.first.user.name) + } + AppTextPerformance(startValue = data.second, currentValue = data.third) + } + } + } + } + } else { + AppProgressIndicator(color = MaterialTheme.colorScheme.onSecondary, center = true) + } + } + // Positions + OutlinedColumn { + val text = if (data == null || selectedParticipant == null) { + "" + } else if (selectedParticipant == data.user) { + "Your " + } else { + "${selectedParticipant.user.name}'s " + } + SubHeading("${text}Positions") + if (data == null || selectedParticipant == null) { + AppProgressIndicator(color = MaterialTheme.colorScheme.onSecondary, center = true) + } else { + val positions = data.positions[selectedParticipant] + if (positions == null) { + AppText( + "Something unexpected happen here...\nCould not load positions!", + color = customColors.redDown + ) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 1000.dp)) { + item { + Row { + Spacer(modifier = Modifier.fillMaxWidth(0.5f)) + Row( + modifier = Modifier.weight(0.5f), + horizontalArrangement = Arrangement.SpaceBetween + ) { + AppSecondaryText("Value") + AppSecondaryText("Performance") + } + } + } + itemsIndexed(positions) { index, position -> + Row(verticalAlignment = Alignment.CenterVertically) { + AppText( + position.asset.name, + modifier = Modifier.weight(0.5f), + textAlign = TextAlign.Start + ) + Spacer(modifier = Modifier.size(MaterialTheme.spacing.small)) + Row( + modifier = Modifier.weight(0.5f), + horizontalArrangement = Arrangement.SpaceBetween + ) { + AppTextEmphasized((position.currentPrice * position.amount).toString()) + Spacer(modifier = Modifier.size(MaterialTheme.spacing.small)) + AppTextPerformance( + currentValue = position.currentMarketValue(), + startValue = position.buyMarketValue(), + ) + } + } + if (index < positions.lastIndex) { + Box( + modifier = Modifier.fillMaxWidth().height(1.dp) + .background(color = MaterialTheme.colorScheme.tertiary, RectangleShape) + .padding(horizontal = MaterialTheme.spacing.small) + ) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/CreateChallenge.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/CreateChallenge.kt new file mode 100644 index 0000000..b0fcf65 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/CreateChallenge.kt @@ -0,0 +1,63 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.runtime.* +import io.ktor.client.statement.* +import io.ktor.http.* +import win.tap_tap.papertrader.Endpoints +import win.tap_tap.papertrader.MessageHandler +import win.tap_tap.papertrader.RequestCreateChallenge +import win.tap_tap.papertrader.Server +import win.tap_tap.papertrader.components.AppButton +import win.tap_tap.papertrader.components.AppCashInput +import win.tap_tap.papertrader.components.AppCashInputViewModel +import win.tap_tap.papertrader.components.AppOutlinedTextField + +class CreateChallengeViewModel(server: Server, messageHandler: MessageHandler) : PageViewModel(server, messageHandler) { + var name by mutableStateOf("") + private set + val isLoading = mutableStateOf(false) + val createdSuccessfully = mutableStateOf(false) + val cashInputModel = AppCashInputViewModel() + + override fun reset() { + name = "" + createdSuccessfully.value = false + } + + fun updateName(input: String) { + name = input + } + + fun createChallenge() { + this.handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.CREATE_CHALLENGE, + request = RequestCreateChallenge(name, cashInputModel.cash.euro, cashInputModel.cash.cent), + isLoading = isLoading, + resultBoolean = createdSuccessfully, + successMessage = "Challenge Created", + ) + } +} + +@Composable +fun CreateChallenge(viewModel: CreateChallengeViewModel, onSuccessfulCreation: () -> Unit) { + LaunchedEffect(viewModel.createdSuccessfully.value) { + val createdSuccessfully = viewModel.createdSuccessfully.value + if (createdSuccessfully) { + onSuccessfulCreation() + viewModel.reset() + } + } + + AppOutlinedTextField( + value = viewModel.name, onValueChange = { viewModel.updateName(it) }, + label = "Challenge Name" + ) + AppCashInput(viewModel.cashInputModel) + AppButton( + text = "Create", + onClick = { viewModel.createChallenge() }, + isLoading = viewModel.isLoading.value + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Home.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Home.kt new file mode 100644 index 0000000..af6cf12 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Home.kt @@ -0,0 +1,121 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import io.ktor.http.* +import win.tap_tap.papertrader.* +import win.tap_tap.papertrader.components.* + +class HomeViewModel(server: Server, messageHandler: MessageHandler) : + PageViewModel(server, messageHandler) { + + var challenges: MutableState = mutableStateOf(null) + var challengesLoading = mutableStateOf(false) + + override fun reset() { + challenges.value = null + } + + fun loadChallenges() { + this.handleRequest( + method = HttpMethod.Get, + endpoint = Endpoints.GET_CHALLENGES, + isLoading = challengesLoading, + result = challenges + ) + } +} + +@Composable +fun Home( + viewModel: HomeViewModel, + onCreateRedirect: () -> Unit, + onProfileRedirect: () -> Unit, + onChallengeClick: (id: Long) -> Unit +) { + LaunchedEffect(viewModel.challenges.value) { + if (viewModel.challenges.value == null) { + viewModel.loadChallenges() + } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + AppButton( + "Profile", + onClick = { + onProfileRedirect() + viewModel.reset() + }, + leadingIcon = Icons.Rounded.Person, + modifier = Modifier.weight(0.4f) + ) + ButtonSpacer() + AppButton( + "Create", + onClick = { + onCreateRedirect() + viewModel.reset() + }, + leadingIcon = Icons.Rounded.Add, + modifier = Modifier.weight(0.4f) + ) + } +// AppPerformanceOverview("Overview over all Challenges") + OutlinedColumn { + SubHeading("Your Challenges") + Column(Modifier.fillMaxWidth()) { + // item { +// Column { +// Row(Modifier.fillMaxWidth()) { +// Row(Modifier.weight(0.7f)) { +// // TODO +// } +// Column(Modifier.weight(0.3f).padding(0.dp, 0.dp, 6.dp, 0.dp)) { +// AppSecondaryText("Performances", modifier = Modifier.fillMaxWidth()) +// Row( +// Modifier.align(Alignment.CenterHorizontally).fillMaxWidth(), +// horizontalArrangement = Arrangement.SpaceBetween +// ) { +// AppSecondaryText("You") +// AppSecondaryText("Best") +// } +// } +// } +// } + if (viewModel.challengesLoading.value) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(MaterialTheme.spacing.large), + color = MaterialTheme.colorScheme.onSecondary + ) + } + } + } + val response = viewModel.challenges.value + if (response != null) { + for (challenge in response.challenges) { + AppTonalButton( + text = challenge.name, + onClick = { + onChallengeClick(challenge.id) + viewModel.reset() + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/LegalNotice.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/LegalNotice.kt new file mode 100644 index 0000000..c0cb196 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/LegalNotice.kt @@ -0,0 +1,92 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.runtime.Composable +import com.mikepenz.markdown.m3.Markdown +import win.tap_tap.papertrader.Languages +import win.tap_tap.papertrader.LocalTexts +import win.tap_tap.papertrader.Texts +import win.tap_tap.papertrader.components.ButtonSpacer +import win.tap_tap.papertrader.components.LanguageSelect + +fun Texts.getLegalNotice(): String { + return when (selectedLanguage()) { + Languages.GERMAN -> """ +**Impressum**\ +Angaben gemäß § 5 DDG\ +Theo Tappe\ +c/o Impressumservice Dein-Impressum\ +Stettiner Str. 41\ +35410 Hungen\ +\ +Bitte versenden Sie keine Pakete an dieser Adresse.\ +\ +**Kontakt**:\ +E-Mail: taptap.papertrader@gmail.com\ +Telefon: +49 157 92341658\ +\ +**Verbraucherstreitbeilegung / Universalschlichtungsstelle**:\ +Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor +einer Verbraucherschlichtungsstelle teilzunehmen.\ +\ +**Haftung für Inhalte**:\ +Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. +Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen +oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. +Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. +Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. +Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.\ +\ +**Urheberrecht**\ +Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. +Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts +bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien +dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet. +Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. +Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, +bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen. + """ + + Languages.ENGLISH -> """ +**Legal Notice**\ +Information pursuant to § 5 DDG\ +Theo Tappe\ +c/o Impressumservice Dein-Impressum\ +Stettiner Str. 41\ +35410 Hungen\ +\ +Please do not send packages to this address.\ +\ +**Contact**:\ +Email: taptap.papertrader@gmail.com\ +Phone: +49 157 92341658\ +\ +**Consumer dispute resolution / Universal arbitration board**:\ +We are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board.\ +\ +**Liability for Content**:\ +As a service provider, we are responsible for our own content on these pages in accordance with general laws pursuant to § 7 (1) DDG. +However, according to §§ 8 to 10 DDG, we as a service provider are not obliged to monitor transmitted or +stored third-party information or to investigate circumstances that indicate illegal activity. +Obligations to remove or block the use of information in accordance with general laws remain unaffected. +However, liability in this regard is only possible from the moment of knowledge of a specific legal violation. +If we become aware of such legal violations, we will remove this content immediately.\ +\ +**Copyright**\ +The content and works created by the site operators on these pages are subject to German copyright law. +The duplication, processing, distribution, and any kind of exploitation outside the limits of copyright +law require the written consent of the respective author or creator. +Downloads and copies of this site are only permitted for private, non-commercial use. +Insofar as the content on this site was not created by the operator, the copyrights of third parties are respected. +In particular, third-party content is marked as such. Should you nevertheless become aware of a copyright infringement, please let us know. +If we become aware of legal violations, we will remove such content immediately. + """ + } +} + +@Composable +fun LegalNotice() { + val texts = LocalTexts.current + LanguageSelect() + ButtonSpacer() + Markdown(texts.getLegalNotice()) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/LegalWarning.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/LegalWarning.kt new file mode 100644 index 0000000..e389c47 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/LegalWarning.kt @@ -0,0 +1,113 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import com.mikepenz.markdown.m3.Markdown +import win.tap_tap.papertrader.* +import win.tap_tap.papertrader.components.AppButton +import win.tap_tap.papertrader.components.AppText +import win.tap_tap.papertrader.components.ButtonSpacer +import win.tap_tap.papertrader.components.LanguageSelect + +fun Texts.getConfirmLegalWarning(): String { + return when (selectedLanguage()) { + Languages.ENGLISH -> "I understand & Accept" + Languages.GERMAN -> "Ich verstehe & Akzeptiere" + } +} + +fun Texts.getLegalWarningDontShowAgain(): String { + return when (selectedLanguage()) { + Languages.ENGLISH -> "Don't show this again" + Languages.GERMAN -> "Diesen Hinweis nicht mehr anzeigen" + } +} + +fun Texts.getLegalWarningHeading(): String { + return when (selectedLanguage()) { + Languages.ENGLISH -> "Disclaimer" + Languages.GERMAN -> "Haftungsausschluss" + } +} + +fun Texts.getLegalWarning(): String { + return when (selectedLanguage()) { + Languages.ENGLISH -> """ +- This application is a trading simulation and is intended solely for educational and testing purposes. +- **No real money is involved. All balances, profits, and losses are strictly virtual and cannot be withdrawn or converted into real currency.** +- This app does not provide access to real market exchanges or brokerage services. +- It does not constitute investment advice, financial analysis, or an offer to buy or sell financial instruments. +- Simulated trading results do not reflect real-world trading conditions (such as liquidity, slippage, or fees). +- Past performance is no guarantee of future results. +- The data provided may be delayed or inaccurate. +- The operator assumes no liability for financial losses or damages resulting from the use of this application. +- Users must be at least 18 years of age (or the legal age in their jurisdiction). +- Use of this simulation is at your own risk. + """ + + Languages.GERMAN -> """ +- Diese Anwendung ist eine Trading-Simulation und dient ausschließlich Bildungs- und Testzwecken. +- **Es ist kein echtes Geld im Spiel. Alle Guthaben, Gewinne und Verluste sind rein virtuell und können nicht ausgezahlt oder in echte Währung umgerechnet werden.** +- Diese App bietet keinen Zugang zu echten Börsen oder Broker-Dienstleistungen. +- Sie stellt keine Anlageberatung, Finanzanalyse oder Aufforderung zum Kauf oder Verkauf von Finanzinstrumenten dar. +- Simulierte Handelsergebnisse bilden die realen Marktbedingungen (wie Liquidität, Slippage oder Gebühren) nicht vollständig ab. +- Vergangene Erfolge sind keine Garantie für zukünftige Gewinne. +- Die bereitgestellten Daten können zeitverzögert oder ungenau sein. +- Der Betreiber übernimmt keine Haftung für finanzielle Verluste oder Schäden, die aus der Nutzung dieser App resultieren. +- Nutzer müssen mindestens 18 Jahre alt sein (oder das gesetzliche Mindestalter in ihrer Gerichtsbarkeit erreicht haben). +- Die Nutzung erfolgt auf eigene Gefahr. + """ + } +} + +@Composable +fun LegalWarning(onAgreeRedirect: () -> Unit) { + val texts = LocalTexts.current + val cache = LocalCache.current + val customColors = LocalCustomColors.current + val ignoreLegalWarning = remember { mutableStateOf(false) } + LanguageSelect() + ButtonSpacer() + Column( + verticalArrangement = Arrangement.Center, modifier = Modifier.background( + color = customColors.warning, shape = RoundedCornerShape( + MaterialTheme.spacing.medium + ) + ).padding(MaterialTheme.spacing.small) + ) { + Markdown(texts.getLegalWarning()) + ButtonSpacer() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clip(shape = RoundedCornerShape(MaterialTheme.spacing.medium)) + .toggleable(ignoreLegalWarning.value, onValueChange = { ignoreLegalWarning.value = it }) + .padding(MaterialTheme.spacing.small) + ) { + Checkbox( + checked = ignoreLegalWarning.value, + onCheckedChange = null + ) + AppText( + text = texts.getLegalWarningDontShowAgain(), + color = MaterialTheme.colorScheme.onSecondary + ) + } + ButtonSpacer() + Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + AppButton(text = texts.getConfirmLegalWarning(), onClick = { + cache.saveIgnoreWarning(ignoreLegalWarning.value) + onAgreeRedirect() + }) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Login.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Login.kt new file mode 100644 index 0000000..0e8436b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Login.kt @@ -0,0 +1,71 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Login +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.ktor.http.* +import win.tap_tap.papertrader.* +import win.tap_tap.papertrader.components.AppButton +import win.tap_tap.papertrader.components.AppTextLink +import win.tap_tap.papertrader.components.PasswordInput +import win.tap_tap.papertrader.components.UsernameInput + +class LoginViewModel( + server: Server, + messageHandler: MessageHandler, + val cache: Cache +) : + PageViewModel(server, messageHandler) { + + val isLoading = mutableStateOf(false) + val password = mutableStateOf("") + val username = mutableStateOf("") + val responseLogin = mutableStateOf(null) + + override fun reset() { + password.value = "" + responseLogin.value = null + } + + fun login() { + this.handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.LOGIN, + isLoading = isLoading, + request = RequestLoginUser(username.value, password.value), + result = responseLogin, + successMessage = "Logged in successfully", + onSuccess = { response -> cache.saveLoginResponse(response) } + ) + } +} + +@Composable +fun Login(viewModel: LoginViewModel, onSuccessfulLogin: () -> Unit, onRegisterRedirect: () -> Unit) { + LaunchedEffect(viewModel.responseLogin.value) { + if (viewModel.responseLogin.value != null) { + onSuccessfulLogin() + viewModel.reset() + } + } + UsernameInput(viewModel.username, enabled = !viewModel.isLoading.value) + PasswordInput(viewModel.password, enabled = !viewModel.isLoading.value) + AppButton( + onClick = { viewModel.login() }, + content = { Text("Login") }, + isLoading = viewModel.isLoading.value, + leadingIcon = Icons.AutoMirrored.Rounded.Login, + modifier = Modifier.padding(horizontal = 0.dp, vertical = MaterialTheme.spacing.small) + ) + AppTextLink("Don't have an account?", onClick = { + onRegisterRedirect() + viewModel.reset() + }) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Page.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Page.kt new file mode 100644 index 0000000..8ebef7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Page.kt @@ -0,0 +1,95 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronLeft +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.ktor.util.reflect.* +import win.tap_tap.papertrader.LocalBackStack +import win.tap_tap.papertrader.components.AppTextLink +import win.tap_tap.papertrader.components.ButtonSpacer +import win.tap_tap.papertrader.routes.Routes +import win.tap_tap.papertrader.spacing + +@Composable +fun Page( + heading: String, + viewModel: PageViewModel? = null, + onBackButton: (() -> Unit)? = null, + onRefresh: (() -> Unit)? = null, + content: @Composable () -> Unit +) { + val backStack = LocalBackStack.current + Box(modifier = Modifier.fillMaxSize()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.medium).padding(top = MaterialTheme.spacing.medium) + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (onBackButton != null) { + FilledIconButton(onClick = onBackButton) { + Icon( + imageVector = Icons.Rounded.ChevronLeft, + contentDescription = "Back" + ) + } + } else { + Spacer(modifier = Modifier.size(1.dp)) + } + } + Text( + heading, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = MaterialTheme.spacing.medium) + ) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + if (onRefresh != null) { + FilledIconButton(onClick = onRefresh) { + Icon(imageVector = Icons.Rounded.Refresh, contentDescription = "Refresh") + } + } else { + Spacer(modifier = Modifier.size(1.dp)) + } + } + } + content() + Spacer(modifier = Modifier.weight(1f)) + val curNavKey = backStack.last() + Row { + if (!curNavKey.instanceOf(Routes.LegalNotice::class)) { + AppTextLink("Legal Notice", onClick = { + backStack.add(Routes.LegalNotice) + viewModel?.reset() + }) + ButtonSpacer() + } + if (!curNavKey.instanceOf(Routes.About::class)) { + AppTextLink("About", onClick = { + backStack.add(Routes.About) + viewModel?.reset() + }) + ButtonSpacer() + } + if (!curNavKey.instanceOf(Routes.PrivacyPolicy::class)) { + AppTextLink("Privacy Policy", onClick = { + backStack.add(Routes.PrivacyPolicy) + viewModel?.reset() + }) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/PageViewModel.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/PageViewModel.kt new file mode 100644 index 0000000..2f3ba36 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/PageViewModel.kt @@ -0,0 +1,71 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.runtime.MutableState +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.ktor.http.* +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import win.tap_tap.papertrader.* + +@Serializable +data class AssetSelectData( + val assets: List?, + val positions: List?, + val assetAction: AssetActions, + val challengeId: Long, + val onFinish: () -> Unit, +) + +abstract class PageViewModel(val server: Server, val messageHandler: MessageHandler) : ViewModel() { + + abstract fun reset(): Unit + + inline fun handleRequest( + method: HttpMethod, + endpoint: Endpoints, + isLoading: MutableState, + request: @Serializable Any? = null, + result: MutableState? = null, + resultBoolean: MutableState? = null, + successMessage: String? = null, + crossinline onSuccess: (ReturnType) -> Unit = {}, + crossinline onFailure: (Throwable) -> Unit = {} + ) { + if (method != HttpMethod.Get && method != HttpMethod.Post) { + messageHandler.triggerError("Internal Error in app!") + return + } + viewModelScope.launch { + isLoading.value = true + val response = when (method) { + HttpMethod.Get -> { + server.sendGet(endpoint) + } + + HttpMethod.Post -> { + server.sendPostSerializable(endpoint, request) + } + + else -> { + return@launch + } + } + response.onFailure { error -> + onFailure(error) + messageHandler.triggerError(error.message) + } + response.onSuccess { response -> + if (result != null) { + result.value = response + } + if (resultBoolean != null) { + resultBoolean.value = true + } + onSuccess(response) + messageHandler.triggerSuccess(successMessage) + } + isLoading.value = false + } + } +} diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/PrivacyPolicy.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/PrivacyPolicy.kt new file mode 100644 index 0000000..844fdb8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/PrivacyPolicy.kt @@ -0,0 +1,477 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.runtime.Composable +import com.mikepenz.markdown.m3.Markdown +import win.tap_tap.papertrader.Languages +import win.tap_tap.papertrader.LocalTexts +import win.tap_tap.papertrader.Texts +import win.tap_tap.papertrader.components.ButtonSpacer +import win.tap_tap.papertrader.components.LanguageSelect + +fun Texts.getPrivacyPolicy(): String { + return when (selectedLanguage()) { + Languages.GERMAN -> """ +Datenschutzerklärung +==================== + +Präambel +-------- + +Mit der folgenden Datenschutzerklärung möchten wir Sie darüber aufklären, welche Arten Ihrer personenbezogenen Daten (nachfolgend auch kurz als "Daten" bezeichnet) wir zu welchen Zwecken und in welchem Umfang im Rahmen der Bereitstellung unserer Applikation verarbeiten. + +Die verwendeten Begriffe sind nicht geschlechtsspezifisch. + +Stand: 11. Mai 2026 + +Inhaltsübersicht +---------------- + +* [Präambel](#m4158) +* [Verantwortlicher](#m3) +* [Übersicht der Verarbeitungen](#mOverview) +* [Maßgebliche Rechtsgrundlagen](#m2427) +* [Sicherheitsmaßnahmen](#m27) +* [Übermittlung von personenbezogenen Daten](#m25) +* [Internationale Datentransfers](#m24) +* [Allgemeine Informationen zur Datenspeicherung und Löschung](#m12) +* [Rechte der betroffenen Personen](#m10) +* [Bereitstellung des Onlineangebots und Webhosting](#m225) +* [Einsatz von Cookies](#m134) +* [Registrierung, Anmeldung und Nutzerkonto](#m367) +* [Änderung und Aktualisierung](#m15) +* [Begriffsdefinitionen](#m42) + +Verantwortlicher +---------------- + +Theo, Tappe +Stettinger Str. 41 +35410 Hungen Deutschland + +E-Mail-Adresse: [taptap.papertrader@gmail.com](mailto:taptap.papertrader@gmail.com) + +Telefon: +49 157 92341658 + +Übersicht der Verarbeitungen +---------------------------- + +Die nachfolgende Übersicht fasst die Arten der verarbeiteten Daten und die Zwecke ihrer Verarbeitung zusammen und verweist auf die betroffenen Personen. + +### Arten der verarbeiteten Daten + +* Bestandsdaten. +* Kontaktdaten. +* Inhaltsdaten. +* Nutzungsdaten. +* Meta-, Kommunikations- und Verfahrensdaten. +* Protokolldaten. + +### Kategorien betroffener Personen + +* Nutzer. + +### Zwecke der Verarbeitung + +* Erbringung vertraglicher Leistungen und Erfüllung vertraglicher Pflichten. +* Sicherheitsmaßnahmen. +* Organisations- und Verwaltungsverfahren. +* Bereitstellung unseres Onlineangebotes und Nutzerfreundlichkeit. +* Informationstechnische Infrastruktur. + +Maßgebliche Rechtsgrundlagen +---------------------------- + +**Maßgebliche Rechtsgrundlagen nach der DSGVO:** Im Folgenden erhalten Sie eine Übersicht der Rechtsgrundlagen der DSGVO, auf deren Basis wir personenbezogene Daten verarbeiten. Bitte nehmen Sie zur Kenntnis, dass neben den Regelungen der DSGVO nationale Datenschutzvorgaben in Ihrem bzw. unserem Wohn- oder Sitzland gelten können. Sollten ferner im Einzelfall speziellere Rechtsgrundlagen maßgeblich sein, teilen wir Ihnen diese in der Datenschutzerklärung mit. + +* **Einwilligung (Art. 6 Abs. 1 S. 1 lit. a) DSGVO)** - Die betroffene Person hat ihre Einwilligung in die Verarbeitung der sie betreffenden personenbezogenen Daten für einen spezifischen Zweck oder mehrere bestimmte Zwecke gegeben. +* **Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO)** - Die Verarbeitung ist für die Erfüllung eines Vertrags, dessen Vertragspartei die betroffene Person ist, oder zur Durchführung vorvertraglicher Maßnahmen erforderlich, die auf Anfrage der betroffenen Person erfolgen. +* **Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO)** - die Verarbeitung ist zur Wahrung der berechtigten Interessen des Verantwortlichen oder eines Dritten notwendig, vorausgesetzt, dass die Interessen, Grundrechte und Grundfreiheiten der betroffenen Person, die den Schutz personenbezogener Daten verlangen, nicht überwiegen. + +**Nationale Datenschutzregelungen in Deutschland:** Zusätzlich zu den Datenschutzregelungen der DSGVO gelten nationale Regelungen zum Datenschutz in Deutschland. Hierzu gehört insbesondere das Gesetz zum Schutz vor Missbrauch personenbezogener Daten bei der Datenverarbeitung (Bundesdatenschutzgesetz – BDSG). Das BDSG enthält insbesondere Spezialregelungen zum Recht auf Auskunft, zum Recht auf Löschung, zum Widerspruchsrecht, zur Verarbeitung besonderer Kategorien personenbezogener Daten, zur Verarbeitung für andere Zwecke und zur Übermittlung sowie automatisierten Entscheidungsfindung im Einzelfall einschließlich Profiling. Ferner können Landesdatenschutzgesetze der einzelnen Bundesländer zur Anwendung gelangen. + +Sicherheitsmaßnahmen +-------------------- + +Wir treffen nach Maßgabe der gesetzlichen Vorgaben unter Berücksichtigung des Stands der Technik, der Implementierungskosten und der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung sowie der unterschiedlichen Eintrittswahrscheinlichkeiten und des Ausmaßes der Bedrohung der Rechte und Freiheiten natürlicher Personen geeignete technische und organisatorische Maßnahmen, um ein dem Risiko angemessenes Schutzniveau zu gewährleisten. + +Zu den Maßnahmen gehören insbesondere die Sicherung der Vertraulichkeit, Integrität und Verfügbarkeit von Daten durch Kontrolle des physischen und elektronischen Zugangs zu den Daten als auch des sie betreffenden Zugriffs, der Eingabe, der Weitergabe, der Sicherung der Verfügbarkeit und ihrer Trennung. Des Weiteren haben wir Verfahren eingerichtet, die eine Wahrnehmung von Betroffenenrechten, die Löschung von Daten und Reaktionen auf die Gefährdung der Daten gewährleisten. Ferner berücksichtigen wir den Schutz personenbezogener Daten bereits bei der Entwicklung bzw. Auswahl von Hardware, Software sowie Verfahren entsprechend dem Prinzip des Datenschutzes, durch Technikgestaltung und durch datenschutzfreundliche Voreinstellungen. + +Sicherung von Online-Verbindungen durch TLS-/SSL-Verschlüsselungstechnologie (HTTPS): Um die Daten der Nutzer, die über unsere Online-Dienste übertragen werden, vor unerlaubten Zugriffen zu schützen, setzen wir auf die TLS-/SSL-Verschlüsselungstechnologie. Secure Sockets Layer (SSL) und Transport Layer Security (TLS) sind die Eckpfeiler der sicheren Datenübertragung im Internet. Diese Technologien verschlüsseln die Informationen, die zwischen der Website oder App und dem Browser des Nutzers (oder zwischen zwei Servern) übertragen werden, wodurch die Daten vor unbefugtem Zugriff geschützt sind. TLS, als die weiterentwickelte und sicherere Version von SSL, gewährleistet, dass alle Datenübertragungen den höchsten Sicherheitsstandards entsprechen. Wenn eine Website durch ein SSL-/TLS-Zertifikat gesichert ist, wird dies durch die Anzeige von HTTPS in der URL signalisiert. Dies dient als ein Indikator für die Nutzer, dass ihre Daten sicher und verschlüsselt übertragen werden. + +Übermittlung von personenbezogenen Daten +---------------------------------------- + +Im Rahmen unserer Verarbeitung von personenbezogenen Daten kommt es vor, dass diese an andere Stellen, Unternehmen, rechtlich selbstständige Organisationseinheiten oder Personen übermittelt beziehungsweise ihnen gegenüber offengelegt werden. Zu den Empfängern dieser Daten können z. B. mit IT-Aufgaben beauftragte Dienstleister gehören oder Anbieter von Diensten und Inhalten, die in eine Website eingebunden sind. In solchen Fällen beachten wir die gesetzlichen Vorgaben und schließen insbesondere entsprechende Verträge bzw. Vereinbarungen, die dem Schutz Ihrer Daten dienen, mit den Empfängern Ihrer Daten ab. + +Datenübermittlung innerhalb der Organisation: Wir können personenbezogene Daten an andere Abteilungen oder Einheiten innerhalb unserer Organisation übermitteln oder ihnen den Zugriff darauf gewähren. Sofern die Datenweitergabe zu administrativen Zwecken erfolgt, beruht sie auf unseren berechtigten unternehmerischen und betriebswirtschaftlichen Interessen oder erfolgt, sofern sie zur Erfüllung unserer vertragsbezogenen Verpflichtungen erforderlich ist beziehungsweise wenn eine Einwilligung der Betroffenen oder eine gesetzliche Erlaubnis vorliegt. + +Internationale Datentransfers +----------------------------- + +Datenverarbeitung in Drittländern: Sofern wir Daten in ein Drittland (d. h. außerhalb der Europäischen Union (EU) oder des Europäischen Wirtschaftsraums (EWR)) übermitteln oder dies im Rahmen der Nutzung von Diensten Dritter oder der Offenlegung bzw. Übermittlung von Daten an andere Personen, Stellen oder Unternehmen geschieht (was erkennbar wird anhand der Postadresse des jeweiligen Anbieters oder wenn in der Datenschutzerklärung ausdrücklich auf den Datentransfer in Drittländer hingewiesen wird), erfolgt dies stets im Einklang mit den gesetzlichen Vorgaben. + +Für Datenübermittlungen in die USA stützen wir uns vorrangig auf das Data Privacy Framework (DPF), welches durch einen Angemessenheitsbeschluss der EU-Kommission vom 10.07.2023 als sicherer Rechtsrahmen anerkannt wurde. Zusätzlich haben wir mit den jeweiligen Anbietern Standardvertragsklauseln abgeschlossen, die den Vorgaben der EU-Kommission entsprechen und vertragliche Verpflichtungen zum Schutz Ihrer Daten festlegen. + +Diese zweifache Absicherung gewährleistet einen umfassenden Schutz Ihrer Daten: Das DPF bildet die primäre Schutzebene, während die Standardvertragsklauseln als zusätzliche Sicherheit dienen. Sollten sich Änderungen im Rahmen des DPF ergeben, greifen die Standardvertragsklauseln als zuverlässige Rückfalloption ein. So stellen wir sicher, dass Ihre Daten auch bei etwaigen politischen oder rechtlichen Veränderungen stets angemessen geschützt bleiben. + +Bei den einzelnen Diensteanbietern informieren wir Sie darüber, ob sie nach dem DPF zertifiziert sind und ob Standardvertragsklauseln vorliegen. Weitere Informationen zum DPF und eine Liste der zertifizierten Unternehmen finden Sie auf der Website des US-Handelsministeriums unter [https://www.dataprivacyframework.gov/](https://www.dataprivacyframework.gov/) (in englischer Sprache). + +Für Datenübermittlungen in andere Drittländer gelten entsprechende Sicherheitsmaßnahmen, insbesondere Standardvertragsklauseln, ausdrückliche Einwilligungen oder gesetzlich erforderliche Übermittlungen. Informationen zu Drittlandtransfers und geltenden Angemessenheitsbeschlüssen können Sie dem Informationsangebot der EU-Kommission entnehmen: [https://commission.europa.eu/law/law-topic/data-protection/international-dimension-data-protection\_en?prefLang=de.](https://commission.europa.eu/law/law-topic/data-protection/international-dimension-data-protection_en?prefLang=de) + +Allgemeine Informationen zur Datenspeicherung und Löschung +---------------------------------------------------------- + +Wir löschen personenbezogene Daten, die wir verarbeiten, gemäß den gesetzlichen Bestimmungen, sobald die zugrundeliegenden Einwilligungen widerrufen werden oder keine weiteren rechtlichen Grundlagen für die Verarbeitung bestehen. Dies betrifft Fälle, in denen der ursprüngliche Verarbeitungszweck entfällt oder die Daten nicht mehr benötigt werden. Ausnahmen von dieser Regelung bestehen, wenn gesetzliche Pflichten oder besondere Interessen eine längere Aufbewahrung oder Archivierung der Daten erfordern. + +Insbesondere müssen Daten, die aus handels- oder steuerrechtlichen Gründen aufbewahrt werden müssen oder deren Speicherung notwendig ist zur Rechtsverfolgung oder zum Schutz der Rechte anderer natürlicher oder juristischer Personen, entsprechend archiviert werden. + +Unsere Datenschutzhinweise enthalten zusätzliche Informationen zur Aufbewahrung und Löschung von Daten, die speziell für bestimmte Verarbeitungsprozesse gelten. + +Bei mehreren Angaben zur Aufbewahrungsdauer oder Löschungsfristen eines Datums, ist stets die längste Frist maßgeblich. Daten, die nicht mehr für den ursprünglich vorgesehenen Zweck, sondern aufgrund gesetzlicher Vorgaben oder anderer Gründe aufbewahrt werden, verarbeiten wir ausschließlich zu den Gründen, die ihre Aufbewahrung rechtfertigen. + +Aufbewahrung und Löschung von Daten: Die folgenden allgemeinen Fristen gelten für die Aufbewahrung und Archivierung nach deutschem Recht: + +* 10 Jahre - Aufbewahrungsfrist für Bücher und Aufzeichnungen, Jahresabschlüsse, Inventare, Lageberichte, Eröffnungsbilanz sowie die zu ihrem Verständnis erforderlichen Arbeitsanweisungen und sonstigen Organisationsunterlagen (§ 147 Abs. 1 Nr. 1 i.V.m. Abs. 3 AO, § 14b Abs. 1 UStG, § 257 Abs. 1 Nr. 1 i.V.m. Abs. 4 HGB). +* 8 Jahre - Buchungsbelege, wie z. B. Rechnungen und Kostenbelege (§ 147 Abs. 1 Nr. 4 und 4a i.V.m. Abs. 3 Satz 1 AO sowie § 257 Abs. 1 Nr. 4 i.V.m. Abs. 4 HGB). +* 6 Jahre - Übrige Geschäftsunterlagen: empfangene Handels- oder Geschäftsbriefe, Wiedergaben der abgesandten Handels- oder Geschäftsbriefe, sonstige Unterlagen, soweit sie für die Besteuerung von Bedeutung sind, z. B. Stundenlohnzettel, Betriebsabrechnungsbögen, Kalkulationsunterlagen, Preisauszeichnungen, aber auch Lohnabrechnungsunterlagen, soweit sie nicht bereits Buchungsbelege sind und Kassenstreifen (§ 147 Abs. 1 Nr. 2, 3, 5 i.V.m. Abs. 3 AO, § 257 Abs. 1 Nr. 2 u. 3 i.V.m. Abs. 4 HGB). +* 3 Jahre - Daten, die erforderlich sind, um potenzielle Gewährleistungs- und Schadensersatzansprüche oder ähnliche vertragliche Ansprüche und Rechte zu berücksichtigen sowie damit verbundene Anfragen zu bearbeiten, basierend auf früheren Geschäftserfahrungen und üblichen Branchenpraktiken, werden für die Dauer der regulären gesetzlichen Verjährungsfrist von drei Jahren gespeichert (§§ 195, 199 BGB). + +Fristbeginn mit Ablauf des Jahres: Beginnt eine Frist nicht ausdrücklich zu einem bestimmten Datum und beträgt sie mindestens ein Jahr, so startet sie automatisch am Ende des Kalenderjahres, in dem das fristauslösende Ereignis eingetreten ist. Im Fall laufender Vertragsverhältnisse, in deren Rahmen Daten gespeichert werden, ist das fristauslösende Ereignis der Zeitpunkt des Wirksamwerdens der Kündigung oder sonstige Beendigung des Rechtsverhältnisses. + +Rechte der betroffenen Personen +------------------------------- + +Rechte der betroffenen Personen aus der DSGVO: Ihnen stehen als Betroffene nach der DSGVO verschiedene Rechte zu, die sich insbesondere aus Art. 15 bis 21 DSGVO ergeben: + +* **Widerspruchsrecht: Sie haben das Recht, aus Gründen, die sich aus Ihrer besonderen Situation ergeben, jederzeit gegen die Verarbeitung der Sie betreffenden personenbezogenen Daten, die aufgrund von Art. 6 Abs. 1 lit. e oder f DSGVO erfolgt, Widerspruch einzulegen; dies gilt auch für ein auf diese Bestimmungen gestütztes Profiling. Werden die Sie betreffenden personenbezogenen Daten verarbeitet, um Direktwerbung zu betreiben, haben Sie das Recht, jederzeit Widerspruch gegen die Verarbeitung der Sie betreffenden personenbezogenen Daten zum Zwecke derartiger Werbung einzulegen; dies gilt auch für das Profiling, soweit es mit solcher Direktwerbung in Verbindung steht.** +* **Widerrufsrecht bei Einwilligungen:** Sie haben das Recht, erteilte Einwilligungen jederzeit zu widerrufen. +* **Auskunftsrecht:** Sie haben das Recht, eine Bestätigung darüber zu verlangen, ob betreffende Daten verarbeitet werden und auf Auskunft über diese Daten sowie auf weitere Informationen und Kopie der Daten entsprechend den gesetzlichen Vorgaben. +* **Recht auf Berichtigung:** Sie haben entsprechend den gesetzlichen Vorgaben das Recht, die Vervollständigung der Sie betreffenden Daten oder die Berichtigung der Sie betreffenden unrichtigen Daten zu verlangen. +* **Recht auf Löschung und Einschränkung der Verarbeitung:** Sie haben nach Maßgabe der gesetzlichen Vorgaben das Recht, zu verlangen, dass Sie betreffende Daten unverzüglich gelöscht werden, bzw. alternativ nach Maßgabe der gesetzlichen Vorgaben eine Einschränkung der Verarbeitung der Daten zu verlangen. +* **Recht auf Datenübertragbarkeit:** Sie haben das Recht, Sie betreffende Daten, die Sie uns bereitgestellt haben, nach Maßgabe der gesetzlichen Vorgaben in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten oder deren Übermittlung an einen anderen Verantwortlichen zu fordern. +* **Beschwerde bei Aufsichtsbehörde:** Sie haben unbeschadet eines anderweitigen verwaltungsrechtlichen oder gerichtlichen Rechtsbehelfs das Recht auf Beschwerde bei einer Aufsichtsbehörde, insbesondere in dem Mitgliedstaat ihres gewöhnlichen Aufenthaltsorts, ihres Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes, wenn Sie der Ansicht sind, dass die Verarbeitung der Sie betreffenden personenbezogenen Daten gegen die Vorgaben der DSGVO verstößt. + +Bereitstellung des Onlineangebots und Webhosting +------------------------------------------------ + +Wir verarbeiten die Daten der Nutzer, um ihnen unsere Online-Dienste zur Verfügung stellen zu können. Zu diesem Zweck verarbeiten wir die IP-Adresse des Nutzers, die notwendig ist, um die Inhalte und Funktionen unserer Online-Dienste an den Browser oder das Endgerät der Nutzer zu übermitteln. + +* **Verarbeitete Datenarten:** Nutzungsdaten (z. B. Seitenaufrufe und Verweildauer, Klickpfade, Nutzungsintensität und -frequenz, verwendete Gerätetypen und Betriebssysteme, Interaktionen mit Inhalten und Funktionen); Meta-, Kommunikations- und Verfahrensdaten (z. B. IP-Adressen, Zeitangaben, Identifikationsnummern, beteiligte Personen); Protokolldaten (z. B. Logfiles betreffend Logins oder den Abruf von Daten oder Zugriffszeiten.). Inhaltsdaten (z. B. textliche oder bildliche Nachrichten und Beiträge sowie die sie betreffenden Informationen, wie z. B. Angaben zur Autorenschaft oder Zeitpunkt der Erstellung). +* **Betroffene Personen:** Nutzer (z. B. Webseitenbesucher, Nutzer von Onlinediensten). +* **Zwecke der Verarbeitung und berechtigte Interessen:** Bereitstellung unseres Onlineangebotes und Nutzerfreundlichkeit; Informationstechnische Infrastruktur (Betrieb und Bereitstellung von Informationssystemen und technischen Geräten (Computer, Server etc.)). Sicherheitsmaßnahmen. +* **Aufbewahrung und Löschung:** Löschung entsprechend Angaben im Abschnitt "Allgemeine Informationen zur Datenspeicherung und Löschung". +* **Rechtsgrundlagen:** Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO). + +**Weitere Hinweise zu Verarbeitungsprozessen, Verfahren und Diensten:** + +* **Bereitstellung Onlineangebot auf eigener/ dedizierter Serverhardware:** Für die Bereitstellung unseres Onlineangebotes nutzen wir von uns betriebene Serverhardware sowie den damit verbundenen Speicherplatz, die Rechenkapazität und die Software; **Rechtsgrundlagen:** Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO). +* **Erhebung von Zugriffsdaten und Logfiles:** Der Zugriff auf unser Onlineangebot wird in Form von sogenannten "Server-Logfiles" protokolliert. Zu den Serverlogfiles können die Adresse und der Name der abgerufenen Webseiten und Dateien, Datum und Uhrzeit des Abrufs, übertragene Datenmengen, Meldung über erfolgreichen Abruf, Browsertyp nebst Version, das Betriebssystem des Nutzers, Referrer URL (die zuvor besuchte Seite) und im Regelfall IP-Adressen und der anfragende Provider gehören. Die Serverlogfiles können zum einen zu Sicherheitszwecken eingesetzt werden, z. B. um eine Überlastung der Server zu vermeiden (insbesondere im Fall von missbräuchlichen Angriffen, sogenannten DDoS-Attacken), und zum anderen, um die Auslastung der Server und ihre Stabilität sicherzustellen; **Rechtsgrundlagen:** Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO). **Löschung von Daten:** Logfile-Informationen werden für die Dauer von maximal 30 Tagen gespeichert und danach gelöscht oder anonymisiert. Daten, deren weitere Aufbewahrung zu Beweiszwecken erforderlich ist, sind bis zur endgültigen Klärung des jeweiligen Vorfalls von der Löschung ausgenommen. +* **Content-Delivery-Network:** Wir setzen ein "Content-Delivery-Network" (CDN) ein. Ein CDN ist ein Dienst, mit dessen Hilfe Inhalte eines Onlineangebotes, insbesondere große Mediendateien, wie Grafiken oder Programm-Skripte, mit Hilfe regional verteilter und über das Internet verbundener Server schneller und sicherer ausgeliefert werden können; **Rechtsgrundlagen:** Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO). +* **Instart:** Content-Delivery-Network (CDN) - Dienst, mit dessen Hilfe Inhalte eines Onlineangebotes, insbesondere große Mediendateien, wie Grafiken oder Programm-Skripte mit Hilfe regional verteilter und über das Internet verbundener Server, schneller und sicherer ausgeliefert werden können; **Dienstanbieter:** Instart Logic, Inc., 450 Lambert Avenue, Palo Alto, CA 94306, USA; **Rechtsgrundlagen:** Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO); **Website:** [https://www.instart.com](https://www.instart.com). **Datenschutzerklärung:** [https://www.instart.com/company/legal/privacy-policy](https://www.instart.com/company/legal/privacy-policy). +* **Stackpath:** Content-Delivery-Network (CDN) - Dienst, mit dessen Hilfe Inhalte eines Onlineangebotes, insbesondere große Mediendateien, wie Grafiken oder Programm-Skripte mit Hilfe regional verteilter und über das Internet verbundener Server, schneller und sicherer ausgeliefert werden können; **Dienstanbieter:** StackPath, LLC, 2021 McKinney Avenue, Suite 1100, Dallas, Texas 75201, USA; **Rechtsgrundlagen:** Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO); **Website:** [https://www.stackpath.com](https://www.stackpath.com); **Datenschutzerklärung:** [https://www.stackpath.com/legal/privacy-statement/](https://www.stackpath.com/legal/privacy-statement/); **Auftragsverarbeitungsvertrag:** [https://www.stackpath.com/legal/](https://www.stackpath.com/legal/). **Grundlage Drittlandtransfers:** Data Privacy Framework (DPF). + +Einsatz von Cookies +------------------- + +Unter dem Begriff „Cookies" werden Funktionen, die Informationen auf Endgeräten der Nutzer speichern und aus ihnen auslesen, verstanden. Cookies können ferner in Bezug auf unterschiedliche Anliegen Einsatz finden, etwa zu Zwecken der Funktionsfähigkeit, der Sicherheit und des Komforts von Onlineangeboten sowie der Erstellung von Analysen der Besucherströme. Wir verwenden Cookies gemäß den gesetzlichen Vorschriften. Dazu holen wir, wenn erforderlich, vorab die Zustimmung der Nutzer ein. Ist eine Zustimmung nicht notwendig, setzen wir auf unsere berechtigten Interessen. Dies gilt, wenn das Speichern und Auslesen von Informationen unerlässlich ist, um ausdrücklich angeforderte Inhalte und Funktionen bereitstellen zu können. Dazu zählen etwa die Speicherung von Einstellungen sowie die Sicherstellung der Funktionalität und Sicherheit unseres Onlineangebots. Die Einwilligung kann jederzeit widerrufen werden. Wir informieren klar über deren Umfang und welche Cookies genutzt werden. + +**Hinweise zu datenschutzrechtlichen Rechtsgrundlagen:** Ob wir personenbezogene Daten mithilfe von Cookies verarbeiten, hängt von einer Einwilligung ab. Liegt eine Einwilligung vor, dient sie als Rechtsgrundlage. Ohne Einwilligung stützen wir uns auf unsere berechtigten Interessen, die vorstehend in diesem Abschnitt und im Kontext der jeweiligen Dienste und Verfahren erläutert sind. + +**Speicherdauer:** Im Hinblick auf die Speicherdauer werden die folgenden Arten von Cookies unterschieden: + +* **Temporäre Cookies (auch: Session- oder Sitzungscookies):** Temporäre Cookies werden spätestens gelöscht, nachdem ein Nutzer ein Onlineangebot verlassen und sein Endgerät (z. B. Browser oder mobile Applikation) geschlossen hat. +* **Permanente Cookies:** Permanente Cookies bleiben auch nach dem Schließen des Endgeräts gespeichert. So können beispielsweise der Log-in-Status gespeichert und bevorzugte Inhalte direkt angezeigt werden, wenn der Nutzer eine Website erneut besucht. Ebenso können die mithilfe von Cookies erhobenen Nutzerdaten zur Reichweitenmessung Verwendung finden. Sofern wir Nutzern keine expliziten Angaben zur Art und Speicherdauer von Cookies mitteilen (z. B. im Rahmen der Einholung der Einwilligung), sollten sie davon ausgehen, dass diese permanent sind und die Speicherdauer bis zu zwei Jahre betragen kann. + +**Allgemeine Hinweise zum Widerruf und Widerspruch (Opt-out):** Nutzer können die von ihnen abgegebenen Einwilligungen jederzeit widerrufen und zudem einen Widerspruch gegen die Verarbeitung entsprechend den gesetzlichen Vorgaben, auch mittels der Privatsphäre-Einstellungen ihres Browsers, erklären. + +* **Verarbeitete Datenarten:** Meta-, Kommunikations- und Verfahrensdaten (z. B. IP-Adressen, Zeitangaben, Identifikationsnummern, beteiligte Personen). +* **Betroffene Personen:** Nutzer (z. B. Webseitenbesucher, Nutzer von Onlinediensten). +* **Rechtsgrundlagen:** Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO). Einwilligung (Art. 6 Abs. 1 S. 1 lit. a) DSGVO). + +**Weitere Hinweise zu Verarbeitungsprozessen, Verfahren und Diensten:** + +* **Verarbeitung von Cookie-Daten auf Grundlage einer Einwilligung:** Wir setzen eine Einwilligungs-Management-Lösung ein, bei der die Einwilligung der Nutzer zur Verwendung von Cookies oder zu den im Rahmen der Einwilligungs-Management-Lösung genannten Verfahren und Anbietern eingeholt wird. Dieses Verfahren dient der Einholung, Protokollierung, Verwaltung und dem Widerruf von Einwilligungen, insbesondere bezogen auf den Einsatz von Cookies und vergleichbaren Technologien, die zur Speicherung, zum Auslesen und zur Verarbeitung von Informationen auf den Endgeräten der Nutzer eingesetzt werden. Im Rahmen dieses Verfahrens werden die Einwilligungen der Nutzer für die Nutzung von Cookies und die damit verbundenen Verarbeitungen von Informationen, einschließlich der im Einwilligungs-Management-Verfahren genannten spezifischen Verarbeitungen und Anbieter, eingeholt. Die Nutzer haben zudem die Möglichkeit, ihre Einwilligungen zu verwalten und zu widerrufen. Die Einwilligungserklärungen werden gespeichert, um eine erneute Abfrage zu vermeiden und den Nachweis der Einwilligung gemäß der gesetzlichen Anforderungen führen zu können. Die Speicherung erfolgt serverseitig und/oder in einem Cookie (sogenanntes Opt-In-Cookie) oder mittels vergleichbarer Technologien, um die Einwilligung einem spezifischen Nutzer oder dessen Gerät zuordnen zu können. Sofern keine spezifischen Angaben zu den Anbietern von Einwilligungs-Management-Diensten vorliegen, gelten folgende allgemeine Hinweise: Die Dauer der Speicherung der Einwilligung beträgt bis zu zwei Jahre. Dabei wird ein pseudonymer Nutzer-Identifikator erstellt, der zusammen mit dem Zeitpunkt der Einwilligung, den Angaben zum Umfang der Einwilligung (z. B. betreffende Kategorien von Cookies und/oder Diensteanbieter) sowie Informationen über den Browser, das System und das verwendete Endgerät gespeichert wird; **Rechtsgrundlagen:** Einwilligung (Art. 6 Abs. 1 S. 1 lit. a) DSGVO). + +Registrierung, Anmeldung und Nutzerkonto +---------------------------------------- + +Nutzer können ein Nutzerkonto anlegen. Im Rahmen der Registrierung werden den Nutzern die erforderlichen Pflichtangaben mitgeteilt und zu Zwecken der Bereitstellung des Nutzerkontos auf Grundlage vertraglicher Pflichterfüllung verarbeitet. Zu den verarbeiteten Daten gehören insbesondere die Login-Informationen (Nutzername, Passwort sowie eine E-Mail-Adresse). + +Im Rahmen der Inanspruchnahme unserer Registrierungs- und Anmeldefunktionen sowie der Nutzung des Nutzerkontos speichern wir die IP-Adresse und den Zeitpunkt der jeweiligen Nutzerhandlung. Die Speicherung erfolgt auf Grundlage unserer berechtigten Interessen als auch jener der Nutzer an einem Schutz vor Missbrauch und sonstiger unbefugter Nutzung. Eine Weitergabe dieser Daten an Dritte erfolgt grundsätzlich nicht, es sei denn, sie ist zur Verfolgung unserer Ansprüche erforderlich oder es besteht eine gesetzliche Verpflichtung hierzu. + +Die Nutzer können über Vorgänge, die für deren Nutzerkonto relevant sind, wie z. B. technische Änderungen, per E-Mail informiert werden. + +* **Verarbeitete Datenarten:** Bestandsdaten (z. B. der vollständige Name, Wohnadresse, Kontaktinformationen, Kundennummer, etc.); Kontaktdaten (z. B. Post- und E-Mail-Adressen oder Telefonnummern); Inhaltsdaten (z. B. textliche oder bildliche Nachrichten und Beiträge sowie die sie betreffenden Informationen, wie z. B. Angaben zur Autorenschaft oder Zeitpunkt der Erstellung); Nutzungsdaten (z. B. Seitenaufrufe und Verweildauer, Klickpfade, Nutzungsintensität und -frequenz, verwendete Gerätetypen und Betriebssysteme, Interaktionen mit Inhalten und Funktionen). Protokolldaten (z. B. Logfiles betreffend Logins oder den Abruf von Daten oder Zugriffszeiten.). +* **Betroffene Personen:** Nutzer (z. B. Webseitenbesucher, Nutzer von Onlinediensten). +* **Zwecke der Verarbeitung und berechtigte Interessen:** Erbringung vertraglicher Leistungen und Erfüllung vertraglicher Pflichten; Sicherheitsmaßnahmen; Organisations- und Verwaltungsverfahren. Bereitstellung unseres Onlineangebotes und Nutzerfreundlichkeit. +* **Aufbewahrung und Löschung:** Löschung entsprechend Angaben im Abschnitt "Allgemeine Informationen zur Datenspeicherung und Löschung". Löschung nach Kündigung. +* **Rechtsgrundlagen:** Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO). Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO). + +**Weitere Hinweise zu Verarbeitungsprozessen, Verfahren und Diensten:** + +* **Registrierung mit Pseudonymen:** Nutzer dürfen statt Klarnamen Pseudonyme als Nutzernamen verwenden; **Rechtsgrundlagen:** Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO). +* **Profile der Nutzer sind öffentlich:** Die Profile der Nutzer sind öffentlich sichtbar und zugänglich. +* **Keine Aufbewahrungspflicht für Daten:** Es obliegt den Nutzern, ihre Daten bei erfolgter Kündigung vor dem Vertragsende zu sichern. Wir sind berechtigt, sämtliche während der Vertragsdauer gespeicherte Daten des Nutzers unwiederbringlich zu löschen; **Rechtsgrundlagen:** Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO). + +Änderung und Aktualisierung +--------------------------- + +Wir bitten Sie, sich regelmäßig über den Inhalt unserer Datenschutzerklärung zu informieren. Wir passen die Datenschutzerklärung an, sobald die Änderungen der von uns durchgeführten Datenverarbeitungen dies erforderlich machen. Wir informieren Sie, sobald durch die Änderungen eine Mitwirkungshandlung Ihrerseits (z. B. Einwilligung) oder eine sonstige individuelle Benachrichtigung erforderlich wird. + +Sofern wir in dieser Datenschutzerklärung Adressen und Kontaktinformationen von Unternehmen und Organisationen angeben, bitten wir zu beachten, dass die Adressen sich über die Zeit ändern können und bitten die Angaben vor Kontaktaufnahme zu prüfen. + +Begriffsdefinitionen +-------------------- + +In diesem Abschnitt erhalten Sie eine Übersicht über die in dieser Datenschutzerklärung verwendeten Begrifflichkeiten. Soweit die Begrifflichkeiten gesetzlich definiert sind, gelten deren gesetzliche Definitionen. Die nachfolgenden Erläuterungen sollen dagegen vor allem dem Verständnis dienen. + +* **Bestandsdaten:** Bestandsdaten umfassen wesentliche Informationen, die für die Identifikation und Verwaltung von Vertragspartnern, Benutzerkonten, Profilen und ähnlichen Zuordnungen notwendig sind. Diese Daten können u.a. persönliche und demografische Angaben wie Namen, Kontaktinformationen (Adressen, Telefonnummern, E-Mail-Adressen), Geburtsdaten und spezifische Identifikatoren (Benutzer-IDs) beinhalten. Bestandsdaten bilden die Grundlage für jegliche formelle Interaktion zwischen Personen und Diensten, Einrichtungen oder Systemen, indem sie eine eindeutige Zuordnung und Kommunikation ermöglichen. +* **Inhaltsdaten:** Inhaltsdaten umfassen Informationen, die im Zuge der Erstellung, Bearbeitung und Veröffentlichung von Inhalten aller Art generiert werden. Diese Kategorie von Daten kann Texte, Bilder, Videos, Audiodateien und andere multimediale Inhalte einschließen, die auf verschiedenen Plattformen und Medien veröffentlicht werden. Inhaltsdaten sind nicht nur auf den eigentlichen Inhalt beschränkt, sondern beinhalten auch Metadaten, die Informationen über den Inhalt selbst liefern, wie Tags, Beschreibungen, Autoreninformationen und Veröffentlichungsdaten +* **Kontaktdaten:** Kontaktdaten sind essentielle Informationen, die die Kommunikation mit Personen oder Organisationen ermöglichen. Sie umfassen u.a. Telefonnummern, postalische Adressen und E-Mail-Adressen, sowie Kommunikationsmittel wie soziale Medien-Handles und Instant-Messaging-Identifikatoren. +* **Meta-, Kommunikations- und Verfahrensdaten:** Meta-, Kommunikations- und Verfahrensdaten sind Kategorien, die Informationen über die Art und Weise enthalten, wie Daten verarbeitet, übermittelt und verwaltet werden. Meta-Daten, auch bekannt als Daten über Daten, umfassen Informationen, die den Kontext, die Herkunft und die Struktur anderer Daten beschreiben. Sie können Angaben zur Dateigröße, dem Erstellungsdatum, dem Autor eines Dokuments und den Änderungshistorien beinhalten. Kommunikationsdaten erfassen den Austausch von Informationen zwischen Nutzern über verschiedene Kanäle, wie E-Mail-Verkehr, Anrufprotokolle, Nachrichten in sozialen Netzwerken und Chat-Verläufe, inklusive der beteiligten Personen, Zeitstempel und Übertragungswege. Verfahrensdaten beschreiben die Prozesse und Abläufe innerhalb von Systemen oder Organisationen, einschließlich Workflow-Dokumentationen, Protokolle von Transaktionen und Aktivitäten, sowie Audit-Logs, die zur Nachverfolgung und Überprüfung von Vorgängen verwendet werden. +* **Nutzungsdaten:** Nutzungsdaten beziehen sich auf Informationen, die erfassen, wie Nutzer mit digitalen Produkten, Dienstleistungen oder Plattformen interagieren. Diese Daten umfassen eine breite Palette von Informationen, die aufzeigen, wie Nutzer Anwendungen nutzen, welche Funktionen sie bevorzugen, wie lange sie auf bestimmten Seiten verweilen und über welche Pfade sie durch eine Anwendung navigieren. Nutzungsdaten können auch die Häufigkeit der Nutzung, Zeitstempel von Aktivitäten, IP-Adressen, Geräteinformationen und Standortdaten einschließen. Sie sind besonders wertvoll für die Analyse des Nutzerverhaltens, die Optimierung von Benutzererfahrungen, das Personalisieren von Inhalten und das Verbessern von Produkten oder Dienstleistungen. Darüber hinaus spielen Nutzungsdaten eine entscheidende Rolle beim Erkennen von Trends, Vorlieben und möglichen Problembereichen innerhalb digitaler Angebote +* **Personenbezogene Daten:** "Personenbezogene Daten" sind alle Informationen, die sich auf eine identifizierte oder identifizierbare natürliche Person (im Folgenden "betroffene Person") beziehen; als identifizierbar wird eine natürliche Person angesehen, die direkt oder indirekt, insbesondere mittels Zuordnung zu einer Kennung wie einem Namen, zu einer Kennnummer, zu Standortdaten, zu einer Online-Kennung (z. B. Cookie) oder zu einem oder mehreren besonderen Merkmalen identifiziert werden kann, die Ausdruck der physischen, physiologischen, genetischen, psychischen, wirtschaftlichen, kulturellen oder sozialen Identität dieser natürlichen Person sind. +* **Protokolldaten:** Protokolldaten sind Informationen über Ereignisse oder Aktivitäten, die in einem System oder Netzwerk protokolliert wurden. Diese Daten enthalten typischerweise Informationen wie Zeitstempel, IP-Adressen, Benutzeraktionen, Fehlermeldungen und andere Details über die Nutzung oder den Betrieb eines Systems. Protokolldaten werden oft zur Analyse von Systemproblemen, zur Sicherheitsüberwachung oder zur Erstellung von Leistungsberichten verwendet. +* **Verantwortlicher:** Als "Verantwortlicher" wird die natürliche oder juristische Person, Behörde, Einrichtung oder andere Stelle, die allein oder gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von personenbezogenen Daten entscheidet, bezeichnet. +* **Verarbeitung:** "Verarbeitung" ist jeder mit oder ohne Hilfe automatisierter Verfahren ausgeführte Vorgang oder jede solche Vorgangsreihe im Zusammenhang mit personenbezogenen Daten. Der Begriff reicht weit und umfasst praktisch jeden Umgang mit Daten, sei es das Erheben, das Auswerten, das Speichern, das Übermitteln oder das Löschen. + +[Erstellt mit kostenlosem Datenschutz-Generator.de von Dr. Thomas Schwenke](https://datenschutz-generator.de/ "Rechtstext von Dr. Schwenke - für weitere Informationen bitte anklicken.") + """ + + Languages.ENGLISH -> """ +Privacy Policy +============== + +Preamble +-------- + +With the following privacy policy, we would like to inform you about the types of your personal data (hereinafter also simply referred to as "data") that we process, for what purposes, and to what extent within the framework of providing our application. + +The terms used are not gender-specific. + +Last updated: May 11, 2026 + +Table of Contents +----------------- + +* [Preamble](#m4158) +* [Controller](#m3) +* [Overview of Processing Operations](#mOverview) +* [Relevant Legal Bases](#m2427) +* [Security Measures](#m27) +* [Transmission of Personal Data](#m25) +* [International Data Transfers](#m24) +* [General Information on Data Storage and Deletion](#m12) +* [Rights of Data Subjects](#m10) +* [Provision of the Online Offer and Web Hosting](#m225) +* [Use of Cookies](#m134) +* [Registration, Login, and User Account](#m367) +* [Changes and Updates](#m15) +* [Definitions](#m42) + +Controller +---------- + +Theo Tappe +Stettinger Str. 41 +35410 Hungen, Germany + +Email address: [taptap.papertrader@gmail.com](mailto:taptap.papertrader@gmail.com) + +Phone: +49 157 92341658 + +Overview of Processing Operations +--------------------------------- + +The following overview summarizes the types of data processed, the purposes of their processing, and refers to the data subjects. + +### Types of Processed Data + +* Inventory data. +* Contact data. +* Content data. +* Usage data. +* Meta, communication, and procedural data. +* Log data. + +### Categories of Data Subjects + +* Users. + +### Purposes of Processing + +* Provision of contractual services and fulfillment of contractual obligations. +* Security measures. +* Organizational and administrative procedures. +* Provision of our online offer and user-friendliness. +* Information technology infrastructure. + +Relevant Legal Bases +-------------------- + +**Relevant legal bases under the GDPR:** Below is an overview of the legal bases under the GDPR (General Data Protection Regulation) upon which we process personal data. Please note that, in addition to the regulations of the GDPR, national data protection provisions may apply in your or our country of residence or domicile. Furthermore, should more specific legal bases be relevant in individual cases, we will inform you about them in this privacy policy. + +* **Consent (Art. 6 Para. 1 Sentence 1 lit. a GDPR)** - The data subject has given consent to the processing of his or her personal data for one or more specific purposes. +* **Performance of a contract and pre-contractual requests (Art. 6 Para. 1 Sentence 1 lit. b GDPR)** - Processing is necessary for the performance of a contract to which the data subject is party or in order to take steps at the request of the data subject prior to entering into a contract. +* **Legitimate interests (Art. 6 Para. 1 Sentence 1 lit. f GDPR)** - Processing is necessary for the purposes of the legitimate interests pursued by the controller or by a third party, except where such interests are overridden by the interests or fundamental rights and freedoms of the data subject which require protection of personal data. + +**National Data Protection Regulations in Germany:** In addition to the data protection rules of the GDPR, national data protection laws apply in Germany. This includes, in particular, the Act on Protection against Misuse of Personal Data in Data Processing (Federal Data Protection Act – BDSG). The BDSG contains specific provisions on the right of access, the right to erasure, the right to object, the processing of special categories of personal data, processing for other purposes, and transmission as well as automated decision-making in individual cases including profiling. Furthermore, state data protection laws of the individual federal states may apply. + +Security Measures +----------------- + +We take appropriate technical and organizational measures in accordance with legal requirements, taking into account the state of the art, implementation costs, and the nature, scope, context, and purposes of processing as well as the varying likelihood and severity of the risk to the rights and freedoms of natural persons, to ensure a level of security appropriate to the risk. + +The measures include, in particular, safeguarding the confidentiality, integrity, and availability of data by controlling physical and electronic access to the data, as well as access, entry, transfer, securing availability, and segregation of data. Furthermore, we have established procedures ensuring the exercise of data subject rights, the deletion of data, and responses to data threats. We also consider the protection of personal data from the very beginning during the development or selection of hardware, software, and procedures, in accordance with the principle of data protection by design and by default. + +Securing online connections using TLS/SSL encryption technology (HTTPS): To protect users' data transmitted via our online services from unauthorized access, we rely on TLS/SSL encryption technology. Secure Sockets Layer (SSL) and Transport Layer Security (TLS) are the cornerstones of secure data transmission over the internet. These technologies encrypt the information transmitted between the website or app and the user's browser (or between two servers), thus protecting the data from unauthorized access. TLS, as the more advanced and secure version of SSL, ensures that all data transfers meet the highest security standards. If a website is secured by an SSL/TLS certificate, this is indicated by HTTPS in the URL. This serves as an indicator to users that their data is transmitted securely and encrypted. + +Transmission of Personal Data +----------------------------- + +In the course of our processing of personal data, it may happen that data is transmitted to or disclosed to other entities, companies, legally independent organizational units, or persons. Recipients of this data may include, for example, service providers tasked with IT functions or providers of services and content that are integrated into a website. In such cases, we comply with legal requirements and, in particular, enter into appropriate contracts or agreements that serve to protect your data with the recipients. + +Data transmission within the organization: We may transmit personal data to other departments or units within our organization or grant them access to it. If the data is shared for administrative purposes, it is based on our legitimate business and commercial interests, or takes place if it is necessary for the fulfillment of our contract-related obligations, or when there is consent from the data subjects or legal permission. + +International Data Transfers +---------------------------- + +Data processing in third countries: If we process data in a third country (i.e., outside the European Union (EU) or the European Economic Area (EEA)) or if this takes place in the context of using third-party services or the disclosure or transmission of data to other persons, entities, or companies (which becomes apparent from the provider's postal address or if international data transfers are explicitly mentioned in the privacy policy), this will only occur in compliance with legal requirements. + +For data transfers to the USA, we rely primarily on the Data Privacy Framework (DPF), which was recognized as a secure legal framework by an adequacy decision of the EU Commission on July 10, 2023. Additionally, we have concluded Standard Contractual Clauses (SCCs) with the respective providers that comply with the EU Commission's specifications and establish contractual obligations to protect your data. + +This dual safeguard ensures comprehensive protection of your data: The DPF forms the primary layer of protection, while the Standard Contractual Clauses serve as an additional safeguard. Should there be changes regarding the DPF, the Standard Contractual Clauses step in as a reliable fallback option. This ensures that your data remains adequately protected even in the event of political or legal changes. + +For individual service providers, we will inform you whether they are certified under the DPF and whether Standard Contractual Clauses are in place. Further information on the DPF and a list of certified companies can be found on the US Department of Commerce website at [https://www.dataprivacyframework.gov/](https://www.dataprivacyframework.gov/). + +For data transfers to other third countries, appropriate security measures apply, especially Standard Contractual Clauses, explicit consent, or legally required transfers. Information on third-country transfers and applicable adequacy decisions can be found on the European Commission's website: [https://commission.europa.eu/law/law-topic/data-protection/international-dimension-data-protection_en](https://commission.europa.eu/law/law-topic/data-protection/international-dimension-data-protection_en). + +General Information on Data Storage and Deletion +------------------------------------------------ + +We delete personal data that we process in accordance with statutory provisions as soon as the underlying consents are revoked or there are no longer any legal grounds for processing. This applies to cases where the original purpose of processing no longer exists or the data is no longer needed. Exceptions to this rule apply if legal obligations or special interests require a longer retention or archiving of the data. + +In particular, data that must be retained for commercial or tax reasons, or whose storage is necessary for legal prosecution or the protection of the rights of other natural or legal persons, must be archived accordingly. + +Our privacy notices contain additional information on the retention and deletion of data specific to certain processing operations. + +If there are multiple specifications for the retention period or deletion deadlines of data, the longest period always prevails. Data that is no longer retained for the originally intended purpose but due to legal requirements or other reasons is processed exclusively for the reasons justifying its retention. + +Retention and deletion of data: The following general retention periods apply under German law: + +* 10 years - Retention period for books and records, annual financial statements, inventories, management reports, opening balance sheets as well as the work instructions and other organizational documents required to understand them (§ 147 Para. 1 No. 1 in conjunction with Para. 3 AO, § 14b Para. 1 UStG, § 257 Para. 1 No. 1 in conjunction with Para. 4 HGB). +* 8 years - Accounting vouchers, such as invoices and receipts (§ 147 Para. 1 No. 4 and 4a in conjunction with Para. 3 Sentence 1 AO as well as § 257 Para. 1 No. 4 in conjunction with Para. 4 HGB). +* 6 years - Other business documents: received commercial or business letters, copies of sent commercial or business letters, other documents as far as they are relevant for taxation, e.g., hourly wage records, operating accounting sheets, calculation documents, price tags, but also payroll records, unless they are already accounting vouchers, and cash register tapes (§ 147 Para. 1 No. 2, 3, 5 in conjunction with Para. 3 AO, § 257 Para. 1 No. 2 and 3 in conjunction with Para. 4 HGB). +* 3 years - Data required to consider potential warranty and damage claims or similar contractual claims and rights, and to handle related inquiries, based on previous business experience and customary industry practices, are stored for the duration of the regular statutory limitation period of three years (§§ 195, 199 BGB). + +Start of the period at the end of the year: If a period does not explicitly begin on a specific date and is at least one year long, it starts automatically at the end of the calendar year in which the event triggering the period occurred. In the case of ongoing contractual relationships where data is stored, the triggering event is the time the termination becomes effective or any other end of the legal relationship. + +Rights of Data Subjects +----------------------- + +Rights of data subjects under the GDPR: As a data subject, you have various rights under the GDPR, arising in particular from Articles 15 to 21 GDPR: + +* **Right to object: You have the right to object at any time, on grounds relating to your particular situation, to the processing of personal data concerning you which is based on Art. 6 Para. 1 lit. e or f GDPR, including profiling based on those provisions. Where personal data are processed for direct marketing purposes, you have the right to object at any time to the processing of personal data concerning you for such marketing, which includes profiling to the extent that it is related to such direct marketing.** +* **Right to withdraw consent:** You have the right to withdraw given consents at any time. +* **Right of access:** You have the right to request confirmation as to whether data concerning you is being processed, and to receive access to this data as well as further information and a copy of the data in accordance with legal requirements. +* **Right to rectification:** You have the right, in accordance with legal requirements, to request the completion of the data concerning you or the rectification of inaccurate data concerning you. +* **Right to erasure and restriction of processing:** You have the right, in accordance with legal requirements, to demand that data concerning you be deleted immediately, or alternatively, to demand a restriction of the processing of the data in accordance with legal requirements. +* **Right to data portability:** You have the right to receive the personal data concerning you, which you have provided to us, in a structured, commonly used, and machine-readable format in accordance with legal requirements, or to demand its transmission to another controller. +* **Complaint to supervisory authority:** Without prejudice to any other administrative or judicial remedy, you have the right to lodge a complaint with a supervisory authority, in particular in the Member State of your habitual residence, place of work, or place of the alleged infringement if you consider that the processing of personal data relating to you infringes the GDPR. + +Provision of the Online Offer and Web Hosting +--------------------------------------------- + +We process user data in order to be able to provide our online services to them. For this purpose, we process the user's IP address, which is necessary to transmit the content and functions of our online services to the user's browser or device. + +* **Processed data types:** Usage data (e.g., page views and dwell time, click paths, usage intensity and frequency, device types and operating systems used, interactions with content and functions); Meta, communication, and procedural data (e.g., IP addresses, time stamps, identification numbers, persons involved); Log data (e.g., log files regarding logins or the retrieval of data or access times); Content data (e.g., text or visual messages and contributions as well as information relating to them, such as authorship details or creation time). +* **Data subjects:** Users (e.g., website visitors, users of online services). +* **Purposes of processing and legitimate interests:** Provision of our online offer and user-friendliness; Information technology infrastructure (operation and provision of information systems and technical devices (computers, servers, etc.)); Security measures. +* **Retention and deletion:** Deletion in accordance with the details in the "General Information on Data Storage and Deletion" section. +* **Legal bases:** Legitimate interests (Art. 6 Para. 1 Sentence 1 lit. f GDPR). + +**Further information on processing operations, procedures, and services:** + +* **Provision of online offer on dedicated/own server hardware:** For the provision of our online offer, we use server hardware operated by us as well as the associated storage space, computing capacity, and software; **Legal bases:** Legitimate interests (Art. 6 Para. 1 Sentence 1 lit. f GDPR). +* **Collection of access data and log files:** Access to our online offer is logged in the form of so-called "server log files". Server log files can include the address and name of the retrieved websites and files, date and time of retrieval, data volumes transferred, notification of successful retrieval, browser type and version, the user's operating system, referrer URL (the previously visited page), and typically IP addresses and the requesting provider. Server log files can be used on the one hand for security purposes, e.g., to prevent server overload (especially in the case of abusive attacks, so-called DDoS attacks), and on the other hand to ensure server utilization and stability; **Legal bases:** Legitimate interests (Art. 6 Para. 1 Sentence 1 lit. f GDPR). **Deletion of data:** Log file information is stored for a maximum period of 30 days and then deleted or anonymized. Data whose further retention is required for evidentiary purposes is excluded from deletion until the respective incident has been finally clarified. +* **Content Delivery Network:** We use a "Content Delivery Network" (CDN). A CDN is a service that helps to deliver contents of an online offer, especially large media files such as graphics or program scripts, faster and more securely with the help of regionally distributed servers connected via the internet; **Legal bases:** Legitimate interests (Art. 6 Para. 1 Sentence 1 lit. f GDPR). +* **Instart:** Content Delivery Network (CDN) - A service that helps deliver content of an online offer, especially large media files such as graphics or program scripts, faster and more securely using regionally distributed servers connected via the internet; **Service provider:** Instart Logic, Inc., 450 Lambert Avenue, Palo Alto, CA 94306, USA; **Legal bases:** Legitimate interests (Art. 6 Para. 1 Sentence 1 lit. f GDPR); **Website:** [https://www.instart.com](https://www.instart.com). **Privacy Policy:** [https://www.instart.com/company/legal/privacy-policy](https://www.instart.com/company/legal/privacy-policy). +* **Stackpath:** Content Delivery Network (CDN) - A service that helps deliver content of an online offer, especially large media files such as graphics or program scripts, faster and more securely using regionally distributed servers connected via the internet; **Service provider:** StackPath, LLC, 2021 McKinney Avenue, Suite 1100, Dallas, Texas 75201, USA; **Legal bases:** Legitimate interests (Art. 6 Para. 1 Sentence 1 lit. f GDPR); **Website:** [https://www.stackpath.com](https://www.stackpath.com); **Privacy Policy:** [https://www.stackpath.com/legal/privacy-statement/](https://www.stackpath.com/legal/privacy-statement/); **Data Processing Agreement:** [https://www.stackpath.com/legal/](https://www.stackpath.com/legal/). **Basis for third-country transfers:** Data Privacy Framework (DPF). + +Use of Cookies +-------------- + +The term "cookies" refers to functions that store and read information on users' devices. Cookies can be used for various purposes, such as for the functionality, security, and convenience of online offers as well as for the creation of analyses of visitor flows. We use cookies in accordance with legal regulations. For this, if required, we obtain users' consent in advance. If consent is not necessary, we rely on our legitimate interests. This applies when the storing and reading of information is essential to provide explicitly requested content and functions. This includes storing settings and ensuring the functionality and security of our online offer. Consent can be withdrawn at any time. We inform you clearly about the scope and which cookies are used. + +**Information on data protection legal bases:** Whether we process personal data using cookies depends on consent. If consent is given, it serves as the legal basis. Without consent, we rely on our legitimate interests, which are explained above in this section and in the context of the respective services and procedures. + +**Storage duration:** Regarding the storage duration, the following types of cookies are distinguished: + +* **Temporary cookies (also: session cookies):** Temporary cookies are deleted at the latest after a user leaves an online offer and closes their device (e.g., browser or mobile application). +* **Permanent cookies:** Permanent cookies remain stored even after the device is closed. For example, login status can be saved or preferred content displayed directly when the user visits a website again. Similarly, user data collected using cookies can be used for reach measurement. Unless we provide users with explicit information on the type and storage duration of cookies (e.g., when obtaining consent), they should assume that these are permanent and the storage period can be up to two years. + +**General instructions for withdrawal and objection (Opt-out):** Users can withdraw their given consents at any time and also declare an objection to processing in accordance with legal requirements, including via the privacy settings of their browser. + +* **Processed data types:** Meta, communication, and procedural data (e.g., IP addresses, time stamps, identification numbers, persons involved). +* **Data subjects:** Users (e.g., website visitors, users of online services). +* **Legal bases:** Legitimate interests (Art. 6 Para. 1 Sentence 1 lit. f GDPR). Consent (Art. 6 Para. 1 Sentence 1 lit. a GDPR). + +**Further information on processing operations, procedures, and services:** + +* **Processing of cookie data based on consent:** We use a consent management solution to obtain user consent for the use of cookies or for the procedures and providers specified in the consent management solution. This procedure serves to obtain, log, manage, and revoke consents, particularly concerning the use of cookies and similar technologies used for storing, reading, and processing information on user devices. Within this framework, user consent is obtained for the use of cookies and the associated processing of information, including the specific processing operations and providers mentioned in the consent management procedure. Users also have the option to manage and revoke their consents. The consent declarations are stored to avoid querying again and to be able to prove consent according to legal requirements. Storage occurs on the server side and/or in a cookie (so-called opt-in cookie) or using similar technologies to assign the consent to a specific user or their device. If no specific information on the providers of consent management services is provided, the following general information applies: The duration of the storage of consent is up to two years. A pseudonymous user identifier is created, which is stored together with the time of consent, information on the scope of consent (e.g., relevant categories of cookies and/or service providers), and information about the browser, system, and device used; **Legal bases:** Consent (Art. 6 Para. 1 Sentence 1 lit. a GDPR). + +Registration, Login, and User Account +------------------------------------- + +Users can create a user account. During registration, users are provided with the required mandatory information, which is processed for the purpose of providing the user account based on the fulfillment of contractual obligations. The processed data includes, in particular, login information (username, password, and an email address). + +As part of the use of our registration and login functions as well as the use of the user account, we store the IP address and the time of the respective user action. The storage is based on our legitimate interests as well as the users' protection against misuse and other unauthorized use. This data is generally not passed on to third parties, unless it is necessary to pursue our legal claims or there is a legal obligation to do so. + +Users may be informed via email about processes relevant to their user account, such as technical changes. + +* **Processed data types:** Inventory data (e.g., full name, residential address, contact information, customer number, etc.); Contact data (e.g., postal and email addresses or phone numbers); Content data (e.g., text or visual messages and contributions as well as information relating to them, such as authorship details or creation time); Usage data (e.g., page views and dwell time, click paths, usage intensity and frequency, device types and operating systems used, interactions with content and functions); Log data (e.g., log files regarding logins or the retrieval of data or access times). +* **Data subjects:** Users (e.g., website visitors, users of online services). +* **Purposes of processing and legitimate interests:** Provision of contractual services and fulfillment of contractual obligations; Security measures; Organizational and administrative procedures; Provision of our online offer and user-friendliness. +* **Retention and deletion:** Deletion in accordance with the details in the "General Information on Data Storage and Deletion" section. Deletion after termination. +* **Legal bases:** Performance of a contract and pre-contractual requests (Art. 6 Para. 1 Sentence 1 lit. b GDPR). Legitimate interests (Art. 6 Para. 1 Sentence 1 lit. f GDPR). + +**Further information on processing operations, procedures, and services:** + +* **Registration with pseudonyms:** Users may use pseudonyms instead of their real names as usernames; **Legal bases:** Performance of a contract and pre-contractual requests (Art. 6 Para. 1 Sentence 1 lit. b GDPR). +* **User profiles are public:** User profiles are publicly visible and accessible. +* **No retention obligation for data:** It is the users' responsibility to secure their data prior to the end of the contract if termination has occurred. We are entitled to permanently delete all user data stored during the term of the contract; **Legal bases:** Performance of a contract and pre-contractual requests (Art. 6 Para. 1 Sentence 1 lit. b GDPR). + +Changes and Updates +------------------- + +We ask you to regularly inform yourself about the content of our privacy policy. We adapt the privacy policy as soon as changes to our data processing make this necessary. We will inform you as soon as the changes require an act of cooperation on your part (e.g., consent) or other individual notification. + +If we provide addresses and contact information of companies and organizations in this privacy policy, please note that the addresses may change over time and we ask you to verify the information before contacting them. + +Definitions +----------- + +This section provides an overview of the terminology used in this privacy policy. Insofar as terms are defined by law, the legal definitions apply. The following explanations are primarily intended to facilitate understanding. + +* **Inventory data:** Inventory data comprises essential information necessary for the identification and management of contractual partners, user accounts, profiles, and similar assignments. This data may include personal and demographic details such as names, contact information (addresses, phone numbers, email addresses), dates of birth, and specific identifiers (user IDs). Inventory data forms the basis for any formal interaction between individuals and services, facilities, or systems by enabling unambiguous assignment and communication. +* **Content data:** Content data includes information generated during the creation, editing, and publication of content of any kind. This category of data can include texts, images, videos, audio files, and other multimedia content published on various platforms and media. Content data is not limited solely to the actual content but also includes metadata providing information about the content itself, such as tags, descriptions, author information, and publication dates. +* **Contact data:** Contact data is essential information enabling communication with individuals or organizations. It includes, among other things, phone numbers, postal addresses, and email addresses, as well as communication tools like social media handles and instant messaging identifiers. +* **Meta, communication, and procedural data:** Meta, communication, and procedural data are categories containing information about the way data is processed, transmitted, and managed. Meta-data, also known as data about data, encompasses information describing the context, origin, and structure of other data. It may include details on file size, creation date, author of a document, and modification histories. Communication data captures the exchange of information between users across various channels, such as email correspondence, call logs, messages in social networks, and chat histories, including the individuals involved, time stamps, and transmission paths. Procedural data describes the processes and workflows within systems or organizations, including workflow documentation, logs of transactions and activities, and audit logs used for tracking and verifying operations. +* **Usage data:** Usage data refers to information that captures how users interact with digital products, services, or platforms. This data covers a broad range of information showing how users utilize applications, which features they prefer, how long they stay on certain pages, and the paths they navigate through an application. Usage data may also include usage frequency, time stamps of activities, IP addresses, device information, and location data. It is highly valuable for analyzing user behavior, optimizing user experiences, personalizing content, and improving products or services. Furthermore, usage data plays a crucial role in identifying trends, preferences, and potential problem areas within digital offerings. +* **Personal data:** "Personal data" means any information relating to an identified or identifiable natural person (hereinafter "data subject"); an identifiable natural person is one who can be identified, directly or indirectly, in particular by reference to an identifier such as a name, an identification number, location data, an online identifier (e.g., cookie) or to one or more factors specific to the physical, physiological, genetic, mental, economic, cultural, or social identity of that natural person. +* **Log data:** Log data is information about events or activities that have been logged in a system or network. This data typically includes information such as time stamps, IP addresses, user actions, error messages, and other details about the usage or operation of a system. Log data is often used for analyzing system issues, security monitoring, or generating performance reports. +* **Controller:** A "controller" is the natural or legal person, public authority, agency, or other body which, alone or jointly with others, determines the purposes and means of the processing of personal data. +* **Processing:** "Processing" means any operation or set of operations which is performed on personal data or on sets of personal data, whether or not by automated means. The term is broad and covers practically any handling of data, be it collection, evaluation, storage, transmission, or deletion. + +[Created with free Datenschutz-Generator.de by Dr. Thomas Schwenke](https://datenschutz-generator.de/ "Legal text by Dr. Schwenke - click for more information.") + """.trimIndent() + } +} + +@Composable +fun PrivacyPolicy() { + val texts = LocalTexts.current + LanguageSelect() + ButtonSpacer() + Markdown(texts.getPrivacyPolicy()) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Profile.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Profile.kt new file mode 100644 index 0000000..d2d8766 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Profile.kt @@ -0,0 +1,89 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Logout +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.PersonRemove +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import io.ktor.client.statement.* +import io.ktor.http.* +import win.tap_tap.papertrader.* +import win.tap_tap.papertrader.components.AppAlertDialog +import win.tap_tap.papertrader.components.AppButton + +class ProfileViewModel( + server: Server, + messageHandler: MessageHandler, + val cache: Cache, + val onSuccessfulLogout: () -> Unit, +) : + PageViewModel(server, messageHandler) { + var isLoading = mutableStateOf(false) + private set + val showUserDeleteDialog = mutableStateOf(false) + val userDeleteLoading = mutableStateOf(false) + + override fun reset() {} + + fun afterLogout() { + cache.clearLoginResponse() + onSuccessfulLogout() + reset() + } + + fun logout() { + val loginResponse = cache.getLoginResponse() + if (loginResponse != null) { + this.handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.LOGOUT, + isLoading = isLoading, + request = + RequestLogoutUser( + loginResponse.accessToken, + loginResponse.refreshToken + ), + successMessage = "Logged out successfully", + onSuccess = { afterLogout() }, + onFailure = { afterLogout() } + ) + } else { + afterLogout() + } + } + + fun delete() { + handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.USER_DELETE, + isLoading = userDeleteLoading, + successMessage = "Deleted your account successfully", + onSuccess = { afterLogout() } + ) + } +} + +@Composable +fun Profile(viewModel: ProfileViewModel) { + AppAlertDialog( + show = viewModel.showUserDeleteDialog, + isLoading = viewModel.userDeleteLoading, + onProceed = { viewModel.delete() }, + heading = "Confirm Deleting Your Account", + text = "Do you really want to delete your account? This can NOT be undone!" + ) + AppButton(onClick = { viewModel.cache.clear() }, text = "Clear Cache", leadingIcon = Icons.Rounded.Delete) + AppButton( + onClick = { viewModel.showUserDeleteDialog.value = true }, + text = "Delete Account", + leadingIcon = Icons.Rounded.PersonRemove + ) + AppButton( + onClick = { viewModel.logout() }, + text = "Logout", + isLoading = viewModel.isLoading.value, + leadingIcon = Icons.AutoMirrored.Rounded.Logout + ) +} + diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Register.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Register.kt new file mode 100644 index 0000000..d463f33 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/Register.kt @@ -0,0 +1,68 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PersonAdd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import io.ktor.client.statement.* +import io.ktor.http.* +import win.tap_tap.papertrader.Endpoints +import win.tap_tap.papertrader.MessageHandler +import win.tap_tap.papertrader.RequestRegisterUser +import win.tap_tap.papertrader.Server +import win.tap_tap.papertrader.components.AppButton +import win.tap_tap.papertrader.components.AppTextLink +import win.tap_tap.papertrader.components.PasswordInput +import win.tap_tap.papertrader.components.UsernameInput + + +class RegisterViewModel(server: Server, messageHandler: MessageHandler) : PageViewModel(server, messageHandler) { + + val username = mutableStateOf("") + val password = mutableStateOf("") + val passwordConfirm = mutableStateOf("") + val isLoading = mutableStateOf(false) + val registeredSuccessfully = mutableStateOf(false) + + override fun reset() { + registeredSuccessfully.value = false + password.value = "" + passwordConfirm.value = "" + } + + fun register() { + this.handleRequest( + method = HttpMethod.Post, + endpoint = Endpoints.REGISTER, + isLoading = isLoading, + request = RequestRegisterUser(username.value, password.value, passwordConfirm.value), + resultBoolean = registeredSuccessfully, + successMessage = "Registered successfully" + ) + } +} + + +@Composable +fun Register(viewModel: RegisterViewModel, onSuccessfulRegister: () -> Unit, onLoginRedirect: () -> Unit) { + LaunchedEffect(viewModel.registeredSuccessfully.value) { + if (viewModel.registeredSuccessfully.value) { + onSuccessfulRegister() + viewModel.reset() + } + } + UsernameInput(viewModel.username, enabled = !viewModel.isLoading.value) + PasswordInput(viewModel.password, enabled = !viewModel.isLoading.value) + PasswordInput(viewModel.passwordConfirm, enabled = !viewModel.isLoading.value) + AppButton( + onClick = { viewModel.register() }, + text = "Register", + isLoading = viewModel.isLoading.value, + leadingIcon = Icons.Rounded.PersonAdd + ) + AppTextLink("Already have an account?", onClick = { + onLoginRedirect() + viewModel.reset() + }) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/UserSelect.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/UserSelect.kt new file mode 100644 index 0000000..00b11a5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/pages/UserSelect.kt @@ -0,0 +1,65 @@ +package win.tap_tap.papertrader.pages + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import win.tap_tap.papertrader.MessageHandler +import win.tap_tap.papertrader.Server +import win.tap_tap.papertrader.User +import win.tap_tap.papertrader.components.AppOutlinedTextField +import win.tap_tap.papertrader.components.AppText +import win.tap_tap.papertrader.components.AppTonalButton +import win.tap_tap.papertrader.components.OutlinedColumn + +class UserSelectViewModel(server: Server, messageHandler: MessageHandler, val selectableUsers: List) : + PageViewModel(server, messageHandler) { + + var isLoading = mutableStateOf(false) + var search by mutableStateOf("") + var currentUsers by mutableStateOf(selectableUsers) + + override fun reset() { + currentUsers = selectableUsers + search = "" + } + + fun updateSearch(input: String) { + search = input + currentUsers = selectableUsers.filter { it.name.contains(search, ignoreCase = true) } + } +} + +@Composable +fun UserSelect( + viewModel: UserSelectViewModel, + onFinish: () -> Unit, + onSelect: (User, MutableState) -> Unit +) { + AppOutlinedTextField( + value = viewModel.search, + onValueChange = { viewModel.updateSearch(it) }, + label = "Search" + ) + OutlinedColumn { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 1500.dp)) { + if (viewModel.currentUsers.isEmpty()) { + item { AppText("Could not find the user you are looking for.") } + } + items(viewModel.currentUsers) { user -> + AppTonalButton( + text = user.name, + isLoading = viewModel.isLoading.value, + onClick = { + onSelect(user, viewModel.isLoading) + onFinish() + viewModel.reset() + }, modifier = Modifier.fillMaxWidth() + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/routes/Routes.kt b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/routes/Routes.kt new file mode 100644 index 0000000..bcab609 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/win/tap_tap/papertrader/routes/Routes.kt @@ -0,0 +1,50 @@ +package win.tap_tap.papertrader.routes + +import androidx.compose.runtime.MutableState +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import win.tap_tap.papertrader.User +import win.tap_tap.papertrader.pages.AssetSelectData + +object Routes { + @Serializable + data object LegalWarning : NavKey + + @Serializable + data object LegalNotice : NavKey + + @Serializable + data object PrivacyPolicy : NavKey + + @Serializable + data object About : NavKey + + @Serializable + data object Login : NavKey + + @Serializable + data object Register : NavKey + + @Serializable + data class Home(val updaterId: Int) : NavKey + + @Serializable + data object Profile : NavKey + + @Serializable + data object CreateChallenge : NavKey + + @Serializable + data class Challenge(val challengeId: Long) : NavKey + + @Serializable + data class AssetSelect( + val data: AssetSelectData + ) : NavKey + + @Serializable + data class UserSelect( + val users: List, + val onSelect: (User, MutableState) -> Unit + ) : NavKey +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/win/tap_tap/papertrader/ComposeAppCommonTest.kt b/composeApp/src/commonTest/kotlin/win/tap_tap/papertrader/ComposeAppCommonTest.kt new file mode 100644 index 0000000..904a919 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/win/tap_tap/papertrader/ComposeAppCommonTest.kt @@ -0,0 +1,12 @@ +package win.tap_tap.papertrader + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ComposeAppCommonTest { + + @Test + fun example() { + assertEquals(3, 1 + 2) + } +} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/win/tap_tap/papertrader/main.kt b/composeApp/src/jvmMain/kotlin/win/tap_tap/papertrader/main.kt new file mode 100644 index 0000000..88daf32 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/win/tap_tap/papertrader/main.kt @@ -0,0 +1,13 @@ +package win.tap_tap.papertrader + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "papertrader", + ) { + App() + } +} \ No newline at end of file diff --git a/composeApp/src/main/res/drawable/de.xml b/composeApp/src/main/res/drawable/de.xml new file mode 100644 index 0000000..689c490 --- /dev/null +++ b/composeApp/src/main/res/drawable/de.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/webMain/Dockerfile b/composeApp/src/webMain/Dockerfile new file mode 100644 index 0000000..1db3c56 --- /dev/null +++ b/composeApp/src/webMain/Dockerfile @@ -0,0 +1,5 @@ +FROM nginx:alpine +EXPOSE 80 +COPY build/dist/wasmJs/productionExecutable /data/www +COPY src/webMain/nginx.conf /etc/nginx/conf.d/default.conf +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/composeApp/src/webMain/kotlin/win/tap_tap/papertrader/main.kt b/composeApp/src/webMain/kotlin/win/tap_tap/papertrader/main.kt new file mode 100644 index 0000000..a8d3679 --- /dev/null +++ b/composeApp/src/webMain/kotlin/win/tap_tap/papertrader/main.kt @@ -0,0 +1,11 @@ +package win.tap_tap.papertrader + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + ComposeViewport { + App() + } +} diff --git a/composeApp/src/webMain/nginx.conf b/composeApp/src/webMain/nginx.conf new file mode 100644 index 0000000..28619a6 --- /dev/null +++ b/composeApp/src/webMain/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name localhost; + root /data/www; + + location / { + index index.html; + try_files $uri $uri/ /index.html; # Essential for SPA routing + } + + # Explicitly ensure .wasm files have the correct MIME type + location ~ \.wasm$ { + types { application/wasm wasm; } + add_header Cache-Control "public, max-age=31536000"; + } +} \ No newline at end of file diff --git a/composeApp/src/webMain/resources/index.html b/composeApp/src/webMain/resources/index.html new file mode 100644 index 0000000..b004c2c --- /dev/null +++ b/composeApp/src/webMain/resources/index.html @@ -0,0 +1,20 @@ + + + + + + papertrader + + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/webMain/resources/styles.css b/composeApp/src/webMain/resources/styles.css new file mode 100644 index 0000000..0549b10 --- /dev/null +++ b/composeApp/src/webMain/resources/styles.css @@ -0,0 +1,7 @@ +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..9775757 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,14 @@ +#Kotlin +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx3072M +#Gradle +org.gradle.jvmargs=-Xmx3072M -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +org.gradle.caching=true +#Android +android.nonTransitiveRClass=true +android.useAndroidX=true +org.gradle.java.installations.fromEnv=JAVA_HOME +org.gradle.java.installations.auto-download=false +org.gradle.java.installations.auto-detect=false +android.builder.sdkDownload=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..ec4e9dd --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,111 @@ +[versions] +agp = "8.13.2" +android-compileSdk = "36" +android-minSdk = "24" +android-targetSdk = "36" +androidx-activity = "1.13.0" +androidx-appcompat = "1.7.1" +androidx-core = "1.18.0" +androidx-espresso = "3.7.0" +androidx-lifecycle = "2.10.0" +androidx-testExt = "1.3.0" +composeHotReload = "1.1.0" +composeMultiplatform = "1.10.3" +junit = "4.13.2" +kotlin = "2.3.21" +kotlinx-coroutines = "1.10.2" +logback = "1.5.32" +material3 = "1.10.0-alpha05" + +ktor = "3.4.3" +sql-delight = "2.3.2" +multi-settings = "1.3.0" +material-icons = "1.7.3" +composeNavigation = "1.1.1" +jib = "3.5.3" +markdown-renderer = "0.40.2" + +[libraries] +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +junit = { module = "junit:junit", version.ref = "junit" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } +androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } +compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } +ktor-serverNetty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } +ktor-serverTestHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" } + +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-utils = { module = "io.ktor:ktor-utils", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } +ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" } +ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } +ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } + +native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sql-delight" } +runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sql-delight" } +android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sql-delight" } +sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sql-delight" } + +multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multi-settings" } + +material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "material-icons" } +material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "material-icons" } + +compose-navigation = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "composeNavigation" } + +markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdown-renderer" } +markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } +markdown-renderer-jvm = { module = "com.mikepenz:multiplatform-markdown-renderer-jvm", version.ref = "markdown-renderer" } +markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "markdown-renderer" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +ktor = { id = "io.ktor.plugin", version.ref = "ktor" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } + +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sql-delight" } + +jib = { id = "com.google.cloud.tools.jib", version.ref = "jib" } + +[bundles] +ktor = [ + "ktor-utils", + "ktor-client-core", + "ktor-client-content-negotiation", + "ktor-serialization-kotlinx-json", + "ktor-client-auth", + "ktor-client-logging", +] +ktorServer = [ + "ktor-server-call-logging", + "ktor-server-content-negotiation", + "ktor-server-auth" +] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..aaaabb3 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..a82488a --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,6 @@ +FROM eclipse-temurin:23-jre-alpine +WORKDIR /app +RUN mkdir -p /app/data +EXPOSE 8080 +COPY build/libs/papertraderServer.jar /app/paptertraderServer.jar +ENTRYPOINT ["java", "-jar", "/app/paptertraderServer.jar"] \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 0000000..3fa9f6b --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,105 @@ +import org.gradle.kotlin.dsl.support.serviceOf + +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.ktor) + alias(libs.plugins.sqldelight) + alias(libs.plugins.kotlinSerialization) + + alias(libs.plugins.jib) + application +} + +group = "win.tap_tap.papertrader" +application { + mainClass.set("win.tap_tap.papertrader.ApplicationKt") + + val isDevelopment: Boolean = project.ext.has("development") + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") +} + +dependencies { + implementation(projects.shared) + implementation(libs.logback) + implementation(libs.ktor.serverCore) + implementation(libs.ktor.serverNetty) + + implementation(libs.bundles.ktor) + implementation(libs.bundles.ktorServer) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.server.cors) + + implementation(libs.sqlite.driver) + + testImplementation(libs.ktor.serverTestHost) + testImplementation(libs.kotlin.testJunit) +} + +allprojects { + version = "1.0.1" +} + +tasks.register("serverDockerBuild") { + group = "deployment" + dependsOn("buildFatJar") + workingDir = projectDir + commandLine("docker", "build", "-t", "papertrader-server", ".") +} + +tasks.register("serverDockerPush") { + group = "deployment" + val version = project.version + val execOps = project.serviceOf() + doLast { + fun execute(args: List) { + execOps.exec { + commandLine(args) + } + } + execute(listOf("docker", "tag", "papertrader-server", "taptap1/papertrader-server:$version")) + execute(listOf("docker", "tag", "papertrader-server", "taptap1/papertrader-server:latest")) + execute(listOf("docker", "push", "taptap1/papertrader-server:$version")) + execute(listOf("docker", "push", "taptap1/papertrader-server:latest")) + println("Pushed: papertrader-server:$version and papertrader-server:latest") + } +} + +tasks.register("serverDockerDeploy") { + group = "deployment" + dependsOn("serverDockerBuild") + dependsOn("serverDockerPush") +} + +tasks.named("serverDockerPush") { + mustRunAfter("serverDockerBuild") +} + +//jib { +// from { +// image = "eclipse-temurin:23-jre-alpine" +// } +// +// to { +// image = "taptap1/papertrader-server" +// tags = setOf("latest", project.version.toString()) +// } +// +// container { +// entrypoint = listOf("java", "-jar", "/app/paptertraderServer.jar") +// ports = listOf("8080") +// } +//} + +ktor { + fatJar { + archiveFileName.set("papertraderServer.jar") + } +} + +sqldelight { + databases { + register("Data") { + packageName.set("win.tap_tap.papertrader") + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/win/tap_tap/papertrader/AlpacaAPI.kt b/server/src/main/kotlin/win/tap_tap/papertrader/AlpacaAPI.kt new file mode 100644 index 0000000..d1f5baf --- /dev/null +++ b/server/src/main/kotlin/win/tap_tap/papertrader/AlpacaAPI.kt @@ -0,0 +1,104 @@ +package win.tap_tap.papertrader + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class AlpacaAssetBar( + val c: Float +) + +@Serializable +data class AlpacaBars( + val bars: Map +) + +@Serializable +data class AlpacaAsset( + val name: String, + val symbol: String, + val status: String, + val tradable: Boolean, + val fractionable: Boolean +) { + fun toAsset(assetId: Long): Asset { + return Asset(id = assetId, symbol = symbol, name = name) + } +} + + +class AlpacaAPI(val apiKey: String, val apiSecret: String) { + val client = HttpClient(CIO) { + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + install(ContentNegotiation) { + json( + json = Json { + ignoreUnknownKeys = true + }) + } + install(DefaultRequest) { + contentType(ContentType.Application.Json) + headers.append("APCA-API-KEY-ID", apiKey) + headers.append("APCA-API-SECRET-KEY", apiSecret) + } + } + + suspend inline fun safeApiCall(url: String, parameters: Map): Result { + return try { + val response = client.get(url) { + for ((key, value) in parameters.entries) { + parameter(key, value) + } + } + if (response.status == HttpStatusCode.OK) { + Result.success(response.body()) + } else { + Result.failure(Exception(response.body())) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getTradableAssets(): Result> { + val result = + safeApiCall>( + "https://paper-api.alpaca.markets/v2/assets?status=active&asset_class=us_equity", + emptyMap() + ) + return result.map { assets -> + assets.filter { it.tradable && it.fractionable && it.status == "active" } + } + } + + suspend fun getPrices(assets: List): Result> { + if (assets.isEmpty()) { + return Result.success(emptyMap()) + } + val result = safeApiCall( + "https://data.alpaca.markets/v2/stocks/bars/latest", + mapOf( + "symbols" to assets.joinToString(",") { it.symbol }, + "feed" to "delayed_sip" + ) + ) + return result.map { bars -> + bars.bars.mapNotNull { (key, value) -> + val asset = assets.find { it.symbol == key } ?: return@mapNotNull null + asset to Cash.fromFloat(value.c) + }.toMap() + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/win/tap_tap/papertrader/Application.kt b/server/src/main/kotlin/win/tap_tap/papertrader/Application.kt new file mode 100644 index 0000000..0b99057 --- /dev/null +++ b/server/src/main/kotlin/win/tap_tap/papertrader/Application.kt @@ -0,0 +1,141 @@ +package win.tap_tap.papertrader + +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.calllogging.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.slf4j.event.Level +import win.tap_tap.papertrader.handler.* +import kotlin.system.exitProcess +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days + +fun getEnv(key: String): String { + val value: String? = System.getenv(key) + if (value == null) { + println("Environment Variable: $key isn't set! Shutting down!") + exitProcess(1) + } + return value +} + +val API_KEY: String = getEnv("ALPACA_KEY") +val API_SECRET: String = getEnv("ALPACA_SECRET") + +val alpacaAPI = AlpacaAPI(API_KEY, API_SECRET) +val database = Database() +val data = database.data + +fun main() { + println("+$API_KEY+$API_SECRET+") + embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module) + .start(wait = true) +} + +suspend fun handleCall( + call: RoutingCall, + callHandler: suspend (RoutingCall, Long, Database, AlpacaAPI) -> Unit, +) { + val userIdString = call.principal() + if (userIdString == null) { + call.respond(HttpStatusCode.Unauthorized) + } else { + val userId = userIdString.name.toLongOrNull() + if (userId == null) { + call.respond(HttpStatusCode.InternalServerError, "Could not convert user id!") + } else { + callHandler(call, userId, database, alpacaAPI) + } + } +} + +fun Application.module() { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + allowStructuredMapKeys = true + } + ) + } + + install(CORS) { + allowHeader(HttpHeaders.AccessControlAllowOrigin) + allowHeader(HttpHeaders.Authorization) + allowHeader(HttpHeaders.ContentType) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Get) + allowHost("papertrader.tap-tap.win") + allowHost("localhost:8081") + allowHost("localhost:8080") + allowHost("127.0.0.1:8081") + allowHost("127.0.0.1:8080") + } + install(CallLogging) { level = Level.WARN } + install(Authentication) { + bearer("bearer-user") { + realm = "Access to full App API" + authenticate { tokenCredential -> + val token = + database.token.findByAccessToken(tokenCredential.token) + ?: return@authenticate null + if (token.accessTokenExpired > Clock.System.now().toEpochMilliseconds()) { + UserIdPrincipal(token.userId.toString()) + } else { + null + } + } + } + } + launch { + while (true) { + updateAssets(database = database, api = alpacaAPI) + delay(1.days) + } + } + routing { + authenticate("bearer-user") { + get(Endpoints.GET_CHALLENGES.endpoint()) { handleCall(call, ::handleGetChallengesCall) } + get(Endpoints.GET_STOCKS.endpoint()) { handleCall(call, ::handleGetStocksCall) } + post(Endpoints.CREATE_CHALLENGE.endpoint()) { + handleCall(call, ::handleCreateChallengeCall) + } + // post(Endpoints.GET_PARTICIPANTS.endpoint()) { handleCall(call, + // ::handleGetChallengeCall) } + post(Endpoints.GET_MISSING_PARTICIPANTS.endpoint()) { + handleCall(call, ::handleGetMissingParticipantsCall) + } + post(Endpoints.ADD_PARTICIPANT.endpoint()) { + handleCall(call, ::handleAddParticipantCall) + } + post(Endpoints.ASSET_BUY.endpoint()) { handleCall(call, ::handleAssetBuyCall) } + post(Endpoints.ASSET_SELL.endpoint()) { handleCall(call, ::handleStockSellCall) } + post(Endpoints.GET_CHALLENGE_DATA.endpoint()) { + handleCall(call, ::handleChallengeDataCall) + } + post(Endpoints.CHALLENGE_LEAVE.endpoint()) { + handleCall(call, ::handleChallengeLeaveCall) + } + post(Endpoints.CHALLENGE_KICK.endpoint()) { + handleCall(call, ::handleChallengeKickCall) + } + post(Endpoints.USER_DELETE.endpoint()) { + handleCall(call, ::handleUserDeleteCall) + } + } + post(Endpoints.REFRESH_TOKEN.endpoint()) { handleTokenRefreshCall(call, database) } + post(Endpoints.LOGOUT.endpoint()) { handleLogoutCall(call, database) } + post(Endpoints.REGISTER.endpoint()) { handleRegisterCall(call, database) } + post(Endpoints.LOGIN.endpoint()) { handleLoginCall(call, database) } + } +} diff --git a/server/src/main/kotlin/win/tap_tap/papertrader/Cron.kt b/server/src/main/kotlin/win/tap_tap/papertrader/Cron.kt new file mode 100644 index 0000000..19095d3 --- /dev/null +++ b/server/src/main/kotlin/win/tap_tap/papertrader/Cron.kt @@ -0,0 +1,13 @@ +package win.tap_tap.papertrader + +suspend fun updateAssets(database: Database, api: AlpacaAPI) { + val result = api.getTradableAssets() + result.onFailure { error -> + println("ERROR: Could not get the tradable assets from alpaca: ${error.message}") + } + result.onSuccess { assets -> + assets.forEach { asset -> + database.asset.add(symbol = asset.symbol, name = asset.name) + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/win/tap_tap/papertrader/Database.kt b/server/src/main/kotlin/win/tap_tap/papertrader/Database.kt new file mode 100644 index 0000000..8aafcfd --- /dev/null +++ b/server/src/main/kotlin/win/tap_tap/papertrader/Database.kt @@ -0,0 +1,216 @@ +package win.tap_tap.papertrader + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import java.util.* +import kotlin.time.Instant + +class UserActions(val data: Data) { + val queries = data.userQueries + fun get(name: String): UserDB? { + return queries.getUserByName(name).executeAsOneOrNull() + } + + fun get(id: Long): UserDB? { + return queries.getUser(id).executeAsOneOrNull() + } + + fun add(name: String, password: String) { + queries.insertUser(name, password) + } + + fun delete(id: Long) { + queries.delete(id) + } +} + +class AssetActions(val data: Data) { + val queries = data.stockQueries + + fun get(stockId: Long): Asset? { + return queries.get(stockId) { id, symbol, name -> Asset(id = id, symbol = symbol, name = name) } + .executeAsOneOrNull() + } + + fun add(symbol: String, name: String) { + if (getBySymbol(symbol) == null) { + queries.add(symbol = symbol, name = name) + } + } + + fun getBySymbol(symbol: String): Asset? { + return queries.getBySymbol(symbol) { id, symbol, name -> Asset(id = id, symbol = symbol, name = name) } + .executeAsOneOrNull() + } + + fun getAll(): List { + return queries.getAll { id, symbol, name -> Asset(id = id, symbol = symbol, name = name) }.executeAsList() + } +} + +class TokenActions(val data: Data) { + val queries = data.tokenQueries + fun add(userId: Long, token: String, tokenExpired: Long, refreshToken: String, refreshTokenExpired: Long) { + queries.insertToken( + userId, + token, + tokenExpired, + refreshToken, + refreshTokenExpired, + ) + } + + fun findByAccessToken(accessToken: String): TokenDB? { + return queries.findToken(accessToken).executeAsOneOrNull() + } + + fun findByRefreshToken(refreshToken: String): TokenDB? { + return queries.findRefreshToken(refreshToken).executeAsOneOrNull() + } + + fun updateAccessToken(tokenId: Long, accessToken: String, expiresIn: Long): TokenDB? { + var token: TokenDB? = null + data.transaction { + queries.updateAccessToken(accessToken, expiresIn, tokenId) + token = queries.getToken(tokenId).executeAsOneOrNull() + } + return token + } + + fun delete(accessToken: String, refreshToken: String) { + queries.deleteTokenByAccessToken(accessToken) + queries.deleteTokenByRefreshToken(refreshToken) + } + +} + +class PositionActions(val data: Data) { + val queries = data.positionQueries + + fun getByUser(challengeId: Long, userId: Long): List { + return queries.getPositionByUser(challengeId, userId).executeAsList() + } + + fun add(stockId: Long, challengeId: Long, userId: Long, amount: Double, date: Instant, price: Cash) { + queries.addPosition( + stockId = stockId, + challengeId = challengeId, + userId = userId, + amount = amount, + date = date.toEpochMilliseconds(), + price = price.asCents() + ) + } + + fun get(positionId: Long): PositionDB? { + return queries.get(positionId).executeAsOneOrNull() + } + + fun delete(positionId: Long) { + queries.delete(positionId) + } +} + +class ChallengeActions(val data: Data, val database: Database) { + val queries = data.challengeQueries + + fun get(challengeId: Long): ChallengeDB? { + return queries.get(challengeId).executeAsOneOrNull() + } + + fun leave(challengeId: Long, userId: Long) { + queries.leave(challengeId = challengeId, userId = userId) + } + + fun addParticipant(userId: Long, challengeId: Long) { + val challenge = get(challengeId) + if (challenge != null) { + data.transaction { + val participant = queries.getParticipant(userId, challengeId).executeAsOneOrNull() + if (participant == null) { + queries.insertParticipant(userId, challenge.id, challenge.cash) + } + } + } + } + + fun insertChallenge(name: String, cashInPennies: Long, creatorId: Long) { + data.transaction { + queries.createChallenge(name, cashInPennies, creatorId) + val challengeId = queries.lastInsertId().executeAsOneOrNull() + if (challengeId != null) { + addParticipant(creatorId, challengeId) + } + } + } + + fun getByUserId(userId: Long): List { + val dbChallenges = queries.getChallengesForUser(userId).executeAsList() + val challenges = mutableListOf() + for (challenge in dbChallenges) { + challenges.add(Challenge(challenge.id, challenge.name, Cash.fromCents(challenge.cash))) + } + return challenges + } + + fun getParticipants(challengeId: Long): List { + val participants = queries.getParticipants(challengeId).executeAsList() + return participants.map { participant -> + Participant( + User(participant.id, participant.name), + Cash.fromCents(participant.cash) + ) + } + } + + fun updateParticipantCash(cash: Cash, challengeId: Long, userId: Long) { + queries.updateParticipantsCash(cash = cash.asCents(), challengeId = challengeId, userId = userId) + } + +// fun getParticipantResponse(challengeId: Long, userId: Long): ResponseGetParticipants? { +// val rows = queries.getFullData(challengeId).executeAsList() +// if (rows.isEmpty()) return null +// +// val participants = rows.groupBy { it.userId }.map { (_, row) -> +// val firstRow = row.first() +// Participant( +// User(firstRow.userId, firstRow.username), +// Cash.fromCents(firstRow.cash), +// row.map { row -> +// Position(Asset(row.positionName), Cash.fromCents(row.buyPrice), row.shareAmount) +// } +// ) +// } +// println("Before user") +// val user = participants.find { it.user.id == userId } ?: return null +// println("before challenge") +// val challenge = queries.get(challengeId).executeAsOneOrNull() ?: return null +// println("after challenge") +// return ResponseGetParticipants( +// challenge.creatorId == userId, +// user, +// participants.filter { it.user.id != userId }) +// } + + fun getParticipant(userId: Long, challengeId: Long): ParticipantsDB? { + return queries.getParticipant(userId, challengeId).executeAsOneOrNull() + } + + fun getMissingUsers(challengeId: Long): List { + val users = queries.getMissingUsers(challengeId).executeAsList() + return users.map { user -> User(user.id, user.name) } + } +} + +class Database() { + val driver = JdbcSqliteDriver("jdbc:sqlite:./data/data.db", Properties(), Data.Schema) + val data = Data(driver) + val user = UserActions(data) + val token = TokenActions(data) + val challenge = ChallengeActions(data, this) + val positions = PositionActions(data) + val asset = AssetActions(data) + + init { + Data.Schema.create(driver) + } +} diff --git a/server/src/main/kotlin/win/tap_tap/papertrader/handler/NetworkHandler.kt b/server/src/main/kotlin/win/tap_tap/papertrader/handler/NetworkHandler.kt new file mode 100644 index 0000000..50a2664 --- /dev/null +++ b/server/src/main/kotlin/win/tap_tap/papertrader/handler/NetworkHandler.kt @@ -0,0 +1,168 @@ +package win.tap_tap.papertrader.handler + +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.* +import win.tap_tap.papertrader.* +import kotlin.time.Clock +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +suspend fun sha256(password: String): String { + val digest = Digest("SHA-256") + digest += password.encodeToByteArray() + return hex(digest.build()) +} + +@OptIn(ExperimentalUuidApi::class) +fun generateToken(databaseFinder: (String) -> TokenDB?): String { + var token = Uuid.random().toString() + while (databaseFinder(token) != null) { + token = Uuid.random().toString() + } + return token +} + +fun generateRefreshToken(database: Database): Pair { + return Pair( + generateToken { database.token.findByRefreshToken(it) }, + Clock.System.now().toEpochMilliseconds() + (30L * 24 * 60 * 60 * 1000) + ) +} + +fun generateAccessToken(database: Database): Pair { + return Pair( + generateToken { database.token.findByAccessToken(it) }, + Clock.System.now().toEpochMilliseconds() + (60 * 60 * 1000) + ) +} + +suspend fun handleRegisterCall(call: RoutingCall, database: Database) { + val request = call.receive() + val username = request.username.trim() + val password = request.password.trim() + val passwordConfirm = request.passwordConfirm.trim() + if (username.length < 3) { + call.respond(HttpStatusCode.BadRequest, "Username has to be at least 3 characters.") + } else if (password.length < 8) { + call.respond(HttpStatusCode.BadRequest, "Password has to be at least 8 characters.") + } else if (password != passwordConfirm) { + call.respond(HttpStatusCode.BadRequest, "Passwords are not the same.") + } else if (database.user.get(username) != null) { + call.respond(HttpStatusCode.BadRequest, "Username is already taken.") + } else { + database.user.add(username, sha256(password)) + call.respond(HttpStatusCode.OK, "User created.") + } +} + +@OptIn(ExperimentalUuidApi::class) +suspend fun handleLoginCall(call: RoutingCall, database: Database) { + val request = call.receive() + val username = request.username.trim() + val password = request.password.trim() + if (username.length < 3) { + call.respond(HttpStatusCode.BadRequest, "Your username is at least 3 characters long.") + } else if (password.length < 8) { + call.respond(HttpStatusCode.BadRequest, "Your password is at least 8 characters long.") + } else { + val user = database.user.get(username) + if (user == null) { + call.respond(HttpStatusCode.BadRequest, "The username doesn't exist.") + } else if (user.password != sha256(password)) { + call.respond(HttpStatusCode.BadRequest, "The password is wrong.") + } else { + val accessToken = generateAccessToken(database) + val refreshToken = generateRefreshToken(database) + database.token.add(user.id, accessToken.first, accessToken.second, refreshToken.first, refreshToken.second) + call.respond(HttpStatusCode.OK, ResponseLogin(accessToken.first, refreshToken.first, accessToken.second)) + } + } +} + +suspend fun handleLogoutCall(call: RoutingCall, database: Database) { + val request = call.receive() + database.token.delete(request.accessToken, request.refreshToken) + call.respond(HttpStatusCode.OK) +} + +suspend fun handleGetChallengesCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + val challenges = database.challenge.getByUserId(userId) + call.respond(HttpStatusCode.OK, ResponseGetChallenges(challenges)) +} + +suspend fun handleCreateChallengeCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + val request = call.receive() + database.challenge.insertChallenge(request.name, request.euros * 100 + request.cents, userId) + call.respond(HttpStatusCode.OK) +} + +suspend fun handleTokenRefreshCall(call: RoutingCall, database: Database) { + val request = call.receive() + val token = database.token.findByRefreshToken(request.refreshToken) + if (token == null) { + call.respond(HttpStatusCode.BadRequest, "Refresh Token is invalid") + } else { + val accessToken = generateAccessToken(database) + val token = database.token.updateAccessToken(token.id, accessToken.first, accessToken.second) + if (token == null) { + call.respond(HttpStatusCode.BadRequest, "Internal Error") + } else { + call.respond(HttpStatusCode.OK, ResponseTokenRefresh(accessToken.first, accessToken.second)) + } + } +} + + +suspend fun handleGetMissingParticipantsCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + val request = call.receive() + val users = database.challenge.getMissingUsers(request.challengeId) + call.respond(HttpStatusCode.OK, ResponseGetMissingParticipants(users)) +} + +suspend fun handleAddParticipantCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + val request = call.receive() + val challenge = + database.challenge.get(request.challengeId) ?: return call.respond(HttpStatusCode.InternalServerError) + if (challenge.creatorId != userId) { + call.respond("You are not the creator of the Challenge!") + } else { + database.challenge.addParticipant(request.participantId, request.challengeId) + call.respond(HttpStatusCode.OK) + } +} + +suspend fun handleChallengeLeaveCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + val request = call.receive() + if (database.challenge.getParticipant(userId = userId, challengeId = request.challengeId) == null) { + call.respond("You cannot leave a challenge that you are not participating in") + return + } + database.challenge.leave(challengeId = request.challengeId, userId = userId) + call.respond(HttpStatusCode.OK) +} + +suspend fun handleChallengeKickCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + val request = call.receive() + val challenge = database.challenge.get(request.challengeId) + if (challenge == null) { + call.respond("Cannot find the challenge you are looking for.") + return + } + if (challenge.creatorId != userId) { + call.respond("You can not kick someone if you are not the creator of the challenge.") + return + } + if (database.challenge.getParticipant(challengeId = request.challengeId, userId = request.userId) == null) { + call.respond("You can not kick someone how is not participating in the challenge.") + return + } + database.challenge.leave(challengeId = request.challengeId, userId = request.userId) +} + +suspend fun handleUserDeleteCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + database.user.delete(userId) + call.respond(HttpStatusCode.OK) +} \ No newline at end of file diff --git a/server/src/main/kotlin/win/tap_tap/papertrader/handler/StockHandler.kt b/server/src/main/kotlin/win/tap_tap/papertrader/handler/StockHandler.kt new file mode 100644 index 0000000..26661be --- /dev/null +++ b/server/src/main/kotlin/win/tap_tap/papertrader/handler/StockHandler.kt @@ -0,0 +1,179 @@ +package win.tap_tap.papertrader.handler + +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import win.tap_tap.papertrader.* +import kotlin.time.Clock + +suspend fun handleAssetBuyCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + val request = call.receive() + val participant = database.challenge.getParticipant(userId, request.challengeId) + if (participant == null) { + call.respond(HttpStatusCode.BadRequest, "You are not participating in the challenge.") + return + } + val asset = database.asset.get(request.assetId) + if (asset == null) { + call.respond(HttpStatusCode.BadRequest, "Could not get the asset you want to buy from the database") + return + } + if (participant.cash < request.cash.asCents()) { + call.respond(HttpStatusCode.BadRequest, "You don't have enough cash to place that order.") + return + } + val result = api.getPrices(listOf(asset)) + if (participant.cash < request.cash.asCents()) { + call.respond(HttpStatusCode.BadRequest, "You don't have enough cash to place that order.") + return + } + result.onFailure { error -> + call.respond( + HttpStatusCode.InternalServerError, + "Could not get current price data of asset: ${error.message}" + ) + } + result.onSuccess { assetPrices -> + val price = assetPrices[asset] + if (price == null) { + call.respond( + HttpStatusCode.InternalServerError, + "Could not get the price of the selected asset." + ) + } else { + if (participant.cash < request.cash.asCents()) { + call.respond(HttpStatusCode.BadRequest, "You don't have enough cash to place that order.") + return + } + database.positions.add( + stockId = asset.id, + challengeId = request.challengeId, + userId = userId, + amount = request.cash.asCents().toDouble() / price.asCents().toDouble(), + date = Clock.System.now(), + price = price + ) + database.challenge.updateParticipantCash( + Cash.fromCents(participant.cash - request.cash.asCents()), + request.challengeId, + userId + ) + call.respond(HttpStatusCode.OK) + } + } +} + +suspend fun handleStockSellCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + val request = call.receive() + val position = database.positions.get(request.positionId) + val participant = database.challenge.getParticipant(userId, position?.challengeId ?: -1) + if (position == null || participant == null) { + call.respond( + HttpStatusCode.BadRequest, + "You don't own the asset you are trying to sell" + ) + return + } + val asset = database.asset.get(position.stockId) + if (asset == null) { + call.respond(HttpStatusCode.InternalServerError, "Could not find the corresponding Stock") + return + } + val result = api.getPrices(listOf(asset)) + result.onFailure { error -> + call.respond(HttpStatusCode.InternalServerError, "Could not get the current asset price: ${error.message}.") + return@onFailure + } + result.onSuccess { assetPrice -> + val price = assetPrice[asset] + if (price == null) { + call.respond(HttpStatusCode.InternalServerError, "Unexpected error while viewing the current asset price.") + return@onSuccess + } + database.challenge.updateParticipantCash( + Cash.fromCents(participant.cash) + price * position.amount, + position.challengeId, + userId + ) + database.positions.delete(position.id) + call.respond(HttpStatusCode.OK) + } +} + +suspend fun handleChallengeDataCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + val request = call.receive() + val challenge = database.challenge.get(request.challengeId) + val curParticipant = database.challenge.getParticipant(userId, request.challengeId) + if (curParticipant == null || challenge == null + ) { + call.respond(HttpStatusCode.BadRequest, "You are not participating in the requested challenge.") + return + } + val participants = database.challenge.getParticipants(request.challengeId) + if (participants.isEmpty()) { + call.respond(HttpStatusCode.InternalServerError, "Could not find any participants of the challenge") + return + } + val positionsDbByUser = mutableMapOf>() + val assets = mutableListOf() + for (participant in participants) { + positionsDbByUser[participant] = mutableMapOf() + val positions = database.positions.getByUser(request.challengeId, participant.user.id) + for (position in positions) { + val asset = database.asset.get(position.stockId) + if (asset == null) { + call.respond(HttpStatusCode.InternalServerError, "Could not get stock from database.") + return + } + positionsDbByUser[participant]?.set(position, asset) + assets.add(asset) + } + } + val result = api.getPrices(assets.distinct()) + result.onFailure { error -> + call.respond( + HttpStatusCode.InternalServerError, + "Could not get the price data of the asset: ${error.message}" + ) + } + result.onSuccess { prices -> + val positionsByUser = mutableMapOf>() + for ((participant, assetByPosition) in positionsDbByUser) { + positionsByUser[participant] = mutableListOf() + for ((positionDb, asset) in assetByPosition) { + val price = prices[asset] + if (price == null) { + call.respond(HttpStatusCode.InternalServerError, "Could not get the price data of an asset.") + return + } + positionsByUser[participant]?.add( + Position( + id = positionDb.id, + asset = asset, + buyPrice = Cash.fromCents(positionDb.price), + currentPrice = price, + amount = positionDb.amount + ) + ) + } + } + val user = database.user.get(userId) + if (user == null) { + call.respond(HttpStatusCode.InternalServerError, "Could not get your data.") + return + } + call.respond( + HttpStatusCode.OK, + ResponseChallengeData( + challenge.creatorId == userId, + Participant(User(user.id, user.name), Cash.fromCents(curParticipant.cash)), + positionsByUser + ) + ) + } +} + +suspend fun handleGetStocksCall(call: RoutingCall, userId: Long, database: Database, api: AlpacaAPI) { + call.respond(HttpStatusCode.OK, ResponseGetStocks(database.asset.getAll())) +} diff --git a/server/src/main/resources/application.yaml b/server/src/main/resources/application.yaml new file mode 100644 index 0000000..d79881c --- /dev/null +++ b/server/src/main/resources/application.yaml @@ -0,0 +1,4 @@ +ktor: + auth: + api_key: "XXX" + api_secret: "XXX" \ No newline at end of file diff --git a/server/src/main/resources/logback.xml b/server/src/main/resources/logback.xml new file mode 100644 index 0000000..3e11d78 --- /dev/null +++ b/server/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + \ No newline at end of file diff --git a/server/src/main/sqldelight/win/tap_tap/papertrader/Challenge.sq b/server/src/main/sqldelight/win/tap_tap/papertrader/Challenge.sq new file mode 100644 index 0000000..7cc20fc --- /dev/null +++ b/server/src/main/sqldelight/win/tap_tap/papertrader/Challenge.sq @@ -0,0 +1,60 @@ +createChallenge: +INSERT INTO ChallengeDB(name, cash, creatorId) +VALUES (?, ?, ?); + +get: +SELECT * FROM ChallengeDB +WHERE ChallengeDB.id == ?; + +leave: +DELETE FROM ParticipantsDB +WHERE challengeId = ? AND userId = ?; + +lastInsertId: +SELECT last_insert_rowid(); + +getChallengesForUser: +SELECT c.* FROM ChallengeDB c +JOIN ParticipantsDB p ON c.id = p.challengeId +WHERE p.userId = :userId; + +insertParticipant: +INSERT INTO ParticipantsDB (userId, challengeId, cash) +VALUES (?, ?, ?); + +getParticipant: +SELECT * FROM ParticipantsDB +WHERE ParticipantsDB.userId = ? AND ParticipantsDB.challengeId = ?; + +getParticipants: +SELECT user.id, user.name, participant.cash FROM UserDB user +JOIN ParticipantsDB participant ON participant.userId = user.id +WHERE participant.challengeId = ?; + +getMissingUsers: +SELECT user.* FROM UserDB user +WHERE NOT EXISTS ( + SELECT 1 FROM ParticipantsDB participants + WHERE participants.challengeId = ? + AND user.id = participants.userId +); + +updateParticipantsCash: +UPDATE ParticipantsDB +SET cash = ? +WHERE userId = ? AND challengeId = ?; + +getFullData: +SELECT + user.id AS userId, + user.name AS username, + participant.cash AS cash, + stock.name AS positionName, + position.price AS buyPrice, + position.amount AS shareAmount +FROM PositionDB position +JOIN ParticipantsDB participant ON position.userId = participant.userId +JOIN AssetDB stock ON position.stockId = stock.id +JOIN UserDB user ON position.userId = user.id +WHERE position.challengeId = ? +ORDER BY user.id ASC, stock.name ASC; diff --git a/server/src/main/sqldelight/win/tap_tap/papertrader/Position.sq b/server/src/main/sqldelight/win/tap_tap/papertrader/Position.sq new file mode 100644 index 0000000..9424f6d --- /dev/null +++ b/server/src/main/sqldelight/win/tap_tap/papertrader/Position.sq @@ -0,0 +1,15 @@ +getPositionByUser: +SELECT * FROM PositionDB +WHERE challengeId = ? AND userId = ?; + +addPosition: +INSERT INTO PositionDB(stockId, challengeId, userId, amount, date, price) +VALUES (?, ?, ?, ?, ?, ?); + +get: +SELECT * FROM PositionDB +WHERE id = ?; + +delete: +DELETE FROM PositionDB +WHERE id = ?; \ No newline at end of file diff --git a/server/src/main/sqldelight/win/tap_tap/papertrader/Stock.sq b/server/src/main/sqldelight/win/tap_tap/papertrader/Stock.sq new file mode 100644 index 0000000..f38f0ed --- /dev/null +++ b/server/src/main/sqldelight/win/tap_tap/papertrader/Stock.sq @@ -0,0 +1,14 @@ +get: +SELECT * FROM AssetDB +WHERE AssetDB.id = ?; + +add: +INSERT INTO AssetDB(symbol, name) +VALUES (?, ?); + +getBySymbol: +SELECT * FROM AssetDB +WHERE AssetDB.symbol = ?; + +getAll: +SELECT * FROM AssetDB; \ No newline at end of file diff --git a/server/src/main/sqldelight/win/tap_tap/papertrader/Tables.sq b/server/src/main/sqldelight/win/tap_tap/papertrader/Tables.sq new file mode 100644 index 0000000..ed9638e --- /dev/null +++ b/server/src/main/sqldelight/win/tap_tap/papertrader/Tables.sq @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS UserDB ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + password TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS TokenDB ( + id Integer PRIMARY KEY AUTOINCREMENT, + userId INTEGER NOT NULL, + accessToken TEXT NOT NULL UNIQUE, + accessTokenExpired INTEGER NOT NULL, + refreshToken TEXT NOT NULL UNIQUE, + refreshtokenExpired INTEGER NOT NULL, + FOREIGN KEY (userId) REFERENCES UserDB(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS ChallengeDB ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + cash INTEGER NOT NULL, + creatorId INTEGER NOT NULL, + FOREIGN KEY (creatorId) REFERENCES UserDB(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS ParticipantsDB ( + userId INTEGER NOT NULL, + challengeId INTEGER NOT NULL, + cash INTEGER NOT NULL, + PRIMARY KEY (userId, challengeId), + FOREIGN KEY (userId) REFERENCES UserDB(id) ON DELETE CASCADE, + FOREIGN KEY (challengeId) REFERENCES ChallengeDB(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS AssetDB ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL UNIQUE, + name TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS PositionDB ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stockId INTEGER NOT NULL, + challengeId INTEGER NOT NULL, + userId INTEGER NOT NULL, + amount REAL NOT NULL, + date INTEGER NOT NULL, + price INTEGER NOT NULL, + FOREIGN KEY (stockId) REFERENCES AssetDB(id) ON DELETE CASCADE, + FOREIGN KEY (challengeId) REFERENCES ChallengeDB(id) ON DELETE CASCADE, + FOREIGN KEY (userId) REFERENCES UserDB(id) ON DELETE CASCADE +); + diff --git a/server/src/main/sqldelight/win/tap_tap/papertrader/Token.sq b/server/src/main/sqldelight/win/tap_tap/papertrader/Token.sq new file mode 100644 index 0000000..901ac63 --- /dev/null +++ b/server/src/main/sqldelight/win/tap_tap/papertrader/Token.sq @@ -0,0 +1,31 @@ +insertToken: +INSERT INTO TokenDB(userId, accessToken, accessTokenExpired, refreshToken, refreshtokenExpired) +VALUES (?, ?, ?, ?, ?); + +getToken: +SELECT * FROM TokenDB +WHERE id = ? +LIMIT 1; + +findToken: +SELECT * FROM TokenDB +WHERE accessToken = ? +LIMIT 1; + +findRefreshToken: +SELECT * FROM TokenDB +WHERE refreshToken = ? +LIMIT 1; + +updateAccessToken: +UPDATE TokenDB +SET accessToken = ?, accessTokenExpired = ? +WHERE id = ?; + +deleteTokenByAccessToken: +DELETE FROM TokenDB +WHERE accessToken = ?; + +deleteTokenByRefreshToken: +DELETE FROM TokenDB +WHERE refreshToken = ?; diff --git a/server/src/main/sqldelight/win/tap_tap/papertrader/User.sq b/server/src/main/sqldelight/win/tap_tap/papertrader/User.sq new file mode 100644 index 0000000..34878f0 --- /dev/null +++ b/server/src/main/sqldelight/win/tap_tap/papertrader/User.sq @@ -0,0 +1,12 @@ +insertUser: +INSERT INTO UserDB(name, password) +VALUES (?, ?); + +getUser: +SELECT * FROM UserDB WHERE id = ?; + +getUserByName: +SELECT * FROM UserDB WHERE name = ? COLLATE NOCASE; + +delete: +DELETE FROM UserDB WHERE id = ?; \ No newline at end of file diff --git a/server/src/test/kotlin/win/tap_tap/papertrader/ApplicationTest.kt b/server/src/test/kotlin/win/tap_tap/papertrader/ApplicationTest.kt new file mode 100644 index 0000000..fe69cc1 --- /dev/null +++ b/server/src/test/kotlin/win/tap_tap/papertrader/ApplicationTest.kt @@ -0,0 +1,20 @@ +package win.tap_tap.papertrader + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlin.test.* + +class ApplicationTest { + + @Test + fun testRoot() = testApplication { + application { + module() + } + val response = client.get("/") + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("Ktor: ${Greeting().greet()}", response.bodyAsText()) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c269d34 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,38 @@ +rootProject.name = "papertrader" + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +// plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } + +include(":composeApp") + +include(":server") + +include(":shared") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..545ce6a --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,42 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvmToolchain(21) + + androidTarget() + // androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + targets.withType { + compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } + } + + jvm() + + js { browser() } + + @OptIn(ExperimentalWasmDsl::class) wasmJs { browser() } + + sourceSets { + commonMain.dependencies { + implementation(libs.ktor.serialization.kotlinx.json) + // put your Multiplatform dependencies here + } + commonTest.dependencies { implementation(libs.kotlin.test) } + } +} + +android { + namespace = "win.tap_tap.papertrader.shared" + compileSdk = libs.versions.android.compileSdk.get().toInt() + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() } +} diff --git a/shared/src/commonMain/kotlin/win/tap_tap/papertrader/Data.kt b/shared/src/commonMain/kotlin/win/tap_tap/papertrader/Data.kt new file mode 100644 index 0000000..ef67fb6 --- /dev/null +++ b/shared/src/commonMain/kotlin/win/tap_tap/papertrader/Data.kt @@ -0,0 +1,108 @@ +package win.tap_tap.papertrader + +import kotlinx.serialization.Serializable +import kotlin.math.roundToLong + +fun Double.toPercentString(relative: Boolean = false): String { + var shifted = (this * 10000).toInt() / 100.0 + if (relative) shifted -= 100F + val string = shifted.toString() + val parts = string.split(".") + val decimals = parts.getOrNull(1)?.padEnd(2, '0')?.take(2) ?: "00" + return "${parts[0]}.$decimals%" +} + +fun getRelativePercentage(currentValue: Cash, startValue: Cash): String { + if (startValue.asCents() == 0L) { + return 0.0.toPercentString() + } + val percentages = (currentValue.asCents().toDouble() / startValue.asCents() + .toDouble() + ).toPercentString(relative = true) + return percentages +} + +@Serializable +data class Cash( + val euro: Long, + val cent: Long, +) { + fun asCents(): Long { + return euro * 100 + cent + } + + override fun toString(): String { + return "$euro,$cent€" + } + + operator fun times(other: Double): Cash { + return fromCents((asCents() * other).roundToLong()) + } + + operator fun times(other: Cash): Cash { + return fromCents(asCents() * other.asCents()) + } + + operator fun plus(other: Cash): Cash { + return fromCents(asCents() + other.asCents()) + } + + operator fun minus(other: Cash): Cash { + return fromCents(asCents() - other.asCents()) + } + + companion object { + fun fromCents(cents: Long): Cash { + return Cash(cents / 100, cents % 100) + } + + fun fromFloat(cash: Float): Cash { + return Cash(cash.toLong(), (cash % 1).toLong() * 100L) + } + + fun zero(): Cash { + return Cash(0, 0) + } + } +} + +@Serializable +data class Asset( + val id: Long, val symbol: String, val name: String, +) + +fun List.currentValue(): Cash { + return Cash.fromCents(this.sumOf { it.currentPrice.asCents() * it.amount }.roundToLong()) +} + +fun List.buyValue(): Cash { + return Cash.fromCents(this.sumOf { it.buyPrice.asCents() * it.amount }.roundToLong()) +} + +@Serializable +data class Position( + val id: Long, + val asset: Asset, + val buyPrice: Cash, + val currentPrice: Cash, + val amount: Double, +) { + fun currentMarketValue(): Cash { + return Cash.fromCents((currentPrice.asCents() * amount).roundToLong()) + } + + fun buyMarketValue(): Cash { + return Cash.fromCents((buyPrice.asCents() * amount).roundToLong()) + } +} + +@Serializable +data class Challenge( + val id: Long, val name: String, val cash: Cash +) + +@Serializable +data class Participant(val user: User, val cash: Cash) + +@Serializable +data class User(val id: Long, val name: String) diff --git a/shared/src/commonMain/kotlin/win/tap_tap/papertrader/DataTransferObjects.kt b/shared/src/commonMain/kotlin/win/tap_tap/papertrader/DataTransferObjects.kt new file mode 100644 index 0000000..4a4ee9a --- /dev/null +++ b/shared/src/commonMain/kotlin/win/tap_tap/papertrader/DataTransferObjects.kt @@ -0,0 +1,77 @@ +package win.tap_tap.papertrader + +import kotlinx.serialization.Serializable + + +// Single Requests + +@Serializable +data class RequestAddParticipant(val participantId: Long, val challengeId: Long) + +@Serializable +data class RequestRegisterUser(val username: String, val password: String, val passwordConfirm: String) + +@Serializable +data class RequestAssetBuy(val assetId: Long, val cash: Cash, val challengeId: Long) + +@Serializable +data class RequestAssetSell(val positionId: Long) + +@Serializable +data class RequestLeaveChallenge(val challengeId: Long) + +@Serializable +data class RequestChallengeKick(val challengeId: Long, val userId: Long) + +// Single Responses + +@Serializable +data class ResponseGetChallenges(val challenges: List) + +@Serializable +data class ResponseGetStocks(val stocks: List) + +// Requests + Responses + +@Serializable +data class RequestLoginUser(val username: String, val password: String) + +@Serializable +data class ResponseLogin(val accessToken: String, val refreshToken: String, val expiresIn: Long) + +@Serializable +data class RequestLogoutUser(val accessToken: String, val refreshToken: String) + +@Serializable +data class RequestCreateChallenge(val name: String, val euros: Long, val cents: Long) + +@Serializable +data class RequestTokenRefresh(val refreshToken: String) + +@Serializable +data class ResponseTokenRefresh(val accessToken: String, val expiresIn: Long) + +@Serializable +data class RequestGetParticipants(val challengeId: Long) + +@Serializable +data class ResponseGetParticipants(val userIsCreator: Boolean, val user: Participant, val participants: List) + +@Serializable +data class RequestGetMissingParticipants(val challengeId: Long) + +@Serializable +data class ResponseGetMissingParticipants(val users: List) + +@Serializable +data class RequestChallengeData(val challengeId: Long) + +@Serializable +data class ResponseChallengeData( + val userIsCreator: Boolean, + val user: Participant, + val positions: Map> +) + + + diff --git a/shared/src/commonMain/kotlin/win/tap_tap/papertrader/Endpoints.kt b/shared/src/commonMain/kotlin/win/tap_tap/papertrader/Endpoints.kt new file mode 100644 index 0000000..0e64dc2 --- /dev/null +++ b/shared/src/commonMain/kotlin/win/tap_tap/papertrader/Endpoints.kt @@ -0,0 +1,28 @@ +package win.tap_tap.papertrader + +//const val API_ENDPOINT = "http://127.0.0.1:8080" + +const val API_ENDPOINT = "https://papertrader.tap-tap.win" + +enum class Endpoints(val path: String) { + LOGIN("/api/login"), + REGISTER("/api/register"), + LOGOUT("/api/logout"), + GET_CHALLENGES("/api/get-challenges"), + REFRESH_TOKEN("/api/refresh-token"), + + // GET_PARTICIPANTS("/api/get-participants"), + CREATE_CHALLENGE("/api/create-challenge"), + GET_MISSING_PARTICIPANTS("/api/get-missing-participants"), + ADD_PARTICIPANT("/api/add-participant"), + GET_STOCKS("/api/get-stocks"), + ASSET_BUY("/api/asset/buy"), + ASSET_SELL("/api/asset/sell"), + CHALLENGE_LEAVE("/api/challenge/leave"), + CHALLENGE_KICK("/api/challenge/kick"), + GET_CHALLENGE_DATA("/api/challenge/data"), + USER_DELETE("/api/user/delete"); + + fun full(): String = API_ENDPOINT + this.path + fun endpoint(): String = this.path +} diff --git a/shared/src/commonTest/kotlin/win/tap_tap/papertrader/SharedCommonTest.kt b/shared/src/commonTest/kotlin/win/tap_tap/papertrader/SharedCommonTest.kt new file mode 100644 index 0000000..b1b0729 --- /dev/null +++ b/shared/src/commonTest/kotlin/win/tap_tap/papertrader/SharedCommonTest.kt @@ -0,0 +1,12 @@ +package win.tap_tap.papertrader + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SharedCommonTest { + + @Test + fun example() { + assertEquals(3, 1 + 2) + } +} \ No newline at end of file diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..90e2896 --- /dev/null +++ b/shell.nix @@ -0,0 +1,69 @@ +{ pkgs ? import { config.allowUnfree = true; } }: + +let + androidSdk = pkgs.androidenv.composeAndroidPackages { + buildToolsVersions = [ "35.0.0" "36.0.0" ]; + platformVersions = [ "35" "36" ]; + abiVersions = [ "x86_64" ]; + includeEmulator = true; + includeSystemImages = true; + systemImageTypes = [ "google_apis_playstore" ]; + includeNDK = false; + }; +in +pkgs.mkShell { + buildInputs = with pkgs; [ + # --- Java --- + # jetbrains.jdk + jdk21 + + # --- Kotlin --- + kotlin + gradle + # kotlin-language-server + + # --- Android --- + androidSdk.androidsdk + android-tools + # yarn + + # --- Web --- + # yarn + # nodejs_24 + + libGL + xorg.libX11 + xorg.libXcursor + xorg.libXext + xorg.libXi + xorg.libXrender + fontconfig + libxkbcommon + ]; + + shellHook = '' + ln -sfn ${pkgs.jdk21}/lib/openjdk .jdk + export ANDROID_HOME="${androidSdk.androidsdk}/libexec/android-sdk" + export ANDROID_SDK_ROOT="${androidSdk.androidsdk}/libexec/android-sdk" + export GRADLE_OPTS="-Dorg.gradle.java.installations.paths=${pkgs.jdk21}/lib/openjdk" + export JAVA_HOME=${pkgs.jdk21} + export JDK_PATH=${pkgs.jdk21}/lib/openjdk + export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$JAVA_HOME/bin:$PATH" + export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath [ + # pkgs.yarn + # pkgs.nodejs_24 + pkgs.libGL + pkgs.xorg.libX11 + pkgs.xorg.libXcursor + pkgs.xorg.libXext + pkgs.xorg.libXi + pkgs.xorg.libXrender + pkgs.fontconfig + pkgs.libxkbcommon + ]} + + echo "sdk.dir=$ANDROID_HOME" > local.properties + + echo "Kotlin Multiplatform Dev Environment Loaded!" + ''; +} diff --git a/syncSqlDelight.sh b/syncSqlDelight.sh new file mode 100755 index 0000000..d19f861 --- /dev/null +++ b/syncSqlDelight.sh @@ -0,0 +1,2 @@ +./gradlew generateMainDataInterface +./gradlew generateSqlDelightInterface