From 5eaa9c797745296f50908996aa602becea802984 Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Fri, 5 Jun 2026 14:31:30 +0200 Subject: [PATCH 01/18] Upgrade project to the latests versions --- composeApp/build.gradle.kts | 25 +++------ gradle.properties | 12 ++++- gradle/gradle-daemon-jvm.properties | 13 +++++ gradle/libs.versions.toml | 64 +++++++++++------------- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 3 ++ shared/build.gradle.kts | 4 +- 7 files changed, 66 insertions(+), 57 deletions(-) create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 77e03f8..d55b48a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -15,6 +15,9 @@ val versionMajor = properties["multiplatformkickstarter.version.major"].toString val versionMinor = properties["multiplatformkickstarter.version.minor"].toString().toInt() val versionPatch = properties["multiplatformkickstarter.version.patch"].toString().toInt() +val minSdkVersion = 26 +val sdkVersion = 37 + fun versionCode(): Int { versionNum?.let { return (versionMajor * 1000000) + (versionMinor * 1000) + it.toInt() @@ -22,22 +25,6 @@ fun versionCode(): Int { } kotlin { - /*@OptIn(ExperimentalWasmDsl::class) - wasmJs { - moduleName = "composeApp" - browser { - commonWebpackConfig { - outputFileName = "composeApp.js" - devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { - static = (static ?: mutableListOf()).apply { - // Serve sources to debug inside browser - add(project.projectDir.path) - } - } - } - } - binaries.executable() - }*/ androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { @@ -127,7 +114,7 @@ kotlin { android { namespace = "com.multiplatformkickstarter.app.android" - compileSdk = libs.versions.android.compileSdk.get().toInt() + compileSdk = sdkVersion sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") sourceSets["main"].res.srcDirs("src/androidMain/res") @@ -135,8 +122,8 @@ android { defaultConfig { applicationId = "com.multiplatformkickstarter.app.android" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() + minSdk = minSdkVersion + targetSdk = sdkVersion versionCode = versionCode() versionName = "$versionMajor.$versionMinor.$versionPatch" } diff --git a/gradle.properties b/gradle.properties index c10e7fd..ddf24cf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,4 +35,14 @@ kotlin.native.cachekind=none # Multiplatform Kickstarter config multiplatformkickstarter.version.major = 2 multiplatformkickstarter.version.minor = 2 -multiplatformkickstarter.version.patch = 0 \ No newline at end of file +multiplatformkickstarter.version.patch = 0 +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..baa28d1 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5cf11ec..1e2e753 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,29 +1,26 @@ [versions] -android-compileSdk = "35" -android-minSdk = "26" -android-targetSdk = "35" -kotlin = "2.1.21" -ktor-client = "2.3.12" -ktor-server = "2.3.12" +kotlin = "2.4.0" +ktor-client = "3.5.0" +ktor-server = "3.5.0" multiplatform-settings = "1.3.0" voyager = "1.1.0-beta03" -koin = "4.1.0" -koin-compose = "4.1.0" +koin = "4.2.1" +koin-compose = "4.2.1" junit = "4.13.2" -androidGradlePlugin = "8.11.0" -composeMultiplatform = "1.8.2" -exposed = "0.61.0" -skiko = "0.8.18" -ktlint = "12.3.0" +androidGradlePlugin = "9.2.1" +composeMultiplatform = "1.11.1" +exposed = "1.3.0" +skiko = "0.148.1" +ktlint = "14.2.0" detekt = "1.23.8" -kotlinx-coroutines = "1.10.2" -androidx-junit = "1.2.1" -espressoCore = "3.6.1" +kotlinx-coroutines = "1.11.0" +androidx-junit = "1.3.0" +espressoCore = "3.7.0" accompanistSystemuicontroller = "0.36.0" -activityCompose = "1.10.1" -firebase = "33.16.0" +activityCompose = "1.13.0" +firebase = "34.14.0" touchlab = "2.1.0" -navigationCommonKtx = "2.9.1" +navigationCommonKtx = "2.9.8" [libraries] # Common @@ -38,11 +35,11 @@ multiplatform-settings-test = { module = "com.russhwolf:multiplatform-settings-t skiko = { module = "org.jetbrains.skiko:skiko", version.ref = "skiko" } skiko-macos-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-macos-arm64", version.ref = "skiko" } ktor-client = { module = "io.ktor:ktor-client-core", version.ref = "ktor-client" } -kamel = "media.kamel:kamel-image:0.7.1" -kermit = "co.touchlab:kermit:2.0.6" +kamel = "media.kamel:kamel-image:1.0.9" +kermit = "co.touchlab:kermit:2.1.0" -kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1" -kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.7.0" +kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0" +kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.8.0" kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } @@ -56,10 +53,10 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } # Android -compose-bom = "androidx.compose:compose-bom:2025.06.01" +compose-bom = "androidx.compose:compose-bom:2026.06.00" koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } -androidx-core-ktx = "androidx.core:core-ktx:1.16.0" +androidx-core-ktx = "androidx.core:core-ktx:1.19.0" androidx-core-splashscreen = "androidx.core:core-splashscreen:1.0.1" androidx-tracing-ktx = "androidx.tracing:tracing-ktx:1.3.0" accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } @@ -83,9 +80,9 @@ stately-common = { module = "co.touchlab:stately-common", version.ref = "touchla # Desktop koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose" } -kotlinx-coroutines-swingui = "org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.10.2" +kotlinx-coroutines-swingui = "org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.11.0" ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor-client" } -androidx-compose-ui-util = "org.jetbrains.compose.ui:ui-util:1.8.2" +androidx-compose-ui-util = "org.jetbrains.compose.ui:ui-util:1.11.1" # Backend ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor-server" } @@ -114,10 +111,10 @@ exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exp exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } kotlinx-html-jvm = "org.jetbrains.kotlinx:kotlinx-html-jvm:0.12.0" -logback-classic = "ch.qos.logback:logback-classic:1.5.18" -postgresql = "org.postgresql:postgresql:42.7.7" -hikariCP = "com.zaxxer:HikariCP:6.3.0" -swagger-codegen = "io.swagger.codegen.v3:swagger-codegen-generators:1.0.57" +logback-classic = "ch.qos.logback:logback-classic:1.5.34" +postgresql = "org.postgresql:postgresql:42.7.11" +hikariCP = "com.zaxxer:HikariCP:7.0.2" +swagger-codegen = "io.swagger.codegen.v3:swagger-codegen-generators:1.0.62" kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" } @@ -133,8 +130,7 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } #Backend -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "kotlin" } -shadow = { id = "com.github.johnrengelman.shadow", version = "8.1.1" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +shadow = { id = "com.gradleup.shadow", version = "9.4.2" } [bundles] - diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b..c61a118 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index a8528f9..15a4381 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,9 @@ pluginManagement { mavenCentral() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositories { diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index bdeeac1..236e21c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -118,9 +118,9 @@ kotlin { android { namespace = "com.multiplatformkickstarter.app" - compileSdk = libs.versions.android.compileSdk.get().toInt() + compileSdk = 36 defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInt() + minSdk = 26 } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 From 348d2cde3a91023626d18708cb5bf031d2b3930c Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Fri, 5 Jun 2026 16:30:30 +0200 Subject: [PATCH 02/18] Updated gradle --- composeApp/build.gradle.kts | 4 +-- .../android/core/di/DependencyContainer.kt | 2 +- gradle/libs.versions.toml | 4 +-- gradle/wrapper/gradle-wrapper.jar | Bin 63375 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 28 +++++++++--------- gradlew.bat | 25 ++++++++-------- shared/build.gradle.kts | 2 -- .../feature/profile/ProfileDetailScreen.kt | 2 +- .../app/ui/components/PetCardBig.kt | 2 +- .../app/ui/components/PetCardSmall.kt | 2 +- .../app/ui/screens/PetDetailScreen.kt | 2 +- .../app/ui/screens/ProfileTabScreen.kt | 2 +- 13 files changed, 37 insertions(+), 40 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d55b48a..9d0509a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -33,7 +33,6 @@ kotlin { } jvm("desktop") listOf( - iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> @@ -143,6 +142,7 @@ android { } buildFeatures { compose = true + buildConfig = true } dependencies { implementation(project(":shared")) @@ -191,7 +191,6 @@ compose.desktop { dependencies { implementation(compose.desktop.currentOs) - implementation(libs.skiko) implementation(compose.ui) implementation(compose.foundation) implementation(compose.material) @@ -200,7 +199,6 @@ compose.desktop { implementation(compose.materialIconsExtended) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.components.resources) - implementation(libs.skiko.macos.arm64) implementation(project(":shared")) implementation(libs.androidx.compose.ui.util) diff --git a/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/core/di/DependencyContainer.kt b/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/core/di/DependencyContainer.kt index 2993d9e..5002413 100644 --- a/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/core/di/DependencyContainer.kt +++ b/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/core/di/DependencyContainer.kt @@ -2,7 +2,7 @@ package com.multiplatformkickstarter.app.android.core.di import android.content.Context import androidx.annotation.VisibleForTesting -import com.google.firebase.ktx.BuildConfig +import com.multiplatformkickstarter.app.android.BuildConfig import com.multiplatformkickstarter.app.di.appModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e2e753..85ea846 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,7 +53,7 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } # Android -compose-bom = "androidx.compose:compose-bom:2026.06.00" +compose-bom = "androidx.compose:compose-bom:2026.05.01" koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } androidx-core-ktx = "androidx.core:core-ktx:1.19.0" @@ -73,7 +73,7 @@ androidx-material3-window-size = { module = "androidx.compose.material3:material androidx-ui = { module = "androidx.compose.ui:ui" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } -firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics-ktx" } +firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics" } # iOS stately-common = { module = "co.touchlab:stately-common", version.ref = "touchlab" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c4cdf41af1ab109bc7f253b2b887023340..d997cfc60f4cff0e7451d19d49a82fa986695d07 100644 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 63375 zcmb5VV{~QRw)Y#`wrv{~+qP{x72B%VwzFc}c2cp;N~)5ZbDrJayPv(!dGEd-##*zr z)#n-$y^sH|_dchh3@8{H5D*j;5D<{i*8l5IFJ|DjL!e)upfGNX(kojugZ3I`oH1PvW`wFW_ske0j@lB9bX zO;2)`y+|!@X(fZ1<2n!Qx*)_^Ai@Cv-dF&(vnudG?0CsddG_&Wtae(n|K59ew)6St z#dj7_(Cfwzh$H$5M!$UDd8=4>IQsD3xV=lXUq($;(h*$0^yd+b{qq63f0r_de#!o_ zXDngc>zy`uor)4A^2M#U*DC~i+dc<)Tb1Tv&~Ev@oM)5iJ4Sn#8iRw16XXuV50BS7 zdBL5Mefch(&^{luE{*5qtCZk$oFr3RH=H!c3wGR=HJ(yKc_re_X9pD` zJ;uxPzUfVpgU>DSq?J;I@a+10l0ONXPcDkiYcihREt5~T5Gb}sT0+6Q;AWHl`S5dV>lv%-p9l#xNNy7ZCr%cyqHY%TZ8Q4 zbp&#ov1*$#grNG#1vgfFOLJCaNG@K|2!W&HSh@3@Y%T?3YI75bJp!VP*$*!< z;(ffNS_;@RJ`=c7yX04!u3JP*<8jeqLHVJu#WV&v6wA!OYJS4h<_}^QI&97-;=ojW zQ-1t)7wnxG*5I%U4)9$wlv5Fr;cIizft@&N+32O%B{R1POm$oap@&f| zh+5J{>U6ftv|vAeKGc|zC=kO(+l7_cLpV}-D#oUltScw})N>~JOZLU_0{Ka2e1evz z{^a*ZrLr+JUj;)K&u2CoCAXLC2=fVScI(m_p~0FmF>>&3DHziouln?;sxW`NB}cSX z8?IsJB)Z=aYRz!X=yJn$kyOWK%rCYf-YarNqKzmWu$ZvkP12b4qH zhS9Q>j<}(*frr?z<%9hl*i^#@*O2q(Z^CN)c2c z>1B~D;@YpG?G!Yk+*yn4vM4sO-_!&m6+`k|3zd;8DJnxsBYtI;W3We+FN@|tQ5EW= z!VU>jtim0Mw#iaT8t_<+qKIEB-WwE04lBd%Letbml9N!?SLrEG$nmn7&W(W`VB@5S zaY=sEw2}i@F_1P4OtEw?xj4@D6>_e=m=797#hg}f*l^`AB|Y0# z9=)o|%TZFCY$SzgSjS|8AI-%J4x}J)!IMxY3_KYze`_I=c1nmrk@E8c9?MVRu)7+Ue79|)rBX7tVB7U|w4*h(;Gi3D9le49B38`wuv zp7{4X^p+K4*$@gU(Tq3K1a#3SmYhvI42)GzG4f|u zwQFT1n_=n|jpi=70-yE9LA+d*T8u z`=VmmXJ_f6WmZveZPct$Cgu^~gFiyL>Lnpj*6ee>*0pz=t$IJ}+rE zsf@>jlcG%Wx;Cp5x)YSVvB1$yyY1l&o zvwX=D7k)Dn;ciX?Z)Pn8$flC8#m`nB&(8?RSdBvr?>T9?E$U3uIX7T?$v4dWCa46 z+&`ot8ZTEgp7G+c52oHJ8nw5}a^dwb_l%MOh(ebVj9>_koQP^$2B~eUfSbw9RY$_< z&DDWf2LW;b0ZDOaZ&2^i^g+5uTd;GwO(-bbo|P^;CNL-%?9mRmxEw~5&z=X^Rvbo^WJW=n_%*7974RY}JhFv46> zd}`2|qkd;89l}R;i~9T)V-Q%K)O=yfVKNM4Gbacc7AOd>#^&W&)Xx!Uy5!BHnp9kh z`a(7MO6+Ren#>R^D0K)1sE{Bv>}s6Rb9MT14u!(NpZOe-?4V=>qZ>}uS)!y~;jEUK z&!U7Fj&{WdgU#L0%bM}SYXRtM5z!6M+kgaMKt%3FkjWYh=#QUpt$XX1!*XkpSq-pl zhMe{muh#knk{9_V3%qdDcWDv}v)m4t9 zQhv{;} zc{}#V^N3H>9mFM8`i`0p+fN@GqX+kl|M94$BK3J-X`Hyj8r!#x6Vt(PXjn?N)qedP z=o1T^#?1^a{;bZ&x`U{f?}TMo8ToN zkHj5v|}r}wDEi7I@)Gj+S1aE-GdnLN+$hw!=DzglMaj#{qjXi_dwpr|HL(gcCXwGLEmi|{4&4#OZ4ChceA zKVd4K!D>_N=_X;{poT~4Q+!Le+ZV>=H7v1*l%w`|`Dx8{)McN@NDlQyln&N3@bFpV z_1w~O4EH3fF@IzJ9kDk@7@QctFq8FbkbaH7K$iX=bV~o#gfh?2JD6lZf(XP>~DACF)fGFt)X%-h1yY~MJU{nA5 ze2zxWMs{YdX3q5XU*9hOH0!_S24DOBA5usB+Ws$6{|AMe*joJ?RxfV}*7AKN9V*~J zK+OMcE@bTD>TG1*yc?*qGqjBN8mgg@h1cJLDv)0!WRPIkC` zZrWXrceVw;fB%3`6kq=a!pq|hFIsQ%ZSlo~)D z|64!aCnw-?>}AG|*iOl44KVf8@|joXi&|)1rB;EQWgm+iHfVbgllP$f!$Wf42%NO5b(j9Bw6L z;0dpUUK$5GX4QbMlTmLM_jJt!ur`_0~$b#BB7FL*%XFf<b__1o)Ao3rlobbN8-(T!1d-bR8D3S0@d zLI!*GMb5s~Q<&sjd}lBb8Nr0>PqE6_!3!2d(KAWFxa{hm`@u|a(%#i(#f8{BP2wbs zt+N_slWF4IF_O|{w`c~)Xvh&R{Au~CFmW#0+}MBd2~X}t9lz6*E7uAD`@EBDe$>7W zzPUkJx<`f$0VA$=>R57^(K^h86>09?>_@M(R4q($!Ck6GG@pnu-x*exAx1jOv|>KH zjNfG5pwm`E-=ydcb+3BJwuU;V&OS=6yM^4Jq{%AVqnTTLwV`AorIDD}T&jWr8pB&j28fVtk_y*JRP^t@l*($UZ z6(B^-PBNZ+z!p?+e8@$&jCv^EWLb$WO=}Scr$6SM*&~B95El~;W_0(Bvoha|uQ1T< zO$%_oLAwf1bW*rKWmlD+@CP&$ObiDy=nh1b2ejz%LO9937N{LDe7gle4i!{}I$;&Y zkexJ9Ybr+lrCmKWg&}p=`2&Gf10orS?4$VrzWidT=*6{KzOGMo?KI0>GL0{iFWc;C z+LPq%VH5g}6V@-tg2m{C!-$fapJ9y}c$U}aUmS{9#0CM*8pC|sfer!)nG7Ji>mfRh z+~6CxNb>6eWKMHBz-w2{mLLwdA7dA-qfTu^A2yG1+9s5k zcF=le_UPYG&q!t5Zd_*E_P3Cf5T6821bO`daa`;DODm8Ih8k89=RN;-asHIigj`n=ux>*f!OC5#;X5i;Q z+V!GUy0|&Y_*8k_QRUA8$lHP;GJ3UUD08P|ALknng|YY13)}!!HW@0z$q+kCH%xet zlWf@BXQ=b=4}QO5eNnN~CzWBbHGUivG=`&eWK}beuV*;?zt=P#pM*eTuy3 zP}c#}AXJ0OIaqXji78l;YrP4sQe#^pOqwZUiiN6^0RCd#D271XCbEKpk`HI0IsN^s zES7YtU#7=8gTn#lkrc~6)R9u&SX6*Jk4GFX7){E)WE?pT8a-%6P+zS6o&A#ml{$WX zABFz#i7`DDlo{34)oo?bOa4Z_lNH>n;f0nbt$JfAl~;4QY@}NH!X|A$KgMmEsd^&Y zt;pi=>AID7ROQfr;MsMtClr5b0)xo|fwhc=qk33wQ|}$@?{}qXcmECh>#kUQ-If0$ zseb{Wf4VFGLNc*Rax#P8ko*=`MwaR-DQ8L8V8r=2N{Gaips2_^cS|oC$+yScRo*uF zUO|5=?Q?{p$inDpx*t#Xyo6=s?bbN}y>NNVxj9NZCdtwRI70jxvm3!5R7yiWjREEd zDUjrsZhS|P&|Ng5r+f^kA6BNN#|Se}_GF>P6sy^e8kBrgMv3#vk%m}9PCwUWJg-AD zFnZ=}lbi*mN-AOm zCs)r=*YQAA!`e#1N>aHF=bb*z*hXH#Wl$z^o}x##ZrUc=kh%OHWhp=7;?8%Xj||@V?1c ziWoaC$^&04;A|T)!Zd9sUzE&$ODyJaBpvqsw19Uiuq{i#VK1!htkdRWBnb z`{rat=nHArT%^R>u#CjjCkw-7%g53|&7z-;X+ewb?OLWiV|#nuc8mp*LuGSi3IP<<*Wyo9GKV7l0Noa4Jr0g3p_$ z*R9{qn=?IXC#WU>48-k5V2Oc_>P;4_)J@bo1|pf=%Rcbgk=5m)CJZ`caHBTm3%!Z9 z_?7LHr_BXbKKr=JD!%?KhwdYSdu8XxPoA{n8^%_lh5cjRHuCY9Zlpz8g+$f@bw@0V z+6DRMT9c|>1^3D|$Vzc(C?M~iZurGH2pXPT%F!JSaAMdO%!5o0uc&iqHx?ImcX6fI zCApkzc~OOnfzAd_+-DcMp&AOQxE_EsMqKM{%dRMI5`5CT&%mQO?-@F6tE*xL?aEGZ z8^wH@wRl`Izx4sDmU>}Ym{ybUm@F83qqZPD6nFm?t?(7>h*?`fw)L3t*l%*iw0Qu#?$5eq!Qc zpQvqgSxrd83NsdO@lL6#{%lsYXWen~d3p4fGBb7&5xqNYJ)yn84!e1PmPo7ChVd%4 zHUsV0Mh?VpzZD=A6%)Qrd~i7 z96*RPbid;BN{Wh?adeD_p8YU``kOrGkNox3D9~!K?w>#kFz!4lzOWR}puS(DmfjJD z`x0z|qB33*^0mZdM&6$|+T>fq>M%yoy(BEjuh9L0>{P&XJ3enGpoQRx`v6$txXt#c z0#N?b5%srj(4xmPvJxrlF3H%OMB!jvfy z;wx8RzU~lb?h_}@V=bh6p8PSb-dG|-T#A?`c&H2`_!u+uenIZe`6f~A7r)`9m8atC zt(b|6Eg#!Q*DfRU=Ix`#B_dK)nnJ_+>Q<1d7W)eynaVn`FNuN~%B;uO2}vXr5^zi2 z!ifIF5@Zlo0^h~8+ixFBGqtweFc`C~JkSq}&*a3C}L?b5Mh-bW=e)({F_g4O3 zb@SFTK3VD9QuFgFnK4Ve_pXc3{S$=+Z;;4+;*{H}Rc;845rP?DLK6G5Y-xdUKkA6E3Dz&5f{F^FjJQ(NSpZ8q-_!L3LL@H* zxbDF{gd^U3uD;)a)sJwAVi}7@%pRM&?5IaUH%+m{E)DlA_$IA1=&jr{KrhD5q&lTC zAa3c)A(K!{#nOvenH6XrR-y>*4M#DpTTOGQEO5Jr6kni9pDW`rvY*fs|ItV;CVITh z=`rxcH2nEJpkQ^(;1c^hfb8vGN;{{oR=qNyKtR1;J>CByul*+=`NydWnSWJR#I2lN zTvgnR|MBx*XFsfdA&;tr^dYaqRZp*2NwkAZE6kV@1f{76e56eUmGrZ>MDId)oqSWw z7d&r3qfazg+W2?bT}F)4jD6sWaw`_fXZGY&wnGm$FRPFL$HzVTH^MYBHWGCOk-89y zA+n+Q6EVSSCpgC~%uHfvyg@ufE^#u?JH?<73A}jj5iILz4Qqk5$+^U(SX(-qv5agK znUkfpke(KDn~dU0>gdKqjTkVk`0`9^0n_wzXO7R!0Thd@S;U`y)VVP&mOd-2 z(hT(|$=>4FY;CBY9#_lB$;|Wd$aOMT5O_3}DYXEHn&Jrc3`2JiB`b6X@EUOD zVl0S{ijm65@n^19T3l%>*;F(?3r3s?zY{thc4%AD30CeL_4{8x6&cN}zN3fE+x<9; zt2j1RRVy5j22-8U8a6$pyT+<`f+x2l$fd_{qEp_bfxfzu>ORJsXaJn4>U6oNJ#|~p z`*ZC&NPXl&=vq2{Ne79AkQncuxvbOG+28*2wU$R=GOmns3W@HE%^r)Fu%Utj=r9t` zd;SVOnA(=MXgnOzI2@3SGKHz8HN~Vpx&!Ea+Df~`*n@8O=0!b4m?7cE^K*~@fqv9q zF*uk#1@6Re_<^9eElgJD!nTA@K9C732tV~;B`hzZ321Ph=^BH?zXddiu{Du5*IPg} zqDM=QxjT!Rp|#Bkp$(mL)aar)f(dOAXUiw81pX0DC|Y4;>Vz>>DMshoips^8Frdv} zlTD=cKa48M>dR<>(YlLPOW%rokJZNF2gp8fwc8b2sN+i6&-pHr?$rj|uFgktK@jg~ zIFS(%=r|QJ=$kvm_~@n=ai1lA{7Z}i+zj&yzY+!t$iGUy|9jH#&oTNJ;JW-3n>DF+ z3aCOzqn|$X-Olu_p7brzn`uk1F*N4@=b=m;S_C?#hy{&NE#3HkATrg?enaVGT^$qIjvgc61y!T$9<1B@?_ibtDZ{G zeXInVr5?OD_nS_O|CK3|RzzMmu+8!#Zb8Ik;rkIAR%6?$pN@d<0dKD2c@k2quB%s( zQL^<_EM6ow8F6^wJN1QcPOm|ehA+dP(!>IX=Euz5qqIq}Y3;ibQtJnkDmZ8c8=Cf3 zu`mJ!Q6wI7EblC5RvP*@)j?}W=WxwCvF3*5Up_`3*a~z$`wHwCy)2risye=1mSp%p zu+tD6NAK3o@)4VBsM!@);qgsjgB$kkCZhaimHg&+k69~drbvRTacWKH;YCK(!rC?8 zP#cK5JPHSw;V;{Yji=55X~S+)%(8fuz}O>*F3)hR;STU`z6T1aM#Wd+FP(M5*@T1P z^06O;I20Sk!bxW<-O;E081KRdHZrtsGJflFRRFS zdi5w9OVDGSL3 zNrC7GVsGN=b;YH9jp8Z2$^!K@h=r-xV(aEH@#JicPy;A0k1>g1g^XeR`YV2HfmqXY zYbRwaxHvf}OlCAwHoVI&QBLr5R|THf?nAevV-=~V8;gCsX>jndvNOcFA+DI+zbh~# zZ7`qNk&w+_+Yp!}j;OYxIfx_{f0-ONc?mHCiCUak=>j>~>YR4#w# zuKz~UhT!L~GfW^CPqG8Lg)&Rc6y^{%3H7iLa%^l}cw_8UuG;8nn9)kbPGXS}p3!L_ zd#9~5CrH8xtUd?{d2y^PJg+z(xIfRU;`}^=OlehGN2=?}9yH$4Rag}*+AWotyxfCJ zHx=r7ZH>j2kV?%7WTtp+-HMa0)_*DBBmC{sd$)np&GEJ__kEd`xB5a2A z*J+yx>4o#ZxwA{;NjhU*1KT~=ZK~GAA;KZHDyBNTaWQ1+;tOFFthnD)DrCn`DjBZ% zk$N5B4^$`n^jNSOr=t(zi8TN4fpaccsb`zOPD~iY=UEK$0Y70bG{idLx@IL)7^(pL z{??Bnu=lDeguDrd%qW1)H)H`9otsOL-f4bSu};o9OXybo6J!Lek`a4ff>*O)BDT_g z<6@SrI|C9klY(>_PfA^qai7A_)VNE4c^ZjFcE$Isp>`e5fLc)rg@8Q_d^Uk24$2bn z9#}6kZ2ZxS9sI(RqT7?El2@B+($>eBQrNi_k#CDJ8D9}8$mmm z4oSKO^F$i+NG)-HE$O6s1--6EzJa?C{x=QgK&c=)b(Q9OVoAXYEEH20G|q$}Hue%~ zO3B^bF=t7t48sN zWh_zA`w~|){-!^g?6Mqf6ieV zFx~aPUOJGR=4{KsW7I?<=J2|lY`NTU=lt=%JE9H1vBpkcn=uq(q~=?iBt_-r(PLBM zP-0dxljJO>4Wq-;stY)CLB4q`-r*T$!K2o}?E-w_i>3_aEbA^MB7P5piwt1dI-6o!qWCy0 ztYy!x9arGTS?kabkkyv*yxvsPQ7Vx)twkS6z2T@kZ|kb8yjm+^$|sEBmvACeqbz)RmxkkDQX-A*K!YFziuhwb|ym>C$}U|J)4y z$(z#)GH%uV6{ec%Zy~AhK|+GtG8u@c884Nq%w`O^wv2#A(&xH@c5M`Vjk*SR_tJnq z0trB#aY)!EKW_}{#L3lph5ow=@|D5LzJYUFD6 z7XnUeo_V0DVSIKMFD_T0AqAO|#VFDc7c?c-Q%#u00F%!_TW1@JVnsfvm@_9HKWflBOUD~)RL``-!P;(bCON_4eVdduMO>?IrQ__*zE@7(OX zUtfH@AX*53&xJW*Pu9zcqxGiM>xol0I~QL5B%Toog3Jlenc^WbVgeBvV8C8AX^Vj& z^I}H})B=VboO%q1;aU5ACMh{yK4J;xlMc`jCnZR^!~LDs_MP&8;dd@4LDWw~*>#OT zeZHwdQWS!tt5MJQI~cw|Ka^b4c|qyd_ly(+Ql2m&AAw^ zQeSXDOOH!!mAgzAp0z)DD>6Xo``b6QwzUV@w%h}Yo>)a|xRi$jGuHQhJVA%>)PUvK zBQ!l0hq<3VZ*RnrDODP)>&iS^wf64C;MGqDvx>|p;35%6(u+IHoNbK z;Gb;TneFo*`zUKS6kwF*&b!U8e5m4YAo03a_e^!5BP42+r)LFhEy?_7U1IR<; z^0v|DhCYMSj<-;MtY%R@Fg;9Kky^pz_t2nJfKWfh5Eu@_l{^ph%1z{jkg5jQrkvD< z#vdK!nku*RrH~TdN~`wDs;d>XY1PH?O<4^U4lmA|wUW{Crrv#r%N>7k#{Gc44Fr|t z@UZP}Y-TrAmnEZ39A*@6;ccsR>)$A)S>$-Cj!=x$rz7IvjHIPM(TB+JFf{ehuIvY$ zsDAwREg*%|=>Hw$`us~RP&3{QJg%}RjJKS^mC_!U;E5u>`X`jW$}P`Mf}?7G7FX#{ zE(9u1SO;3q@ZhDL9O({-RD+SqqPX)`0l5IQu4q)49TUTkxR(czeT}4`WV~pV*KY&i zAl3~X%D2cPVD^B43*~&f%+Op)wl<&|D{;=SZwImydWL6@_RJjxP2g)s=dH)u9Npki zs~z9A+3fj0l?yu4N0^4aC5x)Osnm0qrhz@?nwG_`h(71P znbIewljU%T*cC=~NJy|)#hT+lx#^5MuDDnkaMb*Efw9eThXo|*WOQzJ*#3dmRWm@! zfuSc@#kY{Um^gBc^_Xdxnl!n&y&}R4yAbK&RMc+P^Ti;YIUh|C+K1|=Z^{nZ}}rxH*v{xR!i%qO~o zTr`WDE@k$M9o0r4YUFFeQO7xCu_Zgy)==;fCJ94M_rLAv&~NhfvcLWCoaGg2ao~3e zBG?Ms9B+efMkp}7BhmISGWmJsKI@a8b}4lLI48oWKY|8?zuuNc$lt5Npr+p7a#sWu zh!@2nnLBVJK!$S~>r2-pN||^w|fY`CT{TFnJy`B|e5;=+_v4l8O-fkN&UQbA4NKTyntd zqK{xEKh}U{NHoQUf!M=2(&w+eef77VtYr;xs%^cPfKLObyOV_9q<(%76-J%vR>w9!us-0c-~Y?_EVS%v!* z15s2s3eTs$Osz$JayyH|5nPAIPEX=U;r&p;K14G<1)bvn@?bM5kC{am|C5%hyxv}a z(DeSKI5ZfZ1*%dl8frIX2?);R^^~LuDOpNpk-2R8U1w92HmG1m&|j&J{EK=|p$;f9 z7Rs5|jr4r8k5El&qcuM+YRlKny%t+1CgqEWO>3;BSRZi(LA3U%Jm{@{y+A+w(gzA< z7dBq6a1sEWa4cD0W7=Ld9z0H7RI^Z7vl(bfA;72j?SWCo`#5mVC$l1Q2--%V)-uN* z9ha*s-AdfbDZ8R8*fpwjzx=WvOtmSzGFjC#X)hD%Caeo^OWjS(3h|d9_*U)l%{Ab8 zfv$yoP{OuUl@$(-sEVNt{*=qi5P=lpxWVuz2?I7Dc%BRc+NGNw+323^ z5BXGfS71oP^%apUo(Y#xkxE)y?>BFzEBZ}UBbr~R4$%b7h3iZu3S(|A;&HqBR{nK& z$;GApNnz=kNO^FL&nYcfpB7Qg;hGJPsCW44CbkG1@l9pn0`~oKy5S777uH)l{irK!ru|X+;4&0D;VE*Ii|<3P zUx#xUqvZT5kVQxsF#~MwKnv7;1pR^0;PW@$@T7I?s`_rD1EGUdSA5Q(C<>5SzE!vw z;{L&kKFM-MO>hy#-8z`sdVx})^(Dc-dw;k-h*9O2_YZw}|9^y-|8RQ`BWJUJL(Cer zP5Z@fNc>pTXABbTRY-B5*MphpZv6#i802giwV&SkFCR zGMETyUm(KJbh+&$8X*RB#+{surjr;8^REEt`2&Dubw3$mx>|~B5IKZJ`s_6fw zKAZx9&PwBqW1Oz0r0A4GtnZd7XTKViX2%kPfv+^X3|_}RrQ2e3l=KG_VyY`H?I5&CS+lAX5HbA%TD9u6&s#v!G> zzW9n4J%d5ye7x0y`*{KZvqyXUfMEE^ZIffzI=Hh|3J}^yx7eL=s+TPH(Q2GT-sJ~3 zI463C{(ag7-hS1ETtU;_&+49ABt5!A7CwLwe z=SoA8mYZIQeU;9txI=zcQVbuO%q@E)JI+6Q!3lMc=Gbj(ASg-{V27u>z2e8n;Nc*pf}AqKz1D>p9G#QA+7mqqrEjGfw+85Uyh!=tTFTv3|O z+)-kFe_8FF_EkTw!YzwK^Hi^_dV5x-Ob*UWmD-})qKj9@aE8g240nUh=g|j28^?v7 zHRTBo{0KGaWBbyX2+lx$wgXW{3aUab6Bhm1G1{jTC7ota*JM6t+qy)c5<@ zpc&(jVdTJf(q3xB=JotgF$X>cxh7k*(T`-V~AR+`%e?YOeALQ2Qud( zz35YizXt(aW3qndR}fTw1p()Ol4t!D1pitGNL95{SX4ywzh0SF;=!wf=?Q?_h6!f* zh7<+GFi)q|XBsvXZ^qVCY$LUa{5?!CgwY?EG;*)0ceFe&=A;!~o`ae}Z+6me#^sv- z1F6=WNd6>M(~ z+092z>?Clrcp)lYNQl9jN-JF6n&Y0mp7|I0dpPx+4*RRK+VQI~>en0Dc;Zfl+x z_e_b7s`t1_A`RP3$H}y7F9_na%D7EM+**G_Z0l_nwE+&d_kc35n$Fxkd4r=ltRZhh zr9zER8>j(EdV&Jgh(+i}ltESBK62m0nGH6tCBr90!4)-`HeBmz54p~QP#dsu%nb~W z7sS|(Iydi>C@6ZM(Us!jyIiszMkd)^u<1D+R@~O>HqZIW&kearPWmT>63%_t2B{_G zX{&a(gOYJx!Hq=!T$RZ&<8LDnxsmx9+TBL0gTk$|vz9O5GkK_Yx+55^R=2g!K}NJ3 zW?C;XQCHZl7H`K5^BF!Q5X2^Mj93&0l_O3Ea3!Ave|ixx+~bS@Iv18v2ctpSt4zO{ zp#7pj!AtDmti$T`e9{s^jf(ku&E|83JIJO5Qo9weT6g?@vX!{7)cNwymo1+u(YQ94 zopuz-L@|5=h8A!(g-MXgLJC0MA|CgQF8qlonnu#j z;uCeq9ny9QSD|p)9sp3ebgY3rk#y0DA(SHdh$DUm^?GI<>%e1?&}w(b zdip1;P2Z=1wM+$q=TgLP$}svd!vk+BZ@h<^4R=GS2+sri7Z*2f`9 z5_?i)xj?m#pSVchk-SR!2&uNhzEi+#5t1Z$o0PoLGz*pT64%+|Wa+rd5Z}60(j?X= z{NLjtgRb|W?CUADqOS@(*MA-l|E342NxRaxLTDqsOyfWWe%N(jjBh}G zm7WPel6jXijaTiNita+z(5GCO0NM=Melxud57PP^d_U## zbA;9iVi<@wr0DGB8=T9Ab#2K_#zi=$igyK48@;V|W`fg~7;+!q8)aCOo{HA@vpSy-4`^!ze6-~8|QE||hC{ICKllG9fbg_Y7v z$jn{00!ob3!@~-Z%!rSZ0JO#@>|3k10mLK0JRKP-Cc8UYFu>z93=Ab-r^oL2 zl`-&VBh#=-?{l1TatC;VweM^=M7-DUE>m+xO7Xi6vTEsReyLs8KJ+2GZ&rxw$d4IT zPXy6pu^4#e;;ZTsgmG+ZPx>piodegkx2n0}SM77+Y*j^~ICvp#2wj^BuqRY*&cjmL zcKp78aZt>e{3YBb4!J_2|K~A`lN=u&5j!byw`1itV(+Q_?RvV7&Z5XS1HF)L2v6ji z&kOEPmv+k_lSXb{$)of~(BkO^py&7oOzpjdG>vI1kcm_oPFHy38%D4&A4h_CSo#lX z2#oqMCTEP7UvUR3mwkPxbl8AMW(e{ARi@HCYLPSHE^L<1I}OgZD{I#YH#GKnpRmW3 z2jkz~Sa(D)f?V?$gNi?6)Y;Sm{&?~2p=0&BUl_(@hYeX8YjaRO=IqO7neK0RsSNdYjD zaw$g2sG(>JR=8Iz1SK4`*kqd_3-?;_BIcaaMd^}<@MYbYisWZm2C2|Np_l|8r9yM|JkUngSo@?wci(7&O9a z%|V(4C1c9pps0xxzPbXH=}QTxc2rr7fXk$9`a6TbWKPCz&p=VsB8^W96W=BsB|7bc zf(QR8&Ktj*iz)wK&mW`#V%4XTM&jWNnDF56O+2bo<3|NyUhQ%#OZE8$Uv2a@J>D%t zMVMiHh?es!Ex19q&6eC&L=XDU_BA&uR^^w>fpz2_`U87q_?N2y;!Z!bjoeKrzfC)} z?m^PM=(z{%n9K`p|7Bz$LuC7!>tFOuN74MFELm}OD9?%jpT>38J;=1Y-VWtZAscaI z_8jUZ#GwWz{JqvGEUmL?G#l5E=*m>`cY?m*XOc*yOCNtpuIGD+Z|kn4Xww=BLrNYS zGO=wQh}Gtr|7DGXLF%|`G>J~l{k^*{;S-Zhq|&HO7rC_r;o`gTB7)uMZ|WWIn@e0( zX$MccUMv3ABg^$%_lNrgU{EVi8O^UyGHPNRt%R!1#MQJn41aD|_93NsBQhP80yP<9 zG4(&0u7AtJJXLPcqzjv`S~5;Q|5TVGccN=Uzm}K{v)?f7W!230C<``9(64}D2raRU zAW5bp%}VEo{4Rko`bD%Ehf=0voW?-4Mk#d3_pXTF!-TyIt6U+({6OXWVAa;s-`Ta5 zTqx&8msH3+DLrVmQOTBOAj=uoxKYT3DS1^zBXM?1W+7gI!aQNPYfUl{3;PzS9*F7g zWJN8x?KjBDx^V&6iCY8o_gslO16=kh(|Gp)kz8qlQ`dzxQv;)V&t+B}wwdi~uBs4? zu~G|}y!`3;8#vIMUdyC7YEx6bb^1o}G!Jky4cN?BV9ejBfN<&!4M)L&lRKiuMS#3} z_B}Nkv+zzxhy{dYCW$oGC&J(Ty&7%=5B$sD0bkuPmj7g>|962`(Q{ZZMDv%YMuT^KweiRDvYTEop3IgFv#)(w>1 zSzH>J`q!LK)c(AK>&Ib)A{g`Fdykxqd`Yq@yB}E{gnQV$K!}RsgMGWqC3DKE(=!{}ekB3+(1?g}xF>^icEJbc z5bdxAPkW90atZT+&*7qoLqL#p=>t-(-lsnl2XMpZcYeW|o|a322&)yO_8p(&Sw{|b zn(tY$xn5yS$DD)UYS%sP?c|z>1dp!QUD)l;aW#`%qMtQJjE!s2z`+bTSZmLK7SvCR z=@I4|U^sCwZLQSfd*ACw9B@`1c1|&i^W_OD(570SDLK`MD0wTiR8|$7+%{cF&){$G zU~|$^Ed?TIxyw{1$e|D$050n8AjJvvOWhLtLHbSB|HIfhMpqVf>AF&}ZQHhOJ14Bz zww+XL+qP}nww+W`F>b!by|=&a(cM4JIDhsTXY8@|ntQG}-}jm0&Bcj|LV(#sc=BNS zRjh;k9l>EdAFdd)=H!U`~$WP*}~^3HZ_?H>gKw>NBa;tA8M1{>St|)yDF_=~{KEPAGkg3VB`QCHol!AQ0|?e^W?81f{@()Wy!vQ$bY; z0ctx)l7VK83d6;dp!s{Nu=SwXZ8lHQHC*J2g@P0a={B8qHdv(+O3wV=4-t4HK1+smO#=S; z3cSI#Nh+N@AqM#6wPqjDmQM|x95JG|l1#sAU|>I6NdF*G@bD?1t|ytHlkKD+z9}#j zbU+x_cR-j9yX4s{_y>@zk*ElG1yS({BInGJcIT>l4N-DUs6fufF#GlF2lVUNOAhJT zGZThq54GhwCG(h4?yWR&Ax8hU<*U)?g+HY5-@{#ls5CVV(Wc>Bavs|l<}U|hZn z_%m+5i_gaakS*Pk7!v&w3&?R5Xb|AkCdytTY;r+Z7f#Id=q+W8cn)*9tEet=OG+Y} z58U&!%t9gYMx2N=8F?gZhIjtkH!`E*XrVJ?$2rRxLhV1z82QX~PZi8^N5z6~f-MUE zLKxnNoPc-SGl7{|Oh?ZM$jq67sSa)Wr&3)0YxlJt(vKf!-^L)a|HaPv*IYXb;QmWx zsqM>qY;tpK3RH-omtta+Xf2Qeu^$VKRq7`e$N-UCe1_2|1F{L3&}M0XbJ@^xRe&>P zRdKTgD6601x#fkDWkoYzRkxbn#*>${dX+UQ;FbGnTE-+kBJ9KPn)501#_L4O_k`P3 zm+$jI{|EC?8BXJY{P~^f-{**E53k%kVO$%p+=H5DiIdwMmUo>2euq0UzU90FWL!>; z{5@sd0ecqo5j!6AH@g6Mf3keTP$PFztq}@)^ZjK;H6Go$#SV2|2bAFI0%?aXgVH$t zb4Kl`$Xh8qLrMbZUS<2*7^F0^?lrOE=$DHW+O zvLdczsu0^TlA6RhDy3=@s!k^1D~Awulk!Iyo#}W$xq8{yTAK!CLl={H0@YGhg-g~+ z(u>pss4k#%8{J%~%8=H5!T`rqK6w^es-cNVE}=*lP^`i&K4R=peg1tdmT~UAbDKc& zg%Y*1E{hBf<)xO>HDWV7BaMWX6FW4ou1T2m^6{Jb!Su1UaCCYY8RR8hAV$7ho|FyEyP~ zEgK`@%a$-C2`p zV*~G>GOAs*3KN;~IY_UR$ISJxB(N~K>=2C2V6>xTmuX4klRXdrJd&UPAw7&|KEwF8Zcy2j-*({gSNR1^p02Oj88GN9a_Hq;Skdp}kO0;FLbje%2ZvPiltDZgv^ z#pb4&m^!79;O8F+Wr9X71laPY!CdNXG?J6C9KvdAE2xWW1>U~3;0v≫L+crb^Bz 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 /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -111,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -144,7 +146,7 @@ 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=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -169,7 +171,6 @@ fi # 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" ) @@ -201,16 +202,15 @@ 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 $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# 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" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 93e3f59..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @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 ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +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 @@ -57,22 +59,21 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 236e21c..df2bc40 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -35,7 +35,6 @@ kotlin { jvm() listOf( - iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { @@ -54,7 +53,6 @@ kotlin { } } commonMain.dependencies { - implementation(libs.skiko) implementation(compose.ui) implementation(compose.foundation) implementation(compose.material) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/profile/ProfileDetailScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/profile/ProfileDetailScreen.kt index 79ada48..0a30a06 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/profile/ProfileDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/profile/ProfileDetailScreen.kt @@ -137,7 +137,7 @@ class ProfileDetailScreen(val userId: Int) : Screen { ) } else { KamelImage( - resource = asyncPainterResource(data = state.image!!), + resource = { asyncPainterResource(data = state.image!!) }, contentDescription = "profile image", contentScale = ContentScale.Crop, modifier = Modifier diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt index fc580c4..0bff195 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt @@ -55,7 +55,7 @@ fun PetCardBig( ) { Column { KamelImage( - resource = asyncPainterResource(data = item.images[0]), + resource = { asyncPainterResource(data = item.images[0]) }, contentDescription = "image", contentScale = ContentScale.Crop, modifier = Modifier diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt index 3d2ae1f..e93fc04 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt @@ -56,7 +56,7 @@ fun PetCardSmall( ) { Column { KamelImage( - resource = asyncPainterResource(data = item.images[0]), + resource = { asyncPainterResource(data = item.images[0]) }, contentDescription = "image", contentScale = ContentScale.Crop, modifier = Modifier diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt index 38d9099..5f28071 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt @@ -88,7 +88,7 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { .background(MaterialTheme.colorScheme.background) ) { KamelImage( - resource = asyncPainterResource(data = petModel.images[0]), + resource = { asyncPainterResource(data = petModel.images[0]) }, contentDescription = petModel.title, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().height(250.dp), diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt index cf6933d..a35c10b 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt @@ -116,7 +116,7 @@ class ProfileTabScreen : Screen { ) } else { KamelImage( - resource = asyncPainterResource(data = state.image), + resource = { asyncPainterResource(data = state.image) }, contentDescription = "profile image", contentScale = ContentScale.Crop, modifier = Modifier From ed735be416c5497f5dad402658e5d9bdf371f6fa Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Fri, 5 Jun 2026 17:07:06 +0200 Subject: [PATCH 03/18] Corrected Image loading --- .../io/kamel/image/config/LocalKamelConfig.kt | 14 ++++++++++ composeApp/build.gradle.kts | 4 +-- .../app/android/MainActivity.kt | 26 +++++++++++++++---- composeApp/src/commonMain/kotlin/App.kt | 2 +- gradle/libs.versions.toml | 3 +-- shared/build.gradle.kts | 1 + .../app/feature/debugmenu/Debug.android.kt | 2 +- .../multiplatformkickstarter/app/MainApp.kt | 19 ++++++++++++-- 8 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 commonMain/io/kamel/image/config/LocalKamelConfig.kt diff --git a/commonMain/io/kamel/image/config/LocalKamelConfig.kt b/commonMain/io/kamel/image/config/LocalKamelConfig.kt new file mode 100644 index 0000000..27c25ae --- /dev/null +++ b/commonMain/io/kamel/image/config/LocalKamelConfig.kt @@ -0,0 +1,14 @@ +package io.kamel.image.config + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import io.kamel.core.config.Core +import io.kamel.core.config.KamelConfig + +/** + * Static CompositionLocal that provides the default configuration of [KamelConfig]. + */ +public val LocalKamelConfig: ProvidableCompositionLocal = + staticCompositionLocalOf { detectedKamelConfig ?: KamelConfig.Core } + +public expect var detectedKamelConfig: KamelConfig? \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9d0509a..ec8ef74 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -88,6 +88,7 @@ kotlin { // Kamel for image loading implementation(libs.kamel) + implementation(libs.kamel.decoder.image.bitmap) // Voyager for Navigation implementation(libs.voyager.navigator) @@ -160,8 +161,6 @@ android { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.activity.compose) - implementation(libs.accompanist.systemuicontroller) - implementation(libs.koin.core) implementation(libs.koin.android) @@ -214,6 +213,7 @@ compose.desktop { // Kamel for image loading implementation(libs.kamel) + implementation(libs.kamel.decoder.image.bitmap) // Voyager for Navigation implementation(libs.voyager.navigator) diff --git a/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/MainActivity.kt index 0f6e08c..660a016 100644 --- a/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/multiplatformkickstarter/app/android/MainActivity.kt @@ -2,13 +2,14 @@ package com.multiplatformkickstarter.app.android import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.multiplatformkickstarter.app.MainApp import com.multiplatformkickstarter.app.common.model.theme.DarkThemeConfig import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme @@ -20,12 +21,27 @@ class MainActivity : ComponentActivity() { val uiState: MainActivityUiState by mutableStateOf(MainActivityUiState.Loading) setContent { - val systemUiController = rememberSystemUiController() val darkTheme = shouldUseDarkTheme(uiState) - // Update the dark content of the system bars to match the theme - DisposableEffect(systemUiController, darkTheme) { - systemUiController.systemBarsDarkContentEnabled = !darkTheme + DisposableEffect(darkTheme) { + enableEdgeToEdge( + statusBarStyle = if (!darkTheme) { + SystemBarStyle.light( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ) + } else { + SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + }, + navigationBarStyle = if (!darkTheme) { + SystemBarStyle.light( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ) + } else { + SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + }, + ) onDispose {} } diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index f7e5acd..0aa761d 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -1,6 +1,6 @@ import androidx.compose.runtime.Composable import com.multiplatformkickstarter.app.MainApp -import org.jetbrains.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.Preview @Suppress("FunctionName") @Composable diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85ea846..a6a5d72 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,6 @@ detekt = "1.23.8" kotlinx-coroutines = "1.11.0" androidx-junit = "1.3.0" espressoCore = "3.7.0" -accompanistSystemuicontroller = "0.36.0" activityCompose = "1.13.0" firebase = "34.14.0" touchlab = "2.1.0" @@ -36,6 +35,7 @@ skiko = { module = "org.jetbrains.skiko:skiko", version.ref = "skiko" } skiko-macos-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-macos-arm64", version.ref = "skiko" } ktor-client = { module = "io.ktor:ktor-client-core", version.ref = "ktor-client" } kamel = "media.kamel:kamel-image:1.0.9" +kamel-decoder-image-bitmap = "media.kamel:kamel-decoder-image-bitmap:1.0.9" kermit = "co.touchlab:kermit:2.1.0" kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0" @@ -59,7 +59,6 @@ koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } androidx-core-ktx = "androidx.core:core-ktx:1.19.0" androidx-core-splashscreen = "androidx.core:core-splashscreen:1.0.1" androidx-tracing-ktx = "androidx.tracing:tracing-ktx:1.3.0" -accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index df2bc40..d2803a8 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -70,6 +70,7 @@ kotlin { // Kamel for image loading implementation(libs.kamel) + implementation(libs.kamel.decoder.image.bitmap) // Voyager for Navigation implementation(libs.voyager.navigator) diff --git a/shared/src/androidMain/kotlin/com/multiplatformkickstarter/app/feature/debugmenu/Debug.android.kt b/shared/src/androidMain/kotlin/com/multiplatformkickstarter/app/feature/debugmenu/Debug.android.kt index a879930..9dd11d1 100644 --- a/shared/src/androidMain/kotlin/com/multiplatformkickstarter/app/feature/debugmenu/Debug.android.kt +++ b/shared/src/androidMain/kotlin/com/multiplatformkickstarter/app/feature/debugmenu/Debug.android.kt @@ -1,7 +1,7 @@ package com.multiplatformkickstarter.app.feature.debugmenu class AndroidDebug : Debug { - override val isDebug: Boolean = false + override val isDebug: Boolean = true } actual fun getDebug(): Debug = AndroidDebug() diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/MainApp.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/MainApp.kt index 179d1c9..ab0c0fc 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/MainApp.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/MainApp.kt @@ -1,14 +1,29 @@ package com.multiplatformkickstarter.app import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.ScaleTransition import com.multiplatformkickstarter.app.ui.screens.MainScreen +import io.kamel.core.config.Core +import io.kamel.core.config.KamelConfig +import io.kamel.core.config.takeFrom +import io.kamel.image.config.LocalKamelConfig +import io.kamel.image.config.imageBitmapDecoder @Suppress("FunctionName") @Composable fun MainApp() { - Navigator(MainScreen()) { - ScaleTransition(it) + val kamelConfig = remember { + KamelConfig { + takeFrom(KamelConfig.Core) + imageBitmapDecoder() + } + } + CompositionLocalProvider(LocalKamelConfig provides kamelConfig) { + Navigator(MainScreen()) { + ScaleTransition(it) + } } } From 997e79c306e5d2090c6b86435cd08dc22aa214d0 Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Fri, 5 Jun 2026 17:35:52 +0200 Subject: [PATCH 04/18] Added favorites feature --- .../app/di/CommonModule.kt | 16 +- .../app/feature/favorites/FavoritesScreen.kt | 190 ++++++++++++++++++ .../repositories/FavoritesRepository.kt | 30 +++ .../viewmodels/FavoritesViewModel.kt | 45 +++++ .../app/feature/inbox/InboxScreen.kt | 53 +++++ .../app/localization/Localization.kt | 10 +- .../translations/DeutschLocalization.kt | 22 +- .../translations/EnglishLocalization.kt | 22 +- .../translations/FrenchLocalization.kt | 22 +- .../translations/ItalianLocalization.kt | 22 +- .../translations/SpanishLocalization.kt | 22 +- .../app/navigation/FavoritesTab.kt | 8 +- .../app/navigation/InboxTab.kt | 10 +- .../app/ui/components/PetCardBig.kt | 84 +++++--- .../app/ui/components/PetCardSmall.kt | 84 +++++--- .../ui/icon/MultiplatformKickstarterIcons.kt | 2 + .../app/ui/screens/HomeTabScreen.kt | 19 +- .../app/ui/screens/OnboardingScreen.kt | 20 +- .../app/ui/screens/PetDetailScreen.kt | 78 ++++--- .../ui/screens/ProTemplateFeatureScreen.kt | 69 ------- .../app/ui/screens/SearchListingScreen.kt | 6 +- .../screens/viewmodel/HomeScreenViewModel.kt | 16 ++ .../screens/viewmodel/PetDetailViewModel.kt | 42 ++++ .../viewmodel/SearchListingViewModel.kt | 19 +- 24 files changed, 681 insertions(+), 230 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/repositories/FavoritesRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/viewmodels/FavoritesViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt delete mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProTemplateFeatureScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt index 70f674f..ed15ace 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt @@ -13,6 +13,9 @@ import com.multiplatformkickstarter.app.data.usecases.GetNearMeAdsUseCase import com.multiplatformkickstarter.app.data.usecases.GetSearchUseCase import com.multiplatformkickstarter.app.feature.debugmenu.repositories.GlobalAppSettingsRepository import com.multiplatformkickstarter.app.feature.debugmenu.viewmodel.DebugMenuViewModel +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository +import com.multiplatformkickstarter.app.feature.favorites.viewmodels.FavoritesViewModel +import com.multiplatformkickstarter.app.ui.screens.viewmodel.PetDetailViewModel import com.multiplatformkickstarter.app.feature.petupload.repositories.PetUploadPublishRepository import com.multiplatformkickstarter.app.feature.petupload.usecases.PetUploadUseCase import com.multiplatformkickstarter.app.feature.petupload.viewmodel.PetUploadViewModel @@ -34,7 +37,7 @@ import org.koin.dsl.module val commonModule = module { factory { (navigator: Navigator) -> - HomeScreenViewModel(navigator, get(), get(), get(), get(), get(), get(), get(), get(), get()) + HomeScreenViewModel(navigator, get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } factory { PetUploadViewModel(get(), get(), get(), get()) @@ -48,7 +51,15 @@ val commonModule = module { } factory { (searchId: Int?, petCategory: PetCategory?, navigator: Navigator) -> - SearchListingViewModel(searchId, petCategory, get(), navigator) + SearchListingViewModel(searchId, petCategory, get(), navigator, get()) + } + + factory { (navigator: Navigator) -> + FavoritesViewModel(navigator, get()) + } + + factory { (petId: Int) -> + PetDetailViewModel(petId, get(), get()) } factory { @@ -77,6 +88,7 @@ val commonModule = module { singleOf(::PetsFromSearchRepository) factoryOf(::GetLastSearchUseCase) + singleOf(::FavoritesRepository) singleOf(::SessionRepository) singleOf(::ProfileRepository) singleOf(::Settings) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt new file mode 100644 index 0000000..19dc447 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt @@ -0,0 +1,190 @@ +package com.multiplatformkickstarter.app.feature.favorites + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.feature.favorites.viewmodels.FavoritesViewModel +import com.multiplatformkickstarter.app.localization.getCurrentLocalization +import com.multiplatformkickstarter.app.ui.components.EmptyLayout +import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons +import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme +import com.multiplatformkickstarter.app.ui.theme.Typography +import io.kamel.image.KamelImage +import io.kamel.image.asyncPainterResource +import org.koin.core.parameter.ParametersHolder + +@OptIn(ExperimentalMaterial3Api::class) +class FavoritesScreen : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val localization = getCurrentLocalization() + val viewModel = koinScreenModel( + parameters = { ParametersHolder(listOf(navigator).toMutableList(), false) } + ) + + DisposableEffect(key) { + viewModel.onStarted(navigator) + onDispose {} + } + + val state by viewModel.state.collectAsState() + + MultiplatformKickstarterTheme { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = localization.favorites, + style = Typography.get().headlineSmall, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + ) { paddingValues -> + if (state.favorites.isEmpty()) { + Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + EmptyLayout( + title = localization.favoritesEmptyTitle, + description = localization.favoritesEmptyDescription, + localization = localization, + ) {} + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + items(state.favorites, key = { it.id }) { pet -> + FavoriteItem( + pet = pet, + onPetClicked = { viewModel.onPetDetailClicked(pet.id) }, + onUnfavoriteClicked = { viewModel.onUnfavoriteClicked(pet.id) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun FavoriteItem( + pet: PetModel, + onPetClicked: () -> Unit, + onUnfavoriteClicked: () -> Unit, +) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onPetClicked() }, + headlineContent = { + Text( + text = pet.title, + style = Typography.get().titleMedium, + ) + }, + supportingContent = { + Column { + Text( + text = pet.description, + style = Typography.get().bodySmall, + maxLines = 2, + ) + Text( + text = pet.breed, + style = Typography.get().labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + }, + leadingContent = { + KamelImage( + resource = { asyncPainterResource(data = pet.images[0]) }, + contentDescription = pet.title, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)), + onLoading = { + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + ) + }, + onFailure = { + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + ) { + Icon( + imageVector = MultiplatformKickstarterIcons.Pets, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(12.dp), + ) + } + }, + ) + }, + trailingContent = { + IconButton(onClick = onUnfavoriteClicked) { + Icon( + imageVector = MultiplatformKickstarterIcons.Favorite, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/repositories/FavoritesRepository.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/repositories/FavoritesRepository.kt new file mode 100644 index 0000000..b12aa09 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/repositories/FavoritesRepository.kt @@ -0,0 +1,30 @@ +package com.multiplatformkickstarter.app.feature.favorites.repositories + +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.data.repositories.LastSearchAdsMockRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FavoritesRepository( + private val lastSearchAdsMockRepository: LastSearchAdsMockRepository, +) { + private val _favoriteIds = MutableStateFlow>(emptySet()) + val favoriteIds: StateFlow> = _favoriteIds.asStateFlow() + + fun isFavorite(petId: Int): Boolean = _favoriteIds.value.contains(petId) + + fun toggleFavorite(petId: Int) { + val current = _favoriteIds.value + _favoriteIds.value = if (current.contains(petId)) { + current - petId + } else { + current + petId + } + } + + fun getFavoritePets(): List { + val allPets = lastSearchAdsMockRepository.getAds().getOrNull() ?: emptyList() + return allPets.filter { _favoriteIds.value.contains(it.id) } + } +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/viewmodels/FavoritesViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/viewmodels/FavoritesViewModel.kt new file mode 100644 index 0000000..35d5c36 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/viewmodels/FavoritesViewModel.kt @@ -0,0 +1,45 @@ +package com.multiplatformkickstarter.app.feature.favorites.viewmodels + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository +import com.multiplatformkickstarter.app.ui.screens.PetDetailScreen +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class FavoritesViewModel( + private var navigator: Navigator, + private val favoritesRepository: FavoritesRepository, +) : ScreenModel { + + private val _state = MutableStateFlow(FavoritesState(favorites = emptyList())) + val state: StateFlow = _state.asStateFlow() + + init { + favoritesRepository.favoriteIds + .onEach { _state.value = _state.value.copy(favorites = favoritesRepository.getFavoritePets()) } + .launchIn(screenModelScope) + } + + fun onStarted(navigator: Navigator) { + this.navigator = navigator + _state.value = _state.value.copy(favorites = favoritesRepository.getFavoritePets()) + } + + fun onPetDetailClicked(petId: Int) { + navigator.push(PetDetailScreen(petId)) + } + + fun onUnfavoriteClicked(petId: Int) { + favoritesRepository.toggleFavorite(petId) + } +} + +data class FavoritesState( + val favorites: List, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt new file mode 100644 index 0000000..e8d710c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt @@ -0,0 +1,53 @@ +package com.multiplatformkickstarter.app.feature.inbox + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.screen.Screen +import com.multiplatformkickstarter.app.localization.getCurrentLocalization +import com.multiplatformkickstarter.app.ui.components.EmptyLayout +import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme +import com.multiplatformkickstarter.app.ui.theme.Typography + +@OptIn(ExperimentalMaterial3Api::class) +class InboxScreen : Screen { + + @Composable + override fun Content() { + val localization = getCurrentLocalization() + + MultiplatformKickstarterTheme { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = localization.inbox, + style = Typography.get().headlineSmall, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + EmptyLayout( + title = localization.inboxEmptyTitle, + description = localization.inboxEmptyDescription, + localization = localization, + ) {} + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt index 4de3e75..2dc39d9 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt @@ -60,8 +60,14 @@ interface Localization { val start: String val insertAdBannerTrait: String - val proFeatureScreenTitle: String - val proFeatureScreenDescription: String + val favoritesEmptyTitle: String + val favoritesEmptyDescription: String + + val inboxEmptyTitle: String + val inboxEmptyDescription: String + + val detailFavorite: String + val detailUnfavorite: String val homeFeaturedViewAll: String val homePetsNearYou: String val homeMyLastSearch: String diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt index 7d99951..79144e4 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt @@ -55,8 +55,14 @@ internal object DeutschLocalization : Localization { override val start = "LASST UNS BEGINNEN!" override val insertAdBannerTrait = "Lassen Sie uns Ihnen helfen, eine Familie für Ihr Haustier zu finden. Wenn Ihre Katze gerade kleine Welpen bekommen hat, können Sie unsere Plattform nutzen, um die richtige Person für die Adoption zu finden. Es ist wirklich einfach!" - override val proFeatureScreenTitle = "Bildschirm im Aufbau" - override val proFeatureScreenDescription = "Dieser Bildschirm ist noch im Aufbau. Diese Vorlage hilft Ihnen dabei, Multiplattform-Apps im Handumdrehen zu erstellen." + override val favoritesEmptyTitle = "Noch keine Favoriten" + override val favoritesEmptyDescription = "Tiere, die Sie als Favoriten markieren, werden hier angezeigt. Tippen Sie auf das Herz bei einem Tier, um es zu speichern." + + override val inboxEmptyTitle = "Noch keine Nachrichten" + override val inboxEmptyDescription = "Wenn Sie Kontakt mit einem Tierbesitzer aufnehmen, erscheinen Ihre Gespräche hier." + + override val detailFavorite = "Zu Favoriten hinzufügen" + override val detailUnfavorite = "Aus Favoriten entfernen" override val homeFeaturedViewAll = "Alle anzeigen >" override val homePetsNearYou = "Haustiere in Ihrer Nähe" override val homeMyLastSearch = "Meine letzte Suche" @@ -145,11 +151,11 @@ internal object DeutschLocalization : Localization { override val debugMenuTitle = "Debug-Menü" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Merkmale" - override val onboardingPromoTitle3 = "PRO Template" + override val onboardingPromoTitle1 = "Willkommen bei Adoptme" + override val onboardingPromoTitle2 = "Finde dein Tier" + override val onboardingPromoTitle3 = "Gib ein Zuhause" - override val onboardingPromoLine1 = "Willkommen zu Multiplatform Kickstarter! Dies ist die Onboarding-Komponente, die alle Funktionen dieser Anwendung vorstellt." - override val onboardingPromoLine2 = "Diese Vorlage enthält alles, was Sie benötigen, um Ihre eigenen Anwendungen im Handumdrehen zu starten. Von der Navigation über die Abhängigkeitsinjektion bis zur Server-API." - override val onboardingPromoLine3 = "Wenn Sie mehr benötigen, wie gespeicherte Suchen, Geolokalisierung, Follower, Bewertungen oder einen Marktplatz, müssen Sie nur unsere Pro-Vorlage herunterladen, die Sie unter multiplatformkickstarter.com finden." + override val onboardingPromoLine1 = "! Die App, die dir hilft, das perfekte Tier zur Adoption zu finden oder dein Tier mit einer liebevollen Familie zu verbinden." + override val onboardingPromoLine2 = "Durchsuche Hunderte von Tieren in deiner Nähe, filtere nach Kategorie, Alter, Größe und mehr. Speichere deine Favoriten und kontaktiere Tierheime und Besitzer direkt." + override val onboardingPromoLine3 = "Jedes Tier verdient ein liebevolles Zuhause. Starte heute deine Adoptionsreise und mache einen Unterschied im Leben eines Tieres." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt index eba0804..a00b067 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt @@ -55,8 +55,14 @@ internal object EnglishLocalization : Localization { override val start = "LET\'S START!" override val insertAdBannerTrait = "Let us help you find a family for your pet. If your cat have just got little puppies you can use our platform to find the right person to adopt them. It\'s really easy!" - override val proFeatureScreenTitle = "PRO Template Feature" - override val proFeatureScreenDescription = "This screen is available on the **Pro Template**. Find more information about the features at " + override val favoritesEmptyTitle = "No favorites yet" + override val favoritesEmptyDescription = "Pets you favorite will appear here. Tap the heart on any pet to save it." + + override val inboxEmptyTitle = "No messages yet" + override val inboxEmptyDescription = "When you contact a pet owner, your conversations will appear here." + + override val detailFavorite = "Add to favorites" + override val detailUnfavorite = "Remove from favorites" override val homeFeaturedViewAll = "See all >" override val homePetsNearYou = "Pets near you" override val homeMyLastSearch = "My last search" @@ -145,11 +151,11 @@ internal object EnglishLocalization : Localization { override val debugMenuTitle = "Debug Menu" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Features" - override val onboardingPromoTitle3 = "PRO Template" + override val onboardingPromoTitle1 = "Welcome to Adoptme" + override val onboardingPromoTitle2 = "Find your pet" + override val onboardingPromoTitle3 = "Give a home" - override val onboardingPromoLine1 = "! This is the onboarding component that showcases all the features you'll find in this application." - override val onboardingPromoLine2 = "This template has all the needed to kickstart your own applications for Android, iOS and Desktop in no time. From Navigation, Compose components to Dependency Injection and even a Server with Login/SignUp and CRUD API built in." - override val onboardingPromoLine3 = "If you need more features like Saved Searches, Geolocation, Followers, Rating or Marketplace functionality you only need to download our **Pro Template** you'll find at " + override val onboardingPromoLine1 = "! The app that helps you find the perfect pet to adopt or connect your pet with a loving family." + override val onboardingPromoLine2 = "Browse hundreds of pets near you, filter by category, age, size and more. Save your favorites and get in touch with shelters and owners directly." + override val onboardingPromoLine3 = "Every pet deserves a loving home. Start your adoption journey today and make a difference in a pet's life." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt index 4002139..01f1554 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt @@ -55,8 +55,14 @@ internal object FrenchLocalization : Localization { override val start = "COMMENÇONS !" override val insertAdBannerTrait = "Laissez-nous vous aider à trouver une famille pour votre animal de compagnie. Si votre chat vient d'avoir des petits, vous pouvez utiliser notre plateforme pour trouver la bonne personne pour les adopter. C'est vraiment facile !" - override val proFeatureScreenTitle = "Écran en construction" - override val proFeatureScreenDescription = "Cet écran est encore en construction. Ce modèle vous aidera à créer des applications multiplateformes en un rien de temps." + override val favoritesEmptyTitle = "Pas encore de favoris" + override val favoritesEmptyDescription = "Les animaux que vous marquez en favori apparaîtront ici. Appuyez sur le cœur pour enregistrer un animal." + + override val inboxEmptyTitle = "Pas encore de messages" + override val inboxEmptyDescription = "Lorsque vous contactez un propriétaire d'animal, vos conversations apparaîtront ici." + + override val detailFavorite = "Ajouter aux favoris" + override val detailUnfavorite = "Retirer des favoris" override val homeFeaturedViewAll = "Voir tout >" override val homePetsNearYou = "Animaux près de chez vous" override val homeMyLastSearch = "Ma dernière recherche" @@ -145,11 +151,11 @@ internal object FrenchLocalization : Localization { override val debugMenuTitle = "Menu de Débogage" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Caractéristiques" - override val onboardingPromoTitle3 = "Pro Template" + override val onboardingPromoTitle1 = "Bienvenue sur Adoptme" + override val onboardingPromoTitle2 = "Trouvez votre animal" + override val onboardingPromoTitle3 = "Offrez un foyer" - override val onboardingPromoLine1 = "Bienvenue à Multiplatform Kickstarter! Il s'agit du composant d'intégration qui présentera toutes les fonctionnalités que vous trouverez dans cette application." - override val onboardingPromoLine2 = "Ce modèle contient tout le nécessaire pour démarrer vos propres applications en un rien de temps. De la navigation à l’injection de dépendances vers l’API serveur." - override val onboardingPromoLine3 = "Si vous avez besoin de plus, comme des recherches enregistrées, la géolocalisation, les abonnés, l'évaluation ou une place de marché, il vous suffit de télécharger notre modèle Pro que vous trouverez sur multiplatformkickstarter.com" + override val onboardingPromoLine1 = "! L’application qui vous aide à trouver l’animal de compagnie idéal à adopter ou à connecter votre animal à une famille aimante." + override val onboardingPromoLine2 = "Parcourez des centaines d’animaux près de chez vous, filtrez par catégorie, âge, taille et plus encore. Enregistrez vos favoris et contactez directement les refuges et propriétaires." + override val onboardingPromoLine3 = "Chaque animal mérite un foyer aimant. Commencez votre parcours d’adoption aujourd’hui et faites la différence dans la vie d’un animal." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt index c152eee..881e56d 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt @@ -55,8 +55,14 @@ internal object ItalianLocalization : Localization { override val start = "INIZIAMO!" override val insertAdBannerTrait = "Facciamo in modo di aiutarti a trovare una famiglia per il tuo animale domestico. Se il tuo gatto ha appena avuto cuccioli, puoi utilizzare la nostra piattaforma per trovare la persona giusta per adottarli. È davvero facile!" - override val proFeatureScreenTitle = "Schermo in costruzione" - override val proFeatureScreenDescription = "Questo schermo è ancora in costruzione. Questo modello ti aiuterà a creare app multi-piattaforma in poco tempo." + override val favoritesEmptyTitle = "Nessun preferito ancora" + override val favoritesEmptyDescription = "Gli animali che aggiungi ai preferiti appariranno qui. Tocca il cuore su qualsiasi animale per salvarlo." + + override val inboxEmptyTitle = "Nessun messaggio ancora" + override val inboxEmptyDescription = "Quando contatti il proprietario di un animale, le conversazioni appariranno qui." + + override val detailFavorite = "Aggiungi ai preferiti" + override val detailUnfavorite = "Rimuovi dai preferiti" override val homeFeaturedViewAll = "Vedi tutto >" override val homePetsNearYou = "Animali domestici vicino a te" override val homeMyLastSearch = "La mia ultima ricerca" @@ -145,11 +151,11 @@ internal object ItalianLocalization : Localization { override val debugMenuTitle = "Menu di debug" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Caratteristiche" - override val onboardingPromoTitle3 = "Pro Template" + override val onboardingPromoTitle1 = "Benvenuto su Adoptme" + override val onboardingPromoTitle2 = "Trova il tuo animale" + override val onboardingPromoTitle3 = "Dai una casa" - override val onboardingPromoLine1 = "Benvenuto a Multiplatform Kickstarter! Questo è il componente di onboarding che mostrerà tutte le funzionalità che troverai in questa applicazione." - override val onboardingPromoLine2 = "Questo modello ha tutto il necessario per avviare le tue applicazioni in pochissimo tempo. Dalla navigazione all'inserimento delle dipendenze all'API del server." - override val onboardingPromoLine3 = "Se hai bisogno di altro, come ricerche salvate, geolocalizzazione, follower, valutazione o un marketplace, devi solo scaricare il nostro modello Pro che troverai su multiplatformkickstarter.com" + override val onboardingPromoLine1 = "! L'app che ti aiuta a trovare l'animale domestico perfetto da adottare o a connettere il tuo animale con una famiglia amorevole." + override val onboardingPromoLine2 = "Sfoglia centinaia di animali vicino a te, filtra per categoria, età, taglia e altro ancora. Salva i tuoi preferiti e contatta direttamente rifugi e proprietari." + override val onboardingPromoLine3 = "Ogni animale merita una casa piena d'amore. Inizia il tuo percorso di adozione oggi e fai la differenza nella vita di un animale." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt index ce9ca82..fa90990 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt @@ -55,8 +55,14 @@ internal object SpanishLocalization : Localization { override val start = "¡EMPECEMOS!" override val insertAdBannerTrait = "Permítenos ayudarte a encontrar una familia para tu mascota. Si tu gato acaba de tener cachorros, puedes usar nuestra plataforma para encontrar a la persona adecuada para adoptarlos. ¡Es muy fácil!" - override val proFeatureScreenTitle = "Pantalla en construcción" - override val proFeatureScreenDescription = "Esta pantalla aún está en construcción. Esta plantilla te ayudará a crear aplicaciones multiplataforma en poco tiempo." + override val favoritesEmptyTitle = "Aún no tienes favoritos" + override val favoritesEmptyDescription = "Las mascotas que marques como favoritas aparecerán aquí. Toca el corazón en cualquier mascota para guardarla." + + override val inboxEmptyTitle = "Aún no tienes mensajes" + override val inboxEmptyDescription = "Cuando contactes con el dueño de una mascota, tus conversaciones aparecerán aquí." + + override val detailFavorite = "Añadir a favoritos" + override val detailUnfavorite = "Eliminar de favoritos" override val homeFeaturedViewAll = "Ver todo >" override val homePetsNearYou = "Mascotas cerca de ti" override val homeMyLastSearch = "Mi última búsqueda" @@ -145,11 +151,11 @@ internal object SpanishLocalization : Localization { override val debugMenuTitle = "Menú de Depuración" - override val onboardingPromoTitle1 = "Multiplatform Kickstarter" - override val onboardingPromoTitle2 = "Funcionalidades" - override val onboardingPromoTitle3 = "Pro Template" + override val onboardingPromoTitle1 = "Bienvenido a Adoptme" + override val onboardingPromoTitle2 = "Encuentra tu mascota" + override val onboardingPromoTitle3 = "Dale un hogar" - override val onboardingPromoLine1 = "Bienvenido a Multiplatform Kickstarter! Este es el componente de Onboarding que mostrará todas las funciones que encontrará en esta aplicación." - override val onboardingPromoLine2 = "Esta plantilla tiene todo lo necesario para iniciar sus propias aplicaciones en poco tiempo. De la navegación a la inyección de dependencias y a la API del servidor." - override val onboardingPromoLine3 = "Si necesita más, como búsquedas guardadas, geolocalización, seguidores, calificación o un mercado, solo necesita descargar nuestra plantilla Pro que encontrará en multiplatformkickstarter.com" + override val onboardingPromoLine1 = "! La app que te ayuda a encontrar la mascota perfecta para adoptar o a conectar tu mascota con una familia amorosa." + override val onboardingPromoLine2 = "Explora cientos de mascotas cerca de ti, filtra por categoría, edad, tamaño y mucho más. Guarda tus favoritos y contacta directamente con refugios y propietarios." + override val onboardingPromoLine3 = "Cada mascota merece un hogar lleno de amor. Empieza tu proceso de adopción hoy y marca la diferencia en la vida de una mascota." } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/FavoritesTab.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/FavoritesTab.kt index 8aac048..e27a4f1 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/FavoritesTab.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/FavoritesTab.kt @@ -3,11 +3,13 @@ package com.multiplatformkickstarter.app.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.rememberVectorPainter +import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions +import cafe.adriel.voyager.transitions.ScaleTransition +import com.multiplatformkickstarter.app.feature.favorites.FavoritesScreen import com.multiplatformkickstarter.app.localization.getCurrentLocalization import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons -import com.multiplatformkickstarter.app.ui.screens.ProTemplateFeature internal object FavoritesTab : Tab { override val options: TabOptions @@ -26,6 +28,8 @@ internal object FavoritesTab : Tab { @Composable override fun Content() { - ProTemplateFeature() + Navigator(FavoritesScreen()) { + ScaleTransition(it) + } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/InboxTab.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/InboxTab.kt index e8fc77e..b1f25f2 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/InboxTab.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/navigation/InboxTab.kt @@ -3,11 +3,13 @@ package com.multiplatformkickstarter.app.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.rememberVectorPainter +import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions +import cafe.adriel.voyager.transitions.ScaleTransition +import com.multiplatformkickstarter.app.feature.inbox.InboxScreen import com.multiplatformkickstarter.app.localization.getCurrentLocalization import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons -import com.multiplatformkickstarter.app.ui.screens.ProTemplateFeature internal object InboxTab : Tab { override val options: TabOptions @@ -19,13 +21,15 @@ internal object InboxTab : Tab { TabOptions( index = 3u, title = getCurrentLocalization().inbox, - icon = icon + icon = icon, ) } } @Composable override fun Content() { - ProTemplateFeature() + Navigator(InboxScreen()) { + ScaleTransition(it) + } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt index 0bff195..c13831c 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardBig.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,6 +37,8 @@ import io.kamel.image.asyncPainterResource @Composable fun PetCardBig( item: PetModel, + isFavorite: Boolean = false, + onFavoriteToggled: (() -> Unit)? = null, onClick: () -> Unit, ) { val imageHeight = 120.dp @@ -54,42 +57,61 @@ fun PetCardBig( interactionSource = remember { MutableInteractionSource() }, ) { Column { - KamelImage( - resource = { asyncPainterResource(data = item.images[0]) }, - contentDescription = "image", - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(roundedCornerSize)) - .height(imageHeight), - onLoading = { - Box( - modifier = Modifier - .clip(shape = RoundedCornerShape(roundedCornerSize)) - .background(color = MaterialTheme.colorScheme.primaryContainer) - .height(imageHeight) - .fillMaxWidth() - .shimmerLoadingAnimation(isLoadingCompleted = false), - ) - }, - onFailure = { - Box( - modifier = Modifier - .clip(shape = RoundedCornerShape(roundedCornerSize)) - .background(color = MaterialTheme.colorScheme.primaryContainer) - .height(imageHeight) - .fillMaxWidth(), - contentAlignment = Alignment.Center, + Box { + KamelImage( + resource = { asyncPainterResource(data = item.images[0]) }, + contentDescription = "image", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(roundedCornerSize)) + .height(imageHeight), + onLoading = { + Box( + modifier = Modifier + .clip(shape = RoundedCornerShape(roundedCornerSize)) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .height(imageHeight) + .fillMaxWidth() + .shimmerLoadingAnimation(isLoadingCompleted = false), + ) + }, + onFailure = { + Box( + modifier = Modifier + .clip(shape = RoundedCornerShape(roundedCornerSize)) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .height(imageHeight) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(48.dp), + imageVector = MultiplatformKickstarterIcons.BrokenImage, + contentDescription = "image", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + ) + if (onFavoriteToggled != null) { + IconButton( + onClick = onFavoriteToggled, + modifier = Modifier.align(Alignment.TopEnd).size(40.dp), ) { Icon( - modifier = Modifier.size(48.dp), - imageVector = MultiplatformKickstarterIcons.BrokenImage, - contentDescription = "image", - tint = MaterialTheme.colorScheme.onPrimaryContainer, + imageVector = if (isFavorite) { + MultiplatformKickstarterIcons.Favorite + } else { + MultiplatformKickstarterIcons.FavoriteOutlined + }, + contentDescription = null, + tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(22.dp), ) } } - ) + } Text( text = item.title, style = Typography.get().headlineMedium, diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt index e93fc04..868eccc 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/components/PetCardSmall.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,6 +40,8 @@ fun PetCardSmall( item: PetModel, width: Dp, height: Dp, + isFavorite: Boolean = false, + onFavoriteToggled: (() -> Unit)? = null, onClick: () -> Unit, ) { val imageHeight = 70.dp @@ -55,42 +58,61 @@ fun PetCardSmall( interactionSource = remember { MutableInteractionSource() }, ) { Column { - KamelImage( - resource = { asyncPainterResource(data = item.images[0]) }, - contentDescription = "image", - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(roundedCornerSize)) - .height(imageHeight), - onLoading = { - Box( - modifier = Modifier - .clip(shape = RoundedCornerShape(roundedCornerSize)) - .background(color = MaterialTheme.colorScheme.primaryContainer) - .height(imageHeight) - .fillMaxWidth() - .shimmerLoadingAnimation(isLoadingCompleted = false), - ) - }, - onFailure = { - Box( - modifier = Modifier - .clip(shape = RoundedCornerShape(roundedCornerSize)) - .background(color = MaterialTheme.colorScheme.primaryContainer) - .height(imageHeight) - .fillMaxWidth(), - contentAlignment = Alignment.Center, + Box { + KamelImage( + resource = { asyncPainterResource(data = item.images[0]) }, + contentDescription = "image", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(roundedCornerSize)) + .height(imageHeight), + onLoading = { + Box( + modifier = Modifier + .clip(shape = RoundedCornerShape(roundedCornerSize)) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .height(imageHeight) + .fillMaxWidth() + .shimmerLoadingAnimation(isLoadingCompleted = false), + ) + }, + onFailure = { + Box( + modifier = Modifier + .clip(shape = RoundedCornerShape(roundedCornerSize)) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .height(imageHeight) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(36.dp), + imageVector = MultiplatformKickstarterIcons.BrokenImage, + contentDescription = "image", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + ) + if (onFavoriteToggled != null) { + IconButton( + onClick = onFavoriteToggled, + modifier = Modifier.align(Alignment.TopEnd).size(32.dp), ) { Icon( - modifier = Modifier.size(36.dp), - imageVector = MultiplatformKickstarterIcons.BrokenImage, - contentDescription = "image", - tint = MaterialTheme.colorScheme.onPrimaryContainer, + imageVector = if (isFavorite) { + MultiplatformKickstarterIcons.Favorite + } else { + MultiplatformKickstarterIcons.FavoriteOutlined + }, + contentDescription = null, + tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), ) } } - ) + } Text( modifier = Modifier.padding(top = 6.dp), text = item.title, diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/icon/MultiplatformKickstarterIcons.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/icon/MultiplatformKickstarterIcons.kt index 8f21c1d..ec2a4e0 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/icon/MultiplatformKickstarterIcons.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/icon/MultiplatformKickstarterIcons.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.TextSnippet import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.BrokenImage +import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Create @@ -40,6 +41,7 @@ object MultiplatformKickstarterIcons { val Create = Icons.Rounded.Create val Inbox = Icons.Rounded.Email val Favorite = Icons.Rounded.Favorite + val FavoriteOutlined = Icons.Rounded.FavoriteBorder val Exit = Icons.AutoMirrored.Rounded.ExitToApp val ArrowRight = Icons.AutoMirrored.Rounded.KeyboardArrowRight val Email = Icons.Rounded.Email diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt index 8a38b4b..ef7945e 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt @@ -274,8 +274,15 @@ class HomeTabScreen : Screen { modifier = Modifier.fillMaxWidth() ) { items(state.lastSearchAds.size) { index -> - PetCardSmall(state.lastSearchAds[index], 160.dp, 180.dp) { - viewModel.onPetDetailClicked(state.lastSearchAds[index].id) + val pet = state.lastSearchAds[index] + PetCardSmall( + item = pet, + width = 160.dp, + height = 180.dp, + isFavorite = state.favoriteIds.contains(pet.id), + onFavoriteToggled = { viewModel.onFavoriteToggled(pet.id) }, + ) { + viewModel.onPetDetailClicked(pet.id) } if (index == state.lastSearchAds.size - 1) { Spacer(modifier = Modifier.size(16.dp)) @@ -304,7 +311,13 @@ class HomeTabScreen : Screen { maxItemsInEachRow = 2 ) { state.nearMeAds.map { item -> - PetCardSmall(item = item, itemSize, itemSize) { + PetCardSmall( + item = item, + width = itemSize, + height = itemSize, + isFavorite = state.favoriteIds.contains(item.id), + onFavoriteToggled = { viewModel.onFavoriteToggled(item.id) }, + ) { viewModel.onPetDetailClicked(item.id) } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/OnboardingScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/OnboardingScreen.kt index d7d20c0..106261b 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/OnboardingScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/OnboardingScreen.kt @@ -2,10 +2,11 @@ package com.multiplatformkickstarter.app.ui.screens import androidx.compose.runtime.Composable import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle + import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow @@ -30,14 +31,7 @@ class OnboardingScreen : Screen { append(localization.onboardingPromoTitle1) } } - val onboardingPromoLine1 = - buildAnnotatedString { - append("Welcome to ") - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("Multiplatform Kickstarter") - } - append(localization.onboardingPromoLine1) - } + val onboardingPromoLine1 = localization.onboardingPromoLine1.toAnnotatedString() val onboardingPromoTitle2 = buildAnnotatedString { @@ -53,13 +47,7 @@ class OnboardingScreen : Screen { } } - val onboardingPromoLine3 = - buildAnnotatedString { - append(localization.onboardingPromoLine3) - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("multiplatformkickstarter.com") - } - } + val onboardingPromoLine3 = localization.onboardingPromoLine3.toAnnotatedString() val carouselItems: List = listOf( diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt index 5f28071..5e4a33e 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt @@ -27,38 +27,57 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -import com.multiplatformkickstarter.app.common.model.PetModel -import com.multiplatformkickstarter.app.data.repositories.LastSearchAdsMockRepository import com.multiplatformkickstarter.app.localization.getCurrentLocalization import com.multiplatformkickstarter.app.platform.shimmerLoadingAnimation import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons +import com.multiplatformkickstarter.app.ui.screens.viewmodel.PetDetailViewModel import com.multiplatformkickstarter.app.ui.theme.Typography import io.kamel.image.KamelImage import io.kamel.image.asyncPainterResource +import org.koin.core.parameter.ParametersHolder class PetDetailScreen(private val petId: Int) : Screen { @Composable override fun Content() { val currentNavigator = LocalNavigator.currentOrThrow - val petModel = LastSearchAdsMockRepository().getAds().getOrNull()?.first { petId == it.id } - petModel?.let { - PetDetailView(it) { - currentNavigator.pop() - } + val viewModel = koinScreenModel( + parameters = { ParametersHolder(listOf(petId).toMutableList(), false) } + ) + val state by viewModel.state.collectAsState() + + state.pet?.let { pet -> + PetDetailView( + petTitle = pet.title, + petDescription = pet.description, + petImageUrl = pet.images[0], + isFavorite = state.isFavorite, + onFavoriteToggled = { viewModel.onFavoriteToggled() }, + onClose = { currentNavigator.pop() }, + ) } } } @Composable -fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { +fun PetDetailView( + petTitle: String, + petDescription: String, + petImageUrl: String, + isFavorite: Boolean, + onFavoriteToggled: () -> Unit, + onClose: () -> Unit, +) { val scrollState = rememberScrollState() val localization = getCurrentLocalization() @@ -74,6 +93,23 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { } }, title = {}, + actions = { + IconButton(onClick = onFavoriteToggled) { + Icon( + imageVector = if (isFavorite) { + MultiplatformKickstarterIcons.Favorite + } else { + MultiplatformKickstarterIcons.FavoriteOutlined + }, + contentDescription = null, + tint = if (isFavorite) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + }, colors = TopAppBarDefaults.mediumTopAppBarColors( containerColor = MaterialTheme.colorScheme.background ) @@ -88,8 +124,8 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { .background(MaterialTheme.colorScheme.background) ) { KamelImage( - resource = { asyncPainterResource(data = petModel.images[0]) }, - contentDescription = petModel.title, + resource = { asyncPainterResource(data = petImageUrl) }, + contentDescription = petTitle, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().height(250.dp), onLoading = { @@ -111,7 +147,7 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { ) { Icon( modifier = Modifier.size(64.dp), - imageVector = MultiplatformKickstarterIcons.Person, + imageVector = MultiplatformKickstarterIcons.BrokenImage, contentDescription = "image", tint = MaterialTheme.colorScheme.onPrimaryContainer, ) @@ -122,19 +158,18 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { modifier = Modifier.padding(16.dp) ) { Text( - text = petModel.title, + text = petTitle, style = Typography.get().headlineLarge, color = Color.DarkGray ) Text( - text = petModel.description, + text = petDescription, style = Typography.get().bodyMedium, color = Color.DarkGray ) } Button( - onClick = { - }, + onClick = {}, colors = ButtonDefaults.buttonColors(), modifier = Modifier .fillMaxWidth() @@ -152,16 +187,3 @@ fun PetDetailView(petModel: PetModel, onClose: () -> Unit) { } } } - -/* -@Preview(showBackground = true) -@Composable -fun PlanDetailPreview() { - val featuredAdsRepository = NearMeAdsMockRepository() - val planModel = featuredAdsRepository.getFeaturedAd("0") - - MyProjectNameTheme { - AdDetailView(planModel!!) {} - } -} - */ diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProTemplateFeatureScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProTemplateFeatureScreen.kt deleted file mode 100644 index 2b59eff..0000000 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProTemplateFeatureScreen.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.multiplatformkickstarter.app.ui.screens - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -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.layout.ContentScale -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import com.multiplatformkickstarter.app.localization.getCurrentLocalization -import com.multiplatformkickstarter.app.resources.Res -import com.multiplatformkickstarter.app.resources.mklogo -import org.jetbrains.compose.resources.vectorResource - -@Suppress("FunctionName") -@Composable -fun ProTemplateFeature( - modifier: Modifier = Modifier, -) { - val localization = getCurrentLocalization() - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - imageVector = vectorResource(Res.drawable.mklogo), - "", - modifier = Modifier - .fillMaxHeight(0.4f) - .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp), - contentScale = ContentScale.FillWidth, - ) - Text( - modifier = Modifier.padding(16.dp), - text = localization.proFeatureScreenTitle, - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary, - ) - val proFeaturesDescription = - buildAnnotatedString { - append(localization.proFeatureScreenDescription) - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("multiplatformkickstarter.com") - } - } - Text( - modifier = Modifier.padding(horizontal = 24.dp), - text = proFeaturesDescription, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.outline, - ) - } -} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt index 28b8326..23c9f7b 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt @@ -158,7 +158,11 @@ class SearchListingScreen( ) { if (state.pets.isNotEmpty()) { state.pets.map { petModel -> - PetCardBig(item = petModel) { + PetCardBig( + item = petModel, + isFavorite = state.favoriteIds.contains(petModel.id), + onFavoriteToggled = { viewModel.onFavoriteToggled(petModel.id) }, + ) { viewModel.onPetClicked(petModel.id) } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/HomeScreenViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/HomeScreenViewModel.kt index fd3a04f..28df433 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/HomeScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/HomeScreenViewModel.kt @@ -12,6 +12,7 @@ import com.multiplatformkickstarter.app.data.repositories.ProfileRepository import com.multiplatformkickstarter.app.data.repositories.SessionRepository import com.multiplatformkickstarter.app.data.usecases.GetLastSearchUseCase import com.multiplatformkickstarter.app.data.usecases.GetNearMeAdsUseCase +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository import com.multiplatformkickstarter.app.feature.debugmenu.DebugMenuScreen import com.multiplatformkickstarter.app.feature.debugmenu.getDebug import com.multiplatformkickstarter.app.feature.debugmenu.repositories.GlobalAppSettingsRepository @@ -30,6 +31,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone @@ -50,6 +53,7 @@ class HomeScreenViewModel( private val lastSearchesRepository: LastSearchesRepository, private val globalAppSettingsRepository: GlobalAppSettingsRepository, private val settings: Settings, + private val favoritesRepository: FavoritesRepository, ) : ScreenModel { private val _sideEffects = Channel() val sideEffects: Flow = _sideEffects.receiveAsFlow() @@ -62,11 +66,18 @@ class HomeScreenViewModel( nearMeAds = emptyList(), lastSearchAds = emptyList(), currentLanguage = AvailableLanguages.EN, + favoriteIds = emptySet(), ) ) val state: StateFlow = _state.asStateFlow() private val debugComponent = getDebug() + init { + favoritesRepository.favoriteIds + .onEach { _state.value = _state.value.copy(favoriteIds = it) } + .launchIn(screenModelScope) + } + init { if (!debugComponent.isDebug) { if (!sessionRepository.isLoggedIn()) { @@ -151,6 +162,10 @@ class HomeScreenViewModel( fun onUploadPetClicked() { rootNavigatorRepository.navigator.push(PetUploadScreen()) } + + fun onFavoriteToggled(petId: Int) { + favoritesRepository.toggleFavorite(petId) + } } data class HomeScreenState( @@ -160,6 +175,7 @@ data class HomeScreenState( val nearMeAds: List, val lastSearchAds: List, val currentLanguage: AvailableLanguages, + val favoriteIds: Set, ) sealed class HomeScreenSideEffects diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt new file mode 100644 index 0000000..72486ea --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt @@ -0,0 +1,42 @@ +package com.multiplatformkickstarter.app.ui.screens.viewmodel + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.data.repositories.LastSearchAdsMockRepository +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class PetDetailViewModel( + private val petId: Int, + private val lastSearchAdsMockRepository: LastSearchAdsMockRepository, + private val favoritesRepository: FavoritesRepository, +) : ScreenModel { + + private val _state = MutableStateFlow( + PetDetailState( + pet = lastSearchAdsMockRepository.getFeaturedAd(petId), + isFavorite = favoritesRepository.isFavorite(petId), + ) + ) + val state: StateFlow = _state.asStateFlow() + + init { + favoritesRepository.favoriteIds + .onEach { _state.value = _state.value.copy(isFavorite = favoritesRepository.isFavorite(petId)) } + .launchIn(screenModelScope) + } + + fun onFavoriteToggled() { + favoritesRepository.toggleFavorite(petId) + } +} + +data class PetDetailState( + val pet: PetModel?, + val isFavorite: Boolean, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/SearchListingViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/SearchListingViewModel.kt index 526313c..ff8da55 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/SearchListingViewModel.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/SearchListingViewModel.kt @@ -7,11 +7,14 @@ import co.touchlab.kermit.Logger import com.multiplatformkickstarter.app.common.model.PetCategory import com.multiplatformkickstarter.app.common.model.PetModel import com.multiplatformkickstarter.app.data.usecases.GetSearchUseCase +import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository import com.multiplatformkickstarter.app.ui.screens.PetDetailScreen import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -19,13 +22,20 @@ class SearchListingViewModel( private val searchId: Int?, private val petCategory: PetCategory?, private val searchListingUserCase: GetSearchUseCase, - private val navigator: Navigator + private val navigator: Navigator, + private val favoritesRepository: FavoritesRepository, ) : ScreenModel { - private val _state = MutableStateFlow(SearchListingState(null, null, emptyList())) + private val _state = MutableStateFlow(SearchListingState(null, null, emptyList(), emptySet())) val state = _state.asStateFlow() private val _sideEffects = Channel() val sideEffects: Flow = _sideEffects.receiveAsFlow() + init { + favoritesRepository.favoriteIds + .onEach { _state.value = _state.value.copy(favoriteIds = it) } + .launchIn(screenModelScope) + } + fun onStarted() { screenModelScope.launch { if (searchId != null) { @@ -57,12 +67,17 @@ class SearchListingViewModel( fun onPetClicked(id: Int) { navigator.push(PetDetailScreen(id)) } + + fun onFavoriteToggled(petId: Int) { + favoritesRepository.toggleFavorite(petId) + } } data class SearchListingState( val searchId: Int? = null, val petCategory: PetCategory? = null, val pets: List, + val favoriteIds: Set, ) sealed class SearchListingSideEffects { From 609abc56389c42ebfcd38f42f426b55ee67c2ee6 Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Fri, 5 Jun 2026 18:50:57 +0200 Subject: [PATCH 05/18] Corrected Server --- README.md | 316 +- backend/build.gradle.kts | 5 +- backend/docs/.swagger-codegen/VERSION | 2 +- backend/docs/index.html | 3000 ++--------------- .../AuthenticationConfig.kt | 3 - .../GeneralRoutingConfig.kt | 8 +- .../auth/UserSession.kt | 3 + .../plugins/Monitoring.kt | 2 +- .../repository/DatabaseFactory.kt | 6 +- .../repository/pets/Pets.kt | 4 +- .../repository/pets/PetsRepositoryImp.kt | 13 +- .../profile/ProfileRepositoryImpl.kt | 11 +- .../repository/profile/Profiles.kt | 4 +- .../repository/user/UserRepositoryImp.kt | 8 +- .../repository/user/Users.kt | 4 +- .../routes/PetsRoutes.kt | 27 +- .../routes/ProfileRoutes.kt | 19 +- .../routes/UserRoute.kt | 15 +- gradle/libs.versions.toml | 5 +- .../app/feature/petupload/PetUploadScreen.kt | 2 +- .../app/ui/screens/ProfileTabScreen.kt | 3 +- 21 files changed, 485 insertions(+), 2975 deletions(-) diff --git a/README.md b/README.md index dc0485e..c0048b5 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,229 @@ - +

+ Adoptme Logo +

-[![Build](https://github.com/MultiplatformKickstarter/Adoptme/actions/workflows/basic.yml/badge.svg)](https://github.com/multiplatformkickstarter/Adoptme/actions/workflows/basic.yml) -[![Kotlin](https://img.shields.io/badge/Kotlin-2.0.0-blue.svg?style=flat&logo=kotlin)](https://kotlinlang.org) -![badge-android](http://img.shields.io/badge/platform-android-6EDB8D.svg?style=flat) -![badge-ios](http://img.shields.io/badge/platform-ios-6EDB8D.svg?style=flat) -![badge-desktop](http://img.shields.io/badge/platform-desktop-6EDB8D.svg?style=flat) -![badge-ktor](http://img.shields.io/badge/platform-ktor-6EDB8D.svg?style=flat) +

Adoptme

- - +

+ A full-featured open-source Kotlin Multiplatform app template — Android, iOS, Desktop & Backend. +

-
-* Adoptme * +

+ Production-ready · Compose Multiplatform · Shared UI & Logic · Ktor Backend +

-The first Open Source Template with all boilerplate code needed to quickly create Kotlin Multiplatform Applications. -
+

+ + Build + + Kotlin + Compose Multiplatform + Android + iOS + Desktop + Ktor + License +

- - - - -  - - - - +--- -

+## What is Adoptme? -

-About -| -Download -| -Usage -| -PRO Template -| -Who Made This -| -Contribute -| -Bugs and Feedback -| -Social Media -| -License -

-
+**Adoptme** is an opinionated, production-ready Kotlin Multiplatform app template built on top of **Compose Multiplatform**. It targets Android, iOS, and Desktop from a single shared codebase — with a **Ktor backend** included. -### About +Rather than covering only the basics, Adoptme solves the real problems you encounter when taking an app from zero to production: navigation, dependency injection, image loading, internationalization, dark mode, accessibility, CI, and more — all wired up and working out of the box. + +Use it as a launchpad for your own KMP application. + +--- + +## Features -Adoptme is an opinionated template based in **Kotlin Multiplatform** (KMP) and **Compose Multiplatform**. While most templates only solves the basic functionality, we are aiming to solve most of the common problems when creating applications from initial status to production. +### Screens & Flows + +- ✅ **Login / Sign Up** — full authentication flow +- ✅ **Onboarding** — swipeable intro slides with localized content +- ✅ **Home** — pet listings with "Near Me" grid and "Last Search" carousel +- ✅ **Search Listing** — filterable pet list by category +- ✅ **Pet Detail** — full pet profile with favorite toggle +- ✅ **Favorites** — lists all favorited pets with unfavorite action +- ✅ **Inbox** — empty-state messages screen ready to extend +- ✅ **Pet Upload** — form to submit a new pet listing +- ✅ **Profile** — logged-in user profile with ratings and options +- ✅ **Profile Detail** — public user profile with their listings +- ✅ **Debug Menu** — developer tools screen (debug builds only) +- ✅ **Account Settings / Settings** — stub screens ready to extend + +### Components + +- ✅ `PetCardSmall` & `PetCardBig` with favorite heart icon overlay +- ✅ `RatingBar` star component +- ✅ `PickerItem` list row with icon +- ✅ `EmptyLayout` with optional action button +- ✅ `PetsSearchBar` +- ✅ `ColoredSnackBar` with success/error/info types +- ✅ Shimmer loading animation +- ✅ Navigation bar (bottom bar + rail variants) + +### Architecture & Tech + +- ✅ **Shared UI and Logic** across all platforms +- ✅ **Kotlin 2.4** + **Compose Multiplatform 1.11** +- ✅ **Voyager** — Navigation, Tabs, ScreenModel (ViewModel) +- ✅ **Koin 4** — Dependency Injection with multiplatform support +- ✅ **Ktor 3** (Client + Server) +- ✅ **Kamel 1** — async image loading with per-platform decoders +- ✅ **Kotlin Coroutines** + **StateFlow** for reactive state +- ✅ **Kotlin Serialization** + **Kotlin Datetime** +- ✅ **Multiplatform Settings** — key-value storage +- ✅ **Kermit** — multiplatform logging +- ✅ **Detekt** — static code analysis +- ✅ **Ktlint** — code style linter +- ✅ **Version Catalogs** (`libs.versions.toml`) +- ✅ **Firebase Analytics** (Android) +- ✅ **Ktor Backend** — PostgreSQL + Tomcat + OpenAPI / Swagger -Comes with this stack built-in: +### App-level Features -- **Shared UI and Logic for all platforms** -- **Kotlin** -- **Compose Multiplatform** -- **Kotlin Coroutines** -- **Kotlin Serialization** -- **Kotlin Datetime** -- **Voyager** for Navigation and View Models -- **Koin** as Dependency Injection -- **Detekt** for static code analysis -- **Ktlint** linter -- **Ktor** for Client -- **Multiplatform-settings** for storage -- **Kamel** for Images -- **Kermit** for logging -- **Version Catalogs** -- **Ktor for Server** (PostgresSQL+Tomcat) -- **Swagger Codegen** for **OpenAPI** +- ✅ Dark mode ready +- ✅ Internationalization — English, Spanish, French, Italian, German (FIGS + EN) +- ✅ Accessibility + color-accessible themes +- ✅ Edge-to-edge display with `enableEdgeToEdge()` +- ✅ Favorites powered by `StateFlow>` — reactive across the whole app +- ✅ Basic CI (GitHub Actions) -#### Components and Screens +--- -* Login/Sign Up -* Basic listing -* Basic insertion form -* Basic Detail -* Basic Debug Mode -* Basic Profile -* Basic CI -* Basic Composable Components -* Dark mode ready -* Navigation -* Accessibility (and color accessible themes) -* Tracking system -* Internationalization (FIGS) -

+## Requirements +| Platform | Minimum | +|---|---| +| Android | API 26 (minSdk) | +| iOS | 16.0+ | +| Desktop | JVM 21 | +| Backend | JVM 21, PostgreSQL | -### Download +--- -Click to "Use This Template" button to create a new repository with this template. -

+## Project Structure -### Usage +``` +Adoptme/ +├── composeApp/ # Android application entry point +├── shared/ # Shared KMP module (UI + logic, all platforms) +│ └── commonMain/ +│ ├── di/ # Koin modules +│ ├── feature/ # Feature modules (favorites, profile, petupload, …) +│ ├── navigation/ # Tab navigation objects +│ ├── ui/ # Screens, components, theme, icons +│ └── localization/# Strings for all supported languages +├── backend/ # Ktor server (REST API, JWT auth, PostgreSQL) +└── gradle/ + └── libs.versions.toml +``` -Go to the [Documentation page](https://docs.multiplatformkickstarter.com) for all the information. (**WIP**) -

+--- -Need a different template? ------------------ +## Getting Started -Do you need a different use case? Adoptme Template is a good start but the are more **complete templates** that have all you need to create professional apps without a hassle. -Including more features, screens and components. Like: +### 1. Clone the repository -- Advanced CI (deployment) -- RSS Component -- Read and Download PDFs -- Built-in video-game -- 3rd party integrations -- **and more!** +```bash +git clone https://github.com/MultiplatformKickstarter/Adoptme.git +cd Adoptme +``` -**Save months of work plus a lot of money and focus on building a profitable business.** +### 2. Run on Android -Checkout [multiplatformkickstarter.com](https://www.multiplatformkickstarter.com) for all details or go directly to our [store](https://www.multiplatformkickstarter.com/pricing) -

+Open the project in Android Studio and run the `composeApp` configuration. +### 3. Run on Desktop -Who made this --------------- +```bash +./gradlew :composeApp:run +``` + +### 4. Run on iOS + +Open `iosApp/iosApp.xcodeproj` in Xcode and run on a simulator or device. + +### 5. Run the Backend + +```bash +./gradlew :backend:run +``` + +> Requires a running PostgreSQL instance. Configure the connection in `backend/src/main/resources/application.conf`. + +--- + +## Backend API + +The Ktor backend exposes a REST API secured with **JWT authentication**: + +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `/v1/users/create` | Register a new user | +| `POST` | `/v1/users/login` | Login and receive JWT | +| `POST` | `/v1/users/logout` | Logout | +| `GET` | `/v1/pets/list` | List pets for current user | +| `GET` | `/v1/pets/pet` | Get a single pet | +| `POST` | `/v1/pets/create` | Create a pet listing | +| `PATCH` | `/v1/pets/pet/update` | Update a pet listing | +| `DELETE` | `/v1/pets/delete` | Delete a pet listing | +| `GET` | `/v1/profile` | Get current user profile | +| `POST` | `/v1/profile/create` | Create user profile | +| `PATCH` | `/v1/profile/update` | Update user profile | + +OpenAPI docs available at `/openapi` when the server is running. + +--- + +## Who made this | Ferran Pons | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Ferran Pons](https://github.com/ferranpons) | +|---| +| [Ferran Pons](https://github.com/ferranpons) | + +--- -

+## Contributing -Contribute ----------- +Contributions of any kind are welcome! -1. Create an issue to discuss about your idea -2. [Fork it] (https://github.com/MultiplatformKickstarter/Adoptme/fork) +1. Open an issue to discuss your idea +2. [Fork the repo](https://github.com/MultiplatformKickstarter/Adoptme/fork) 3. Create your feature branch (`git checkout -b my-new-feature`) 4. Commit your changes (`git commit -am 'Add some feature'`) 5. Push to the branch (`git push origin my-new-feature`) -6. Create a new Pull Request -7. Profit! :white_check_mark: -

- +6. Open a Pull Request -Bugs and Feedback ------------------ +--- -For bugs, questions and discussions please use the [Github Issues](https://github.com/multiplatformkickstarter/Adoptme/issues). -

+## Bugs and Feedback +For bugs, questions and discussions please use [GitHub Issues](https://github.com/MultiplatformKickstarter/Adoptme/issues). -Social Media ------------- +--- -Check out our social media to be up-to-date about new products, features and releases. +## Social Media -- [X platform](https://twitter.com/mpkickstarter) - -**Follow us!** -

+ + + +  + + + +--- -License -------- +## License -Copyright 2023-2025 Multiplatform Kickstarter +Copyright 2023–2025 Multiplatform Kickstarter Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -183,10 +236,9 @@ 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. -

+--- -Credits -------- +Illustrations by Storyset -Illustrations by Storyset \ No newline at end of file +Built with ❤️ for the Kotlin Multiplatform community. diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 7459c2d..ae4b048 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -19,7 +19,8 @@ dependencies { implementation(libs.ktor.server.core) implementation(libs.ktor.server.auth) implementation(libs.ktor.server.auth.jwt) - implementation(libs.ktor.server.locations) + implementation(libs.ktor.resources) + implementation(libs.ktor.server.resources) implementation(libs.ktor.server.host.common) implementation(libs.ktor.server.status.pages) implementation(libs.ktor.server.catching.headers) @@ -48,6 +49,6 @@ dependencies { implementation(libs.hikariCP) implementation(libs.swagger.codegen) - testImplementation(libs.ktor.server.tests.jvm) + testImplementation(libs.ktor.server.test.host) testImplementation(libs.kotlin.test.junit) } diff --git a/backend/docs/.swagger-codegen/VERSION b/backend/docs/.swagger-codegen/VERSION index 6f495ea..dea21e1 100644 --- a/backend/docs/.swagger-codegen/VERSION +++ b/backend/docs/.swagger-codegen/VERSION @@ -1 +1 @@ -3.0.69 \ No newline at end of file +3.0.80 \ No newline at end of file diff --git a/backend/docs/index.html b/backend/docs/index.html index ec2aee8..8fd0a54 100644 --- a/backend/docs/index.html +++ b/backend/docs/index.html @@ -1,2780 +1,252 @@ - + - - - Sample API - - - - - - - - - - - - - -
-
-
- -
-
-
-
-

Sample API

-
-
-
- -
-
-

Default

-
-
-
-

usersGet

-

Returns a list of users.

-
-
-
-

-

Optional extended description in CommonMark or HTML.

-

-
-
/users
-

-

Usage and SDK Samples

-

- - -
-
-
curl -X GET\
- -H "Authorization: Basic [[basicHash]]"\
--H "Accept: application/json"\
-"http://api.example.com/v1/users"
-
-
-
import io.swagger.client.*;
-import io.swagger.client.auth.*;
-import io.swagger.client.model.*;
-import io.swagger.client.api.DefaultApi;
-
-import java.io.File;
-import java.util.*;
-
-public class DefaultApiExample {
-
-    public static void main(String[] args) {
-        ApiClient defaultClient = Configuration.getDefaultApiClient();
-        // Configure HTTP basic authorization: BasicAuth
-        HttpBasicAuth BasicAuth = (HttpBasicAuth) defaultClient.getAuthentication("BasicAuth");
-        BasicAuth.setUsername("YOUR USERNAME");
-        BasicAuth.setPassword("YOUR PASSWORD");
-
-        DefaultApi apiInstance = new DefaultApi();
-        try {
-            array['String'] result = apiInstance.usersGet();
-            System.out.println(result);
-        } catch (ApiException e) {
-            System.err.println("Exception when calling DefaultApi#usersGet");
-            e.printStackTrace();
-        }
-    }
-}
-
- -
-
import io.swagger.client.api.DefaultApi;
-
-public class DefaultApiExample {
-
-    public static void main(String[] args) {
-        DefaultApi apiInstance = new DefaultApi();
-        try {
-            array['String'] result = apiInstance.usersGet();
-            System.out.println(result);
-        } catch (ApiException e) {
-            System.err.println("Exception when calling DefaultApi#usersGet");
-            e.printStackTrace();
-        }
-    }
-}
-
- -
-
Configuration *apiConfig = [Configuration sharedConfig];
-// Configure HTTP basic authorization (authentication scheme: BasicAuth)
-[apiConfig setUsername:@"YOUR_USERNAME"];
-[apiConfig setPassword:@"YOUR_PASSWORD"];
-
-DefaultApi *apiInstance = [[DefaultApi alloc] init];
-
-// Returns a list of users.
-[apiInstance usersGetWithCompletionHandler: 
-              ^(array['String'] output, NSError* error) {
-                            if (output) {
-                                NSLog(@"%@", output);
-                            }
-                            if (error) {
-                                NSLog(@"Error: %@", error);
-                            }
-                        }];
-
-
- -
-
var SampleApi = require('sample_api');
-var defaultClient = SampleApi.ApiClient.instance;
-// Configure HTTP basic authorization: BasicAuth
-var BasicAuth = defaultClient.authentications['BasicAuth'];
-BasicAuth.username = 'YOUR USERNAME'
-BasicAuth.password = 'YOUR PASSWORD'
-
-var api = new SampleApi.DefaultApi()
-var callback = function(error, data, response) {
-  if (error) {
-    console.error(error);
-  } else {
-    console.log('API called successfully. Returned data: ' + data);
-  }
-};
-api.usersGet(callback);
-
-
- - -
-
using System;
-using System.Diagnostics;
-using IO.Swagger.Api;
-using IO.Swagger.Client;
-using IO.Swagger.Model;
-
-namespace Example
-{
-    public class usersGetExample
-    {
-        public void main()
-        {
-            // Configure HTTP basic authorization: BasicAuth
-            Configuration.Default.Username = "YOUR_USERNAME";
-            Configuration.Default.Password = "YOUR_PASSWORD";
-
-            var apiInstance = new DefaultApi();
-
-            try
-            {
-                // Returns a list of users.
-                array['String'] result = apiInstance.usersGet();
-                Debug.WriteLine(result);
-            }
-            catch (Exception e)
-            {
-                Debug.Print("Exception when calling DefaultApi.usersGet: " + e.Message );
-            }
-        }
-    }
-}
-
-
- -
-
<?php
-require_once(__DIR__ . '/vendor/autoload.php');
-// Configure HTTP basic authorization: BasicAuth
-Swagger\Client\Configuration::getDefaultConfiguration()->setUsername('YOUR_USERNAME');
-Swagger\Client\Configuration::getDefaultConfiguration()->setPassword('YOUR_PASSWORD');
-
-$api_instance = new Swagger\Client\ApiDefaultApi();
-
-try {
-    $result = $api_instance->usersGet();
-    print_r($result);
-} catch (Exception $e) {
-    echo 'Exception when calling DefaultApi->usersGet: ', $e->getMessage(), PHP_EOL;
-}
-?>
-
- -
-
use Data::Dumper;
-use WWW::SwaggerClient::Configuration;
-use WWW::SwaggerClient::DefaultApi;
-# Configure HTTP basic authorization: BasicAuth
-$WWW::SwaggerClient::Configuration::username = 'YOUR_USERNAME';
-$WWW::SwaggerClient::Configuration::password = 'YOUR_PASSWORD';
-
-my $api_instance = WWW::SwaggerClient::DefaultApi->new();
-
-eval { 
-    my $result = $api_instance->usersGet();
-    print Dumper($result);
-};
-if ($@) {
-    warn "Exception when calling DefaultApi->usersGet: $@\n";
-}
-
- -
-
from __future__ import print_statement
-import time
-import swagger_client
-from swagger_client.rest import ApiException
-from pprint import pprint
-# Configure HTTP basic authorization: BasicAuth
-swagger_client.configuration.username = 'YOUR_USERNAME'
-swagger_client.configuration.password = 'YOUR_PASSWORD'
-
-# create an instance of the API class
-api_instance = swagger_client.DefaultApi()
-
-try: 
-    # Returns a list of users.
-    api_response = api_instance.users_get()
-    pprint(api_response)
-except ApiException as e:
-    print("Exception when calling DefaultApi->usersGet: %s\n" % e)
-
-
- -

Parameters

- - - - - - -

Responses

-

Status: 200 - A JSON array of user names

- - - -
-
-
- -
- -
-
- -
-
-
-
-
- -
-
-
- - - - - - - - + + .method-path { + font-size: 1.5em; + background-color: #0f6ab4; + } + + .up { + float:right; + } + + .parameter { + width: 500px; + } + + .param { + width: 500px; + padding: 10px 0 0 20px; + font-weight: bold; + } + + .param-desc { + width: 700px; + padding: 0 0 0 20px; + color: #777; + } + + .param-type { + font-style: italic; + } + + .param-enum-header { + width: 700px; + padding: 0 0 0 60px; + color: #777; + font-weight: bold; + } + + .param-enum { + width: 700px; + padding: 0 0 0 80px; + color: #777; + font-style: italic; + } + + .field-label { + padding: 0; + margin: 0; + clear: both; + } + + .field-items { + padding: 0 0 15px 0; + margin-bottom: 15px; + } + + .return-type { + clear: both; + padding-bottom: 10px; + } + + .param-header { + font-weight: bold; + } + + .method-tags { + text-align: right; + } + + .method-tag { + background: none repeat scroll 0% 0% #24A600; + border-radius: 3px; + padding: 2px 10px; + margin: 2px; + color: #FFF; + display: inline-block; + text-decoration: none; + } + + + +

Sample API

+
Optional multiline or single-line description in CommonMark or HTML.
+
More information: https://helloreverb.com
+
Contact Info: hello@helloreverb.com
+
Version: 0.1.9
+
BasePath:/v1
+
All rights reserved
+
http://apache.org/licenses/LICENSE-2.0.html
+

Access

+
    +
  1. HTTP Basic Authentication
  2. +
+ +

Methods

+ [ Jump to Models ] + +

Table of Contents

+
+

Default

+ + +

Default

+
+
+ Up +
get /users
+
Returns a list of users. (usersGet)
+
Optional extended description in CommonMark or HTML.
+ + + + + + + +

Return type

+
+ + array[String] +
+ + + +

Example data

+
Content-Type: application/json
+
[ "", "" ]
+ +

Produces

+ This API call produces the following media types according to the Accept request header; + the media type will be conveyed by the Content-Type response header. +
    +
  • application/json
  • +
+ +

Responses

+

200

+ A JSON array of user names + +
+
+ +

Models

+ [ Jump to Methods ] + +

Table of Contents

+
    +
+ + diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt index b1045ec..517e03f 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt @@ -1,5 +1,3 @@ -@file:OptIn(KtorExperimentalLocationsAPI::class) - package com.multiplatformkickstarter import com.multiplatformkickstarter.auth.JWT_CONFIGURATION @@ -16,7 +14,6 @@ import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.auth.Authentication import io.ktor.server.auth.jwt.jwt -import io.ktor.server.locations.KtorExperimentalLocationsAPI import io.ktor.server.routing.routing fun Application.configureAuthentication() { diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/GeneralRoutingConfig.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/GeneralRoutingConfig.kt index da408d3..08a8bbe 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/GeneralRoutingConfig.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/GeneralRoutingConfig.kt @@ -1,5 +1,3 @@ -@file:OptIn(KtorExperimentalLocationsAPI::class) - package com.multiplatformkickstarter import io.ktor.http.HttpStatusCode @@ -7,10 +5,9 @@ import io.ktor.server.application.Application import io.ktor.server.application.call import io.ktor.server.application.install import io.ktor.server.http.content.staticResources -import io.ktor.server.locations.KtorExperimentalLocationsAPI -import io.ktor.server.locations.Locations import io.ktor.server.plugins.openapi.openAPI import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.resources.Resources import io.ktor.server.response.respond import io.ktor.server.response.respondText import io.ktor.server.routing.get @@ -18,8 +15,7 @@ import io.ktor.server.routing.routing import io.swagger.codegen.v3.generators.html.StaticHtmlCodegen fun Application.configureGeneralRouting() { - install(Locations) { - } + install(Resources) routing { get("/") { diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/auth/UserSession.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/auth/UserSession.kt index 40b4e27..e0747d0 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/auth/UserSession.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/auth/UserSession.kt @@ -1,3 +1,6 @@ package com.multiplatformkickstarter.auth +import kotlinx.serialization.Serializable + +@Serializable data class UserSession(val userId: Int) diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/plugins/Monitoring.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/plugins/Monitoring.kt index ef35358..2b3f710 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/plugins/Monitoring.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/plugins/Monitoring.kt @@ -2,7 +2,7 @@ package com.multiplatformkickstarter.plugins import io.ktor.server.application.Application import io.ktor.server.application.install -import io.ktor.server.plugins.callloging.CallLogging +import io.ktor.server.plugins.calllogging.CallLogging import io.ktor.server.request.path import org.slf4j.event.Level diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt index e37b21b..80ad2c6 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt @@ -7,9 +7,9 @@ import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.jetbrains.exposed.sql.Database -import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction private const val MAX_POOL_SIZE = 3 diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/Pets.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/Pets.kt index e5d5522..5215eed 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/Pets.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/Pets.kt @@ -1,8 +1,8 @@ package com.multiplatformkickstarter.repository.pets import com.multiplatformkickstarter.repository.user.Users -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table @Suppress("MagicNumber") object Pets : Table() { diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/PetsRepositoryImp.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/PetsRepositoryImp.kt index 32c258f..acd7056 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/PetsRepositoryImp.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/pets/PetsRepositoryImp.kt @@ -8,12 +8,13 @@ import com.multiplatformkickstarter.app.common.model.PetModel import com.multiplatformkickstarter.app.common.model.PetSize import com.multiplatformkickstarter.app.common.model.PetStatus import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery -import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.deleteWhere -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.statements.InsertStatement -import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.update class PetsRepositoryImp : PetsRepository { override suspend fun addPet( diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/ProfileRepositoryImpl.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/ProfileRepositoryImpl.kt index ca3e6b3..6b452a1 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/ProfileRepositoryImpl.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/ProfileRepositoryImpl.kt @@ -3,11 +3,12 @@ package com.multiplatformkickstarter.repository.profile import com.multiplatformkickstarter.app.common.model.GeoLocation import com.multiplatformkickstarter.app.common.model.Profile import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery -import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.statements.InsertStatement -import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.update class ProfileRepositoryImpl : ProfileRepository { override suspend fun addProfile( diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/Profiles.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/Profiles.kt index 6cb7867..0ea0ec4 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/Profiles.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/profile/Profiles.kt @@ -1,8 +1,8 @@ package com.multiplatformkickstarter.repository.profile import com.multiplatformkickstarter.repository.user.Users -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table @Suppress("MagicNumber") object Profiles : Table() { diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/UserRepositoryImp.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/UserRepositoryImp.kt index 171e75f..4e3fb2b 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/UserRepositoryImp.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/UserRepositoryImp.kt @@ -2,9 +2,11 @@ package com.multiplatformkickstarter.repository.user import com.multiplatformkickstarter.models.DatabaseUser import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery -import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.statements.InsertStatement +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select class UserRepositoryImp : UserRepository { override suspend fun addUser( diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/Users.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/Users.kt index 3b0258d..023d4fb 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/Users.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/user/Users.kt @@ -1,7 +1,7 @@ package com.multiplatformkickstarter.repository.user -import org.jetbrains.exposed.sql.Column -import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table @Suppress("MagicNumber") object Users : Table() { diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/PetsRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/PetsRoutes.kt index 336cd9f..107d40b 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/PetsRoutes.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/PetsRoutes.kt @@ -11,12 +11,11 @@ import io.ktor.http.Parameters import io.ktor.server.application.call import io.ktor.server.application.log import io.ktor.server.auth.authenticate -import io.ktor.server.locations.KtorExperimentalLocationsAPI -import io.ktor.server.locations.Location -import io.ktor.server.locations.delete -import io.ktor.server.locations.get -import io.ktor.server.locations.patch -import io.ktor.server.locations.post +import io.ktor.resources.Resource +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.patch +import io.ktor.server.resources.post import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -34,28 +33,22 @@ const val PETS_GET_ONE = "$PETS/pet" const val PETS_UPDATE_ONE = "$PETS/pet/update" const val PETS_DELETE = "$PETS/delete" -@KtorExperimentalLocationsAPI -@Location(PETS_CREATE) +@Resource(PETS_CREATE) class PetsCreateRoute -@KtorExperimentalLocationsAPI -@Location(PETS_LIST) +@Resource(PETS_LIST) class PetsListRoute -@KtorExperimentalLocationsAPI -@Location(PETS_GET_ONE) +@Resource(PETS_GET_ONE) class PetsGetOneRoute -@KtorExperimentalLocationsAPI -@Location(PETS_UPDATE_ONE) +@Resource(PETS_UPDATE_ONE) class PetsUpdateOneRoute -@KtorExperimentalLocationsAPI -@Location(PETS_DELETE) +@Resource(PETS_DELETE) class PetsDeleteRoute @Suppress("LongMethod", "TooGenericExceptionCaught", "CyclomaticComplexMethod") -@KtorExperimentalLocationsAPI fun Route.pets( petsRepository: PetsRepository, userRepository: UserRepository, diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ProfileRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ProfileRoutes.kt index d8a1b4e..5b3d66f 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ProfileRoutes.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ProfileRoutes.kt @@ -10,11 +10,10 @@ import io.ktor.http.Parameters import io.ktor.server.application.call import io.ktor.server.application.log import io.ktor.server.auth.authenticate -import io.ktor.server.locations.KtorExperimentalLocationsAPI -import io.ktor.server.locations.Location -import io.ktor.server.locations.get -import io.ktor.server.locations.patch -import io.ktor.server.locations.post +import io.ktor.resources.Resource +import io.ktor.server.resources.get +import io.ktor.server.resources.patch +import io.ktor.server.resources.post import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -26,20 +25,16 @@ const val PROFILE = "$API_VERSION/profile" const val PROFILE_CREATE = "$PROFILE/create" const val PROFILE_UPDATE = "$PROFILE/update" -@KtorExperimentalLocationsAPI -@Location(PROFILE) +@Resource(PROFILE) class ProfileRoute -@KtorExperimentalLocationsAPI -@Location(PROFILE_CREATE) +@Resource(PROFILE_CREATE) class ProfileCreateRoute -@KtorExperimentalLocationsAPI -@Location(PROFILE_UPDATE) +@Resource(PROFILE_UPDATE) class ProfileUpdateRoute @Suppress("LongMethod", "TooGenericExceptionCaught", "CyclomaticComplexMethod") -@KtorExperimentalLocationsAPI fun Route.profiles( profileRepository: ProfileRepository, userRepository: UserRepository, diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt index 39705e0..8fbb13f 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt @@ -8,9 +8,8 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.Parameters import io.ktor.server.application.call import io.ktor.server.application.log -import io.ktor.server.locations.KtorExperimentalLocationsAPI -import io.ktor.server.locations.Location -import io.ktor.server.locations.post +import io.ktor.resources.Resource +import io.ktor.server.resources.post import io.ktor.server.request.receive import io.ktor.server.response.header import io.ktor.server.response.respond @@ -26,20 +25,16 @@ const val USER_LOGIN = "$USERS/login" const val USER_CREATE = "$USERS/create" const val USER_LOGOUT = "$USERS/logout" -@KtorExperimentalLocationsAPI -@Location(USER_LOGIN) +@Resource(USER_LOGIN) class UserLoginRoute -@KtorExperimentalLocationsAPI -@Location(USER_CREATE) +@Resource(USER_CREATE) class UserCreateRoute -@KtorExperimentalLocationsAPI -@Location(USER_LOGOUT) +@Resource(USER_LOGOUT) class UserLogoutRoute @Suppress("TooGenericExceptionCaught") -@KtorExperimentalLocationsAPI fun Route.users( userRepository: UserRepository, jwtService: JwtService, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6a5d72..b22de4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,7 +87,8 @@ androidx-compose-ui-util = "org.jetbrains.compose.ui:ui-util:1.11.1" ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor-server" } ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor-server" } ktor-server-auth-jwt = { module = "io.ktor:ktor-server-auth-jwt", version.ref = "ktor-server" } -ktor-server-locations = { module = "io.ktor:ktor-server-locations", version.ref = "ktor-server" } +ktor-server-resources = { module = "io.ktor:ktor-server-resources", version.ref = "ktor-server" } +ktor-resources = { module = "io.ktor:ktor-resources", version.ref = "ktor-server" } ktor-server-host-common = { module = "io.ktor:ktor-server-host-common", version.ref = "ktor-server" } ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor-server" } ktor-server-catching-headers = { module = "io.ktor:ktor-server-caching-headers", version.ref = "ktor-server" } @@ -103,7 +104,7 @@ ktor-server-serialization-gson = { module = "io.ktor:ktor-serialization-gson", v ktor-server-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-server" } ktor-server-tomcat = { module = "io.ktor:ktor-server-tomcat", version.ref = "ktor-server" } ktor-server-openapi = { module = "io.ktor:ktor-server-openapi", version.ref = "ktor-server" } -ktor-server-tests-jvm = { module = "io.ktor:ktor-server-tests-jvm", version.ref = "ktor-server" } +ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor-server" } exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt index ae1e6bb..63ad4df 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt @@ -77,7 +77,7 @@ import kotlin.enums.EnumEntries class PetUploadScreen : Screen { private lateinit var showingModal: MutableState - private val rootSnackbarHostStateRepository = getRootSnackbarHostState() + private val rootSnackbarHostStateRepository by lazy { getRootSnackbarHostState() } @Composable override fun Content() { diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt index a35c10b..9f0503b 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.statusBarsPadding import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator @@ -85,7 +86,7 @@ class ProfileTabScreen : Screen { ) { val scrollState = rememberScrollState() MultiplatformKickstarterTheme { - Column(modifier = Modifier.padding(bottom = 80.dp).verticalScroll(scrollState)) { + Column(modifier = Modifier.statusBarsPadding().padding(bottom = 80.dp).verticalScroll(scrollState)) { UserProfileData(state, viewModel) OptionsList(localization, viewModel) } From 4e4573e013e2eeeaf2af967564c048f0457cd7a2 Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 14:35:00 +0200 Subject: [PATCH 06/18] Added Favorites and Chat --- .../AuthenticationConfig.kt | 10 + .../models/ConversationResponse.kt | 13 + .../models/MessageResponse.kt | 12 + .../repository/DatabaseFactory.kt | 6 + .../repository/chat/Conversations.kt | 16 ++ .../chat/ConversationsRepository.kt | 10 + .../chat/ConversationsRepositoryImpl.kt | 87 +++++++ .../repository/chat/Messages.kt | 15 ++ .../repository/chat/MessagesRepository.kt | 8 + .../repository/chat/MessagesRepositoryImpl.kt | 49 ++++ .../repository/favorites/Favorites.kt | 11 + .../favorites/FavoritesRepository.kt | 8 + .../favorites/FavoritesRepositoryImpl.kt | 47 ++++ .../routes/ChatRoutes.kt | 136 ++++++++++ .../routes/FavoritesRoutes.kt | 76 ++++++ .../app/common/model/ChatConversation.kt | 13 + .../app/common/model/ChatMessage.kt | 12 + .../app/di/CommonModule.kt | 17 +- .../app/feature/inbox/ChatScreen.kt | 237 ++++++++++++++++++ .../app/feature/inbox/ChatViewModel.kt | 73 ++++++ .../app/feature/inbox/InboxScreen.kt | 111 +++++++- .../app/feature/inbox/InboxViewModel.kt | 53 ++++ .../inbox/repositories/ChatRepository.kt | 142 +++++++++++ .../app/localization/Localization.kt | 6 + .../translations/DeutschLocalization.kt | 6 + .../translations/EnglishLocalization.kt | 6 + .../translations/FrenchLocalization.kt | 6 + .../translations/ItalianLocalization.kt | 6 + .../translations/SpanishLocalization.kt | 6 + .../app/ui/screens/PetDetailScreen.kt | 6 +- .../screens/viewmodel/PetDetailViewModel.kt | 20 ++ 31 files changed, 1213 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/models/ConversationResponse.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/models/MessageResponse.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepository.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepositoryImpl.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Messages.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepository.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepositoryImpl.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/Favorites.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepository.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepositoryImpl.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt create mode 100644 backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatConversation.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatMessage.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt index 517e03f..85ffb55 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/AuthenticationConfig.kt @@ -4,9 +4,14 @@ import com.multiplatformkickstarter.auth.JWT_CONFIGURATION import com.multiplatformkickstarter.auth.JwtService import com.multiplatformkickstarter.auth.hash import com.multiplatformkickstarter.repository.DatabaseFactory +import com.multiplatformkickstarter.repository.chat.ConversationsRepositoryImpl +import com.multiplatformkickstarter.repository.chat.MessagesRepositoryImpl +import com.multiplatformkickstarter.repository.favorites.FavoritesRepositoryImpl import com.multiplatformkickstarter.repository.pets.PetsRepositoryImp import com.multiplatformkickstarter.repository.profile.ProfileRepositoryImpl import com.multiplatformkickstarter.repository.user.UserRepositoryImp +import com.multiplatformkickstarter.routes.chat +import com.multiplatformkickstarter.routes.favorites import com.multiplatformkickstarter.routes.pets import com.multiplatformkickstarter.routes.profiles import com.multiplatformkickstarter.routes.users @@ -21,6 +26,9 @@ fun Application.configureAuthentication() { val userRepository = UserRepositoryImp() val petRepository = PetsRepositoryImp() val profileRepository = ProfileRepositoryImpl() + val favoritesRepository = FavoritesRepositoryImpl() + val conversationsRepository = ConversationsRepositoryImpl() + val messagesRepository = MessagesRepositoryImpl() val jwtService = JwtService() val hashFunction = { s: String -> hash(s) } @@ -42,5 +50,7 @@ fun Application.configureAuthentication() { users(userRepository, jwtService, hashFunction) pets(petRepository, userRepository) profiles(profileRepository, userRepository) + favorites(favoritesRepository, userRepository) + chat(conversationsRepository, messagesRepository, userRepository) } } diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/models/ConversationResponse.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/models/ConversationResponse.kt new file mode 100644 index 0000000..d037ef1 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/models/ConversationResponse.kt @@ -0,0 +1,13 @@ +package com.multiplatformkickstarter.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationResponse( + val id: Int, + val petId: Int, + val petName: String, + val buyerUserId: Int, + val sellerUserId: Int, + val createdAt: Long, +) diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/models/MessageResponse.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/models/MessageResponse.kt new file mode 100644 index 0000000..1788d52 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/models/MessageResponse.kt @@ -0,0 +1,12 @@ +package com.multiplatformkickstarter.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MessageResponse( + val id: Int, + val conversationId: Int, + val senderUserId: Int, + val content: String, + val sentAt: Long, +) diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt index 80ad2c6..3c3ef9d 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/DatabaseFactory.kt @@ -1,5 +1,8 @@ package com.multiplatformkickstarter.repository +import com.multiplatformkickstarter.repository.chat.Conversations +import com.multiplatformkickstarter.repository.chat.Messages +import com.multiplatformkickstarter.repository.favorites.Favorites import com.multiplatformkickstarter.repository.pets.Pets import com.multiplatformkickstarter.repository.profile.Profiles import com.multiplatformkickstarter.repository.user.Users @@ -21,6 +24,9 @@ object DatabaseFactory { SchemaUtils.create(Users) SchemaUtils.create(Pets) SchemaUtils.create(Profiles) + SchemaUtils.create(Favorites) + SchemaUtils.create(Conversations) + SchemaUtils.create(Messages) } } diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt new file mode 100644 index 0000000..3975f7b --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt @@ -0,0 +1,16 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.repository.user.Users +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table + +object Conversations : Table() { + val id: Column = integer("id").autoIncrement().uniqueIndex() + val petId: Column = integer("petId") + val petName: Column = varchar("petName", 128) + val buyerUserId: Column = integer("buyerUserId").references(Users.userId) + val sellerUserId: Column = integer("sellerUserId").references(Users.userId) + val createdAt: Column = long("createdAt") + + override val primaryKey = PrimaryKey(id) +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepository.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepository.kt new file mode 100644 index 0000000..e21caa0 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepository.kt @@ -0,0 +1,10 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.models.ConversationResponse + +interface ConversationsRepository { + suspend fun createConversation(petId: Int, petName: String, buyerUserId: Int, sellerUserId: Int): ConversationResponse? + suspend fun getConversationsForUser(userId: Int): List + suspend fun getConversation(conversationId: Int): ConversationResponse? + suspend fun deleteConversation(conversationId: Int, userId: Int): Boolean +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepositoryImpl.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepositoryImpl.kt new file mode 100644 index 0000000..4d81d2b --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/ConversationsRepositoryImpl.kt @@ -0,0 +1,87 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.models.ConversationResponse +import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select + +class ConversationsRepositoryImpl : ConversationsRepository { + + override suspend fun createConversation( + petId: Int, + petName: String, + buyerUserId: Int, + sellerUserId: Int, + ): ConversationResponse? { + val existing = dbQuery { + Conversations.select(Conversations.id) + .where { (Conversations.petId eq petId) and (Conversations.buyerUserId eq buyerUserId) } + .map { rowToConversation(it) } + .singleOrNull() + } + if (existing != null) return existing + + var statement: InsertStatement? = null + dbQuery { + statement = Conversations.insert { + it[Conversations.petId] = petId + it[Conversations.petName] = petName + it[Conversations.buyerUserId] = buyerUserId + it[Conversations.sellerUserId] = sellerUserId + it[Conversations.createdAt] = System.currentTimeMillis() + } + } + return rowToConversation(statement?.resultedValues?.get(0)) + } + + override suspend fun getConversationsForUser(userId: Int): List { + return dbQuery { + Conversations.select( + Conversations.id, + Conversations.petId, + Conversations.petName, + Conversations.buyerUserId, + Conversations.sellerUserId, + Conversations.createdAt, + ).where { (Conversations.buyerUserId eq userId) or (Conversations.sellerUserId eq userId) } + .map { rowToConversation(it)!! } + } + } + + override suspend fun getConversation(conversationId: Int): ConversationResponse? { + return dbQuery { + Conversations.select(Conversations.id) + .where { Conversations.id eq conversationId } + .map { rowToConversation(it) } + .singleOrNull() + } + } + + override suspend fun deleteConversation(conversationId: Int, userId: Int): Boolean { + val deleted = dbQuery { + Conversations.deleteWhere { + (Conversations.id eq conversationId) and + ((Conversations.buyerUserId eq userId) or (Conversations.sellerUserId eq userId)) + } + } + return deleted > 0 + } + + private fun rowToConversation(row: ResultRow?): ConversationResponse? { + if (row == null) return null + return ConversationResponse( + id = row[Conversations.id], + petId = row[Conversations.petId], + petName = row[Conversations.petName], + buyerUserId = row[Conversations.buyerUserId], + sellerUserId = row[Conversations.sellerUserId], + createdAt = row[Conversations.createdAt], + ) + } +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Messages.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Messages.kt new file mode 100644 index 0000000..441786f --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Messages.kt @@ -0,0 +1,15 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.repository.user.Users +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table + +object Messages : Table() { + val id: Column = integer("id").autoIncrement().uniqueIndex() + val conversationId: Column = integer("conversationId").references(Conversations.id) + val senderUserId: Column = integer("senderUserId").references(Users.userId) + val content: Column = text("content") + val sentAt: Column = long("sentAt") + + override val primaryKey = PrimaryKey(id) +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepository.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepository.kt new file mode 100644 index 0000000..c60b331 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepository.kt @@ -0,0 +1,8 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.models.MessageResponse + +interface MessagesRepository { + suspend fun sendMessage(conversationId: Int, senderUserId: Int, content: String): MessageResponse? + suspend fun getMessages(conversationId: Int): List +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepositoryImpl.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepositoryImpl.kt new file mode 100644 index 0000000..48e5972 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/MessagesRepositoryImpl.kt @@ -0,0 +1,49 @@ +package com.multiplatformkickstarter.repository.chat + +import com.multiplatformkickstarter.models.MessageResponse +import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.InsertStatement +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select + +class MessagesRepositoryImpl : MessagesRepository { + + override suspend fun sendMessage(conversationId: Int, senderUserId: Int, content: String): MessageResponse? { + var statement: InsertStatement? = null + dbQuery { + statement = Messages.insert { + it[Messages.conversationId] = conversationId + it[Messages.senderUserId] = senderUserId + it[Messages.content] = content + it[Messages.sentAt] = System.currentTimeMillis() + } + } + return rowToMessage(statement?.resultedValues?.get(0)) + } + + override suspend fun getMessages(conversationId: Int): List { + return dbQuery { + Messages.select( + Messages.id, + Messages.conversationId, + Messages.senderUserId, + Messages.content, + Messages.sentAt, + ).where { Messages.conversationId eq conversationId } + .map { rowToMessage(it)!! } + } + } + + private fun rowToMessage(row: ResultRow?): MessageResponse? { + if (row == null) return null + return MessageResponse( + id = row[Messages.id], + conversationId = row[Messages.conversationId], + senderUserId = row[Messages.senderUserId], + content = row[Messages.content], + sentAt = row[Messages.sentAt], + ) + } +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/Favorites.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/Favorites.kt new file mode 100644 index 0000000..c916701 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/Favorites.kt @@ -0,0 +1,11 @@ +package com.multiplatformkickstarter.repository.favorites + +import com.multiplatformkickstarter.repository.user.Users +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table + +object Favorites : Table() { + val id: Column = integer("id").autoIncrement().uniqueIndex() + val userId: Column = integer("userId").references(Users.userId) + val petId: Column = integer("petId") +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepository.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepository.kt new file mode 100644 index 0000000..da47935 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepository.kt @@ -0,0 +1,8 @@ +package com.multiplatformkickstarter.repository.favorites + +interface FavoritesRepository { + suspend fun addFavorite(userId: Int, petId: Int): Boolean + suspend fun removeFavorite(userId: Int, petId: Int): Boolean + suspend fun getFavorites(userId: Int): List + suspend fun isFavorite(userId: Int, petId: Int): Boolean +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepositoryImpl.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepositoryImpl.kt new file mode 100644 index 0000000..a478dd7 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/favorites/FavoritesRepositoryImpl.kt @@ -0,0 +1,47 @@ +package com.multiplatformkickstarter.repository.favorites + +import com.multiplatformkickstarter.repository.DatabaseFactory.dbQuery +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select + +class FavoritesRepositoryImpl : FavoritesRepository { + + override suspend fun addFavorite(userId: Int, petId: Int): Boolean { + if (isFavorite(userId, petId)) return false + dbQuery { + Favorites.insert { + it[Favorites.userId] = userId + it[Favorites.petId] = petId + } + } + return true + } + + override suspend fun removeFavorite(userId: Int, petId: Int): Boolean { + val deleted = dbQuery { + Favorites.deleteWhere { + (Favorites.userId eq userId) and (Favorites.petId eq petId) + } + } + return deleted > 0 + } + + override suspend fun getFavorites(userId: Int): List { + return dbQuery { + Favorites.select(Favorites.petId) + .where { Favorites.userId eq userId } + .map { it[Favorites.petId] } + } + } + + override suspend fun isFavorite(userId: Int, petId: Int): Boolean { + return dbQuery { + Favorites.select(Favorites.id) + .where { (Favorites.userId eq userId) and (Favorites.petId eq petId) } + .count() > 0 + } + } +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt new file mode 100644 index 0000000..d2690e6 --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt @@ -0,0 +1,136 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.API_VERSION +import com.multiplatformkickstarter.auth.JWT_CONFIGURATION +import com.multiplatformkickstarter.auth.UserSession +import com.multiplatformkickstarter.repository.chat.ConversationsRepository +import com.multiplatformkickstarter.repository.chat.MessagesRepository +import com.multiplatformkickstarter.repository.user.UserRepository +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.resources.Resource +import io.ktor.server.application.call +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.post +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.sessions.get +import io.ktor.server.sessions.sessions + +const val CHAT = "$API_VERSION/chat" +const val CHAT_CONVERSATIONS = "$CHAT/conversations" +const val CHAT_CONVERSATION_CREATE = "$CHAT_CONVERSATIONS/create" +const val CHAT_CONVERSATION_DELETE = "$CHAT_CONVERSATIONS/delete" +const val CHAT_MESSAGES = "$CHAT/messages" +const val CHAT_MESSAGES_SEND = "$CHAT_MESSAGES/send" + +@Resource(CHAT_CONVERSATIONS) +class ChatConversationsRoute + +@Resource(CHAT_CONVERSATION_CREATE) +class ChatConversationCreateRoute + +@Resource(CHAT_CONVERSATION_DELETE) +class ChatConversationDeleteRoute + +@Resource(CHAT_MESSAGES) +class ChatMessagesRoute + +@Resource(CHAT_MESSAGES_SEND) +class ChatMessagesSendRoute + +fun Route.chat( + conversationsRepository: ConversationsRepository, + messagesRepository: MessagesRepository, + userRepository: UserRepository, +) { + authenticate(JWT_CONFIGURATION) { + get { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + if (user == null) { + call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + return@get + } + val conversations = conversationsRepository.getConversationsForUser(user.userId) + call.respond(HttpStatusCode.OK, conversations) + } + + post { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + if (user == null) { + call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + return@post + } + val params = call.receive() + val petId = params["petId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petId") + val petName = params["petName"] + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petName") + val sellerUserId = params["sellerUserId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing sellerUserId") + + val conversation = conversationsRepository.createConversation( + petId = petId, + petName = petName, + buyerUserId = user.userId, + sellerUserId = sellerUserId, + ) + if (conversation != null) { + call.respond(HttpStatusCode.Created, conversation) + } else { + call.respond(HttpStatusCode.InternalServerError, "Failed to create conversation") + } + } + + delete { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + if (user == null) { + call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + return@delete + } + val params = call.receive() + val conversationId = params["conversationId"]?.toIntOrNull() + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing conversationId") + + val deleted = conversationsRepository.deleteConversation(conversationId, user.userId) + call.respond(if (deleted) HttpStatusCode.OK else HttpStatusCode.NotFound) + } + + get { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + if (user == null) { + call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + return@get + } + val params = call.receive() + val conversationId = params["conversationId"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing conversationId") + + val messages = messagesRepository.getMessages(conversationId) + call.respond(HttpStatusCode.OK, messages) + } + + post { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + if (user == null) { + call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + return@post + } + val params = call.receive() + val conversationId = params["conversationId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing conversationId") + val content = params["content"] + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing content") + + val message = messagesRepository.sendMessage(conversationId, user.userId, content) + if (message != null) { + call.respond(HttpStatusCode.Created, message) + } else { + call.respond(HttpStatusCode.InternalServerError, "Failed to send message") + } + } + } +} diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt new file mode 100644 index 0000000..90a15ea --- /dev/null +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt @@ -0,0 +1,76 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.API_VERSION +import com.multiplatformkickstarter.auth.JWT_CONFIGURATION +import com.multiplatformkickstarter.auth.UserSession +import com.multiplatformkickstarter.repository.favorites.FavoritesRepository +import com.multiplatformkickstarter.repository.user.UserRepository +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.resources.Resource +import io.ktor.server.application.call +import io.ktor.server.auth.authenticate +import io.ktor.server.request.receive +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.post +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.sessions.get +import io.ktor.server.sessions.sessions + +const val FAVORITES = "$API_VERSION/favorites" +const val FAVORITES_ADD = "$FAVORITES/add" +const val FAVORITES_REMOVE = "$FAVORITES/remove" + +@Resource(FAVORITES) +class FavoritesListRoute + +@Resource(FAVORITES_ADD) +class FavoritesAddRoute + +@Resource(FAVORITES_REMOVE) +class FavoritesRemoveRoute + +fun Route.favorites( + favoritesRepository: FavoritesRepository, + userRepository: UserRepository, +) { + authenticate(JWT_CONFIGURATION) { + get { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + if (user == null) { + call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + return@get + } + val petIds = favoritesRepository.getFavorites(user.userId) + call.respond(HttpStatusCode.OK, petIds) + } + + post { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + if (user == null) { + call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + return@post + } + val params = call.receive() + val petId = params["petId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petId") + val added = favoritesRepository.addFavorite(user.userId, petId) + call.respond(if (added) HttpStatusCode.Created else HttpStatusCode.Conflict) + } + + delete { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + if (user == null) { + call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + return@delete + } + val params = call.receive() + val petId = params["petId"]?.toIntOrNull() + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing petId") + val removed = favoritesRepository.removeFavorite(user.userId, petId) + call.respond(if (removed) HttpStatusCode.OK else HttpStatusCode.NotFound) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatConversation.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatConversation.kt new file mode 100644 index 0000000..58037a7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatConversation.kt @@ -0,0 +1,13 @@ +package com.multiplatformkickstarter.app.common.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatConversation( + val id: Int, + val petId: Int, + val petName: String, + val buyerUserId: Int, + val sellerUserId: Int, + val createdAt: Long, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatMessage.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatMessage.kt new file mode 100644 index 0000000..e554b70 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/common/model/ChatMessage.kt @@ -0,0 +1,12 @@ +package com.multiplatformkickstarter.app.common.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatMessage( + val id: Int, + val conversationId: Int, + val senderUserId: Int, + val content: String, + val sentAt: Long, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt index ed15ace..beb379b 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt @@ -3,6 +3,7 @@ package com.multiplatformkickstarter.app.di import androidx.compose.material3.SnackbarHostState import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.tab.TabNavigator +import com.multiplatformkickstarter.app.common.model.ChatConversation import com.multiplatformkickstarter.app.common.model.PetCategory import com.multiplatformkickstarter.app.data.repositories.LastSearchAdsMockRepository import com.multiplatformkickstarter.app.data.repositories.NearMeAdsMockRepository @@ -15,6 +16,9 @@ import com.multiplatformkickstarter.app.feature.debugmenu.repositories.GlobalApp import com.multiplatformkickstarter.app.feature.debugmenu.viewmodel.DebugMenuViewModel import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository import com.multiplatformkickstarter.app.feature.favorites.viewmodels.FavoritesViewModel +import com.multiplatformkickstarter.app.feature.inbox.ChatViewModel +import com.multiplatformkickstarter.app.feature.inbox.InboxViewModel +import com.multiplatformkickstarter.app.feature.inbox.repositories.ChatRepository import com.multiplatformkickstarter.app.ui.screens.viewmodel.PetDetailViewModel import com.multiplatformkickstarter.app.feature.petupload.repositories.PetUploadPublishRepository import com.multiplatformkickstarter.app.feature.petupload.usecases.PetUploadUseCase @@ -58,8 +62,16 @@ val commonModule = module { FavoritesViewModel(navigator, get()) } - factory { (petId: Int) -> - PetDetailViewModel(petId, get(), get()) + factory { (petId: Int, navigator: Navigator) -> + PetDetailViewModel(petId, navigator, get(), get(), get()) + } + + factory { (navigator: Navigator) -> + InboxViewModel(navigator, get()) + } + + factory { (conversation: ChatConversation, navigator: Navigator) -> + ChatViewModel(conversation, navigator, get(), get()) } factory { @@ -89,6 +101,7 @@ val commonModule = module { factoryOf(::GetLastSearchUseCase) singleOf(::FavoritesRepository) + single { ChatRepository(get(), get(), get()) } singleOf(::SessionRepository) singleOf(::ProfileRepository) singleOf(::Settings) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt new file mode 100644 index 0000000..5f8aff2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt @@ -0,0 +1,237 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.multiplatformkickstarter.app.feature.inbox + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage +import com.multiplatformkickstarter.app.localization.getCurrentLocalization +import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons +import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme +import com.multiplatformkickstarter.app.ui.theme.Typography +import org.koin.core.parameter.ParametersHolder + +class ChatScreen(private val conversation: ChatConversation) : Screen { + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val localization = getCurrentLocalization() + val viewModel = koinScreenModel( + parameters = { ParametersHolder(listOf(conversation, navigator).toMutableList(), false) } + ) + val state by viewModel.state.collectAsState() + + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(localization.chatDeleteConversation) }, + confirmButton = { + TextButton(onClick = { + showDeleteDialog = false + viewModel.onDeleteConversation() + }) { + Text(localization.chatDeleteConversation) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(localization.backLabel) + } + }, + ) + } + + MultiplatformKickstarterTheme { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { navigator.pop() }) { + Icon( + imageVector = MultiplatformKickstarterIcons.ArrowBack, + contentDescription = localization.backButton, + ) + } + }, + title = { + Text( + text = "${localization.inboxConversationWith} ${conversation.petName}", + style = Typography.get().titleMedium, + ) + }, + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = MultiplatformKickstarterIcons.Delete, + contentDescription = localization.chatDeleteConversation, + tint = MaterialTheme.colorScheme.error, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .imePadding(), + ) { + val listState = rememberLazyListState() + LaunchedEffect(state.messages.size) { + if (state.messages.isNotEmpty()) { + listState.animateScrollToItem(state.messages.size - 1) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.messages, key = { it.id }) { message -> + MessageBubble( + message = message, + isCurrentUser = message.senderUserId == state.currentUserId, + ) + } + } + + MessageInputBar( + value = state.messageInput, + onValueChange = { viewModel.onMessageTextChanged(it) }, + onSend = { viewModel.onSendMessage() }, + hint = localization.chatMessageHint, + sendLabel = localization.chatSendMessage, + ) + } + } + } + } +} + +@Composable +private fun MessageBubble(message: ChatMessage, isCurrentUser: Boolean) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start, + ) { + Box( + modifier = Modifier + .widthIn(max = 280.dp) + .background( + color = if (isCurrentUser) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + shape = RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = if (isCurrentUser) 16.dp else 4.dp, + bottomEnd = if (isCurrentUser) 4.dp else 16.dp, + ), + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = message.content, + style = Typography.get().bodyMedium, + color = if (isCurrentUser) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} + +@Composable +private fun MessageInputBar( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + hint: String, + sendLabel: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + placeholder = { Text(hint) }, + shape = RoundedCornerShape(24.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { onSend() }), + maxLines = 4, + ) + IconButton( + onClick = onSend, + enabled = value.isNotBlank(), + ) { + Icon( + imageVector = MultiplatformKickstarterIcons.Create, + contentDescription = sendLabel, + tint = if (value.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatViewModel.kt new file mode 100644 index 0000000..17244fe --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatViewModel.kt @@ -0,0 +1,73 @@ +package com.multiplatformkickstarter.app.feature.inbox + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage +import com.multiplatformkickstarter.app.data.repositories.SessionRepository +import com.multiplatformkickstarter.app.feature.inbox.repositories.ChatRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ChatViewModel( + private val conversation: ChatConversation, + private val navigator: Navigator, + private val chatRepository: ChatRepository, + private val sessionRepository: SessionRepository, +) : ScreenModel { + + private val _state = MutableStateFlow( + ChatState( + conversation = conversation, + currentUserId = sessionRepository.getUserId(), + ) + ) + val state: StateFlow = _state.asStateFlow() + + init { + loadMessages() + } + + fun loadMessages() { + screenModelScope.launch { + try { + val messages = chatRepository.getMessages(conversation.id) + _state.value = _state.value.copy(messages = messages, error = null) + } catch (e: Exception) { + _state.value = _state.value.copy(error = e.message) + } + } + } + + fun onMessageTextChanged(text: String) { + _state.value = _state.value.copy(messageInput = text) + } + + fun onSendMessage() { + val content = _state.value.messageInput.trim() + if (content.isEmpty()) return + screenModelScope.launch { + _state.value = _state.value.copy(messageInput = "") + chatRepository.sendMessage(conversation.id, content) + loadMessages() + } + } + + fun onDeleteConversation() { + screenModelScope.launch { + chatRepository.deleteConversation(conversation.id) + navigator.pop() + } + } +} + +data class ChatState( + val conversation: ChatConversation, + val messages: List = emptyList(), + val messageInput: String = "", + val currentUserId: Int = -1, + val error: String? = null, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt index e8d710c..42dbe7f 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt @@ -1,28 +1,52 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.multiplatformkickstarter.app.feature.inbox +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.multiplatformkickstarter.app.common.model.ChatConversation import com.multiplatformkickstarter.app.localization.getCurrentLocalization import com.multiplatformkickstarter.app.ui.components.EmptyLayout +import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme import com.multiplatformkickstarter.app.ui.theme.Typography +import org.koin.core.parameter.ParametersHolder -@OptIn(ExperimentalMaterial3Api::class) class InboxScreen : Screen { @Composable override fun Content() { + val navigator = LocalNavigator.currentOrThrow val localization = getCurrentLocalization() + val viewModel = koinScreenModel( + parameters = { ParametersHolder(listOf(navigator).toMutableList(), false) } + ) + val state by viewModel.state.collectAsState() MultiplatformKickstarterTheme { Scaffold( @@ -40,14 +64,87 @@ class InboxScreen : Screen { ) }, ) { paddingValues -> - Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { - EmptyLayout( - title = localization.inboxEmptyTitle, - description = localization.inboxEmptyDescription, - localization = localization, - ) {} + if (state.error != null) { + Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + EmptyLayout( + title = localization.inboxEmptyTitle, + description = state.error ?: localization.inboxEmptyDescription, + localization = localization, + ) { viewModel.loadConversations() } + } + } else if (state.conversations.isEmpty()) { + Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + EmptyLayout( + title = localization.inboxEmptyTitle, + description = localization.inboxEmptyDescription, + localization = localization, + ) {} + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + items(state.conversations, key = { it.id }) { conversation -> + ConversationItem( + conversation = conversation, + onClick = { viewModel.onConversationClicked(conversation) }, + onDelete = { viewModel.onDeleteConversation(conversation.id) }, + ) + } + } } } } } } + +@Composable +private fun ConversationItem( + conversation: ChatConversation, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + val localization = getCurrentLocalization() + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + headlineContent = { + Text( + text = "${localization.inboxConversationWith} ${conversation.petName}", + style = Typography.get().titleMedium, + ) + }, + supportingContent = { + Text( + text = "${localization.chatStartedFor} ${conversation.petName}", + style = Typography.get().bodySmall, + ) + }, + leadingContent = { + Icon( + imageVector = MultiplatformKickstarterIcons.Inbox, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + IconButton(onClick = onDelete) { + Icon( + imageVector = MultiplatformKickstarterIcons.Delete, + contentDescription = localization.chatDeleteConversation, + tint = MaterialTheme.colorScheme.error, + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt new file mode 100644 index 0000000..1ccb548 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt @@ -0,0 +1,53 @@ +package com.multiplatformkickstarter.app.feature.inbox + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.feature.inbox.repositories.ChatRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class InboxViewModel( + private val navigator: Navigator, + private val chatRepository: ChatRepository, +) : ScreenModel { + + private val _state = MutableStateFlow(InboxState()) + val state: StateFlow = _state.asStateFlow() + + init { + loadConversations() + } + + fun loadConversations() { + screenModelScope.launch { + _state.value = _state.value.copy(isLoading = true, error = null) + try { + val conversations = chatRepository.getConversations() + _state.value = _state.value.copy(conversations = conversations, isLoading = false) + } catch (e: Exception) { + _state.value = _state.value.copy(isLoading = false, error = e.message) + } + } + } + + fun onConversationClicked(conversation: ChatConversation) { + navigator.push(ChatScreen(conversation)) + } + + fun onDeleteConversation(conversationId: Int) { + screenModelScope.launch { + chatRepository.deleteConversation(conversationId) + loadConversations() + } + } +} + +data class InboxState( + val conversations: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt new file mode 100644 index 0000000..8c6dc8a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt @@ -0,0 +1,142 @@ +package com.multiplatformkickstarter.app.feature.inbox.repositories + +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage +import com.multiplatformkickstarter.app.data.repositories.SessionRepository +import com.multiplatformkickstarter.app.feature.debugmenu.repositories.GlobalAppSettingsRepository +import com.multiplatformkickstarter.app.platform.ServerEnvironment +import com.multiplatformkickstarter.app.platform.ServiceClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.forms.submitForm +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters + +private const val CONVERSATIONS_PATH = "v1/chat/conversations" +private const val CONVERSATION_CREATE_PATH = "v1/chat/conversations/create" +private const val CONVERSATION_DELETE_PATH = "v1/chat/conversations/delete" +private const val MESSAGES_PATH = "v1/chat/messages" +private const val MESSAGES_SEND_PATH = "v1/chat/messages/send" + +private val mockConversations = listOf( + ChatConversation(id = 1, petId = 1, petName = "Buddy", buyerUserId = -1, sellerUserId = 2, createdAt = 1717776000000L), + ChatConversation(id = 2, petId = 2, petName = "Luna", buyerUserId = 3, sellerUserId = -1, createdAt = 1717862400000L), +) + +private val mockMessages = mapOf( + 1 to listOf( + ChatMessage(id = 1, conversationId = 1, senderUserId = 2, content = "Hi! Is Buddy still available?", sentAt = 1717776000000L), + ChatMessage(id = 2, conversationId = 1, senderUserId = -1, content = "Yes, come meet him anytime!", sentAt = 1717779600000L), + ), + 2 to listOf( + ChatMessage(id = 3, conversationId = 2, senderUserId = -1, content = "I'm interested in Luna.", sentAt = 1717862400000L), + ChatMessage(id = 4, conversationId = 2, senderUserId = 3, content = "She's wonderful, very calm.", sentAt = 1717866000000L), + ), +) + +class ChatRepository( + private val service: ServiceClient, + private val sessionRepository: SessionRepository, + private val globalAppSettingsRepository: GlobalAppSettingsRepository, +) { + + private val baseUrl get() = ServerEnvironment.PRODUCTION.url + private val authToken get() = sessionRepository.getToken() + + suspend fun getConversations(): List { + if (globalAppSettingsRepository.isMockedContentEnabled()) return mockConversations + return try { + val response = service.httpClient.get("$baseUrl/$CONVERSATIONS_PATH") { + header("Authorization", "Bearer $authToken") + } + when { + response.status.value in 200..299 -> response.body() + else -> emptyList() + } + } catch (e: Exception) { + emptyList() + } + } + + suspend fun createConversation(petId: Int, petName: String, sellerUserId: Int): ChatConversation? { + if (globalAppSettingsRepository.isMockedContentEnabled()) { + return mockConversations.firstOrNull { it.petId == petId } + ?: ChatConversation(id = petId, petId = petId, petName = petName, buyerUserId = -1, sellerUserId = sellerUserId, createdAt = 0L) + } + return try { + val response = service.httpClient.submitForm( + url = "$baseUrl/$CONVERSATION_CREATE_PATH", + formParameters = Parameters.build { + append("petId", petId.toString()) + append("petName", petName) + append("sellerUserId", sellerUserId.toString()) + } + ) { + header("Authorization", "Bearer $authToken") + } + when { + response.status.value in 200..299 -> response.body() + response.status == HttpStatusCode.Conflict -> getConversations().firstOrNull { it.petId == petId } + else -> null + } + } catch (e: Exception) { + null + } + } + + suspend fun deleteConversation(conversationId: Int): Boolean { + if (globalAppSettingsRepository.isMockedContentEnabled()) return true + return try { + val response = service.httpClient.delete("$baseUrl/$CONVERSATION_DELETE_PATH") { + header("Authorization", "Bearer $authToken") + url { parameters.append("conversationId", conversationId.toString()) } + } + response.status == HttpStatusCode.OK + } catch (e: Exception) { + false + } + } + + suspend fun getMessages(conversationId: Int): List { + if (globalAppSettingsRepository.isMockedContentEnabled()) { + return mockMessages[conversationId] ?: emptyList() + } + return try { + val response = service.httpClient.get("$baseUrl/$MESSAGES_PATH") { + header("Authorization", "Bearer $authToken") + url { parameters.append("conversationId", conversationId.toString()) } + } + when { + response.status.value in 200..299 -> response.body() + else -> emptyList() + } + } catch (e: Exception) { + emptyList() + } + } + + suspend fun sendMessage(conversationId: Int, content: String): ChatMessage? { + if (globalAppSettingsRepository.isMockedContentEnabled()) { + return ChatMessage(id = 0, conversationId = conversationId, senderUserId = -1, content = content, sentAt = 0L) + } + return try { + val response = service.httpClient.submitForm( + url = "$baseUrl/$MESSAGES_SEND_PATH", + formParameters = Parameters.build { + append("conversationId", conversationId.toString()) + append("content", content) + } + ) { + header("Authorization", "Bearer $authToken") + } + when { + response.status.value in 200..299 -> response.body() + else -> null + } + } catch (e: Exception) { + null + } + } +} diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt index 2dc39d9..7c3d42c 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/Localization.kt @@ -66,6 +66,12 @@ interface Localization { val inboxEmptyTitle: String val inboxEmptyDescription: String + val inboxConversationWith: String + val chatSendMessage: String + val chatDeleteConversation: String + val chatMessageHint: String + val chatStartedFor: String + val detailFavorite: String val detailUnfavorite: String val homeFeaturedViewAll: String diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt index 79144e4..e5162d1 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/DeutschLocalization.kt @@ -61,6 +61,12 @@ internal object DeutschLocalization : Localization { override val inboxEmptyTitle = "Noch keine Nachrichten" override val inboxEmptyDescription = "Wenn Sie Kontakt mit einem Tierbesitzer aufnehmen, erscheinen Ihre Gespräche hier." + override val inboxConversationWith = "Chat über" + override val chatSendMessage = "Senden" + override val chatDeleteConversation = "Gespräch löschen" + override val chatMessageHint = "Nachricht eingeben..." + override val chatStartedFor = "Gespräch gestartet für" + override val detailFavorite = "Zu Favoriten hinzufügen" override val detailUnfavorite = "Aus Favoriten entfernen" override val homeFeaturedViewAll = "Alle anzeigen >" diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt index a00b067..7933f1d 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/EnglishLocalization.kt @@ -61,6 +61,12 @@ internal object EnglishLocalization : Localization { override val inboxEmptyTitle = "No messages yet" override val inboxEmptyDescription = "When you contact a pet owner, your conversations will appear here." + override val inboxConversationWith = "Chat about" + override val chatSendMessage = "Send" + override val chatDeleteConversation = "Delete conversation" + override val chatMessageHint = "Type a message..." + override val chatStartedFor = "Conversation started for" + override val detailFavorite = "Add to favorites" override val detailUnfavorite = "Remove from favorites" override val homeFeaturedViewAll = "See all >" diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt index 01f1554..c092d5c 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/FrenchLocalization.kt @@ -61,6 +61,12 @@ internal object FrenchLocalization : Localization { override val inboxEmptyTitle = "Pas encore de messages" override val inboxEmptyDescription = "Lorsque vous contactez un propriétaire d'animal, vos conversations apparaîtront ici." + override val inboxConversationWith = "Chat sur" + override val chatSendMessage = "Envoyer" + override val chatDeleteConversation = "Supprimer la conversation" + override val chatMessageHint = "Écrire un message..." + override val chatStartedFor = "Conversation démarrée pour" + override val detailFavorite = "Ajouter aux favoris" override val detailUnfavorite = "Retirer des favoris" override val homeFeaturedViewAll = "Voir tout >" diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt index 881e56d..982fdec 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/ItalianLocalization.kt @@ -61,6 +61,12 @@ internal object ItalianLocalization : Localization { override val inboxEmptyTitle = "Nessun messaggio ancora" override val inboxEmptyDescription = "Quando contatti il proprietario di un animale, le conversazioni appariranno qui." + override val inboxConversationWith = "Chat su" + override val chatSendMessage = "Invia" + override val chatDeleteConversation = "Elimina conversazione" + override val chatMessageHint = "Scrivi un messaggio..." + override val chatStartedFor = "Conversazione avviata per" + override val detailFavorite = "Aggiungi ai preferiti" override val detailUnfavorite = "Rimuovi dai preferiti" override val homeFeaturedViewAll = "Vedi tutto >" diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt index fa90990..d719c45 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/localization/translations/SpanishLocalization.kt @@ -61,6 +61,12 @@ internal object SpanishLocalization : Localization { override val inboxEmptyTitle = "Aún no tienes mensajes" override val inboxEmptyDescription = "Cuando contactes con el dueño de una mascota, tus conversaciones aparecerán aquí." + override val inboxConversationWith = "Chat sobre" + override val chatSendMessage = "Enviar" + override val chatDeleteConversation = "Eliminar conversación" + override val chatMessageHint = "Escribe un mensaje..." + override val chatStartedFor = "Conversación iniciada para" + override val detailFavorite = "Añadir a favoritos" override val detailUnfavorite = "Eliminar de favoritos" override val homeFeaturedViewAll = "Ver todo >" diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt index 5e4a33e..4118d90 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt @@ -52,7 +52,7 @@ class PetDetailScreen(private val petId: Int) : Screen { override fun Content() { val currentNavigator = LocalNavigator.currentOrThrow val viewModel = koinScreenModel( - parameters = { ParametersHolder(listOf(petId).toMutableList(), false) } + parameters = { ParametersHolder(listOf(petId, currentNavigator).toMutableList(), false) } ) val state by viewModel.state.collectAsState() @@ -63,6 +63,7 @@ class PetDetailScreen(private val petId: Int) : Screen { petImageUrl = pet.images[0], isFavorite = state.isFavorite, onFavoriteToggled = { viewModel.onFavoriteToggled() }, + onAdoptClicked = { viewModel.onAdoptClicked() }, onClose = { currentNavigator.pop() }, ) } @@ -76,6 +77,7 @@ fun PetDetailView( petImageUrl: String, isFavorite: Boolean, onFavoriteToggled: () -> Unit, + onAdoptClicked: () -> Unit, onClose: () -> Unit, ) { val scrollState = rememberScrollState() @@ -169,7 +171,7 @@ fun PetDetailView( ) } Button( - onClick = {}, + onClick = onAdoptClicked, colors = ButtonDefaults.buttonColors(), modifier = Modifier .fillMaxWidth() diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt index 72486ea..63c0101 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/viewmodel/PetDetailViewModel.kt @@ -2,19 +2,25 @@ package com.multiplatformkickstarter.app.ui.screens.viewmodel import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator import com.multiplatformkickstarter.app.common.model.PetModel import com.multiplatformkickstarter.app.data.repositories.LastSearchAdsMockRepository import com.multiplatformkickstarter.app.feature.favorites.repositories.FavoritesRepository +import com.multiplatformkickstarter.app.feature.inbox.ChatScreen +import com.multiplatformkickstarter.app.feature.inbox.repositories.ChatRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch class PetDetailViewModel( private val petId: Int, + private val navigator: Navigator, private val lastSearchAdsMockRepository: LastSearchAdsMockRepository, private val favoritesRepository: FavoritesRepository, + private val chatRepository: ChatRepository, ) : ScreenModel { private val _state = MutableStateFlow( @@ -34,6 +40,20 @@ class PetDetailViewModel( fun onFavoriteToggled() { favoritesRepository.toggleFavorite(petId) } + + fun onAdoptClicked() { + val pet = _state.value.pet ?: return + screenModelScope.launch { + val conversation = chatRepository.createConversation( + petId = pet.id, + petName = pet.title, + sellerUserId = pet.userId, + ) + if (conversation != null) { + navigator.push(ChatScreen(conversation)) + } + } + } } data class PetDetailState( From 63fd80b1c252adf356d33828336eb9af7aaaec42 Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 15:01:57 +0200 Subject: [PATCH 07/18] Chat correctly displayed --- .../app/di/CommonModule.kt | 4 +-- .../app/feature/inbox/InboxScreen.kt | 8 +---- .../app/feature/inbox/InboxViewModel.kt | 6 ++-- .../inbox/repositories/ChatRepository.kt | 29 +++++++++++++++---- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt index beb379b..fb085f9 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/di/CommonModule.kt @@ -66,8 +66,8 @@ val commonModule = module { PetDetailViewModel(petId, navigator, get(), get(), get()) } - factory { (navigator: Navigator) -> - InboxViewModel(navigator, get()) + factory { + InboxViewModel(get(), get()) } factory { (conversation: ChatConversation, navigator: Navigator) -> diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt index 42dbe7f..c274375 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt @@ -27,25 +27,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow import com.multiplatformkickstarter.app.common.model.ChatConversation import com.multiplatformkickstarter.app.localization.getCurrentLocalization import com.multiplatformkickstarter.app.ui.components.EmptyLayout import com.multiplatformkickstarter.app.ui.icon.MultiplatformKickstarterIcons import com.multiplatformkickstarter.app.ui.theme.MultiplatformKickstarterTheme import com.multiplatformkickstarter.app.ui.theme.Typography -import org.koin.core.parameter.ParametersHolder class InboxScreen : Screen { @Composable override fun Content() { - val navigator = LocalNavigator.currentOrThrow val localization = getCurrentLocalization() - val viewModel = koinScreenModel( - parameters = { ParametersHolder(listOf(navigator).toMutableList(), false) } - ) + val viewModel = koinScreenModel() val state by viewModel.state.collectAsState() MultiplatformKickstarterTheme { diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt index 1ccb548..46cd7b1 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxViewModel.kt @@ -2,17 +2,17 @@ package com.multiplatformkickstarter.app.feature.inbox import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope -import cafe.adriel.voyager.navigator.Navigator import com.multiplatformkickstarter.app.common.model.ChatConversation import com.multiplatformkickstarter.app.feature.inbox.repositories.ChatRepository +import com.multiplatformkickstarter.app.platform.RootNavigatorRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class InboxViewModel( - private val navigator: Navigator, private val chatRepository: ChatRepository, + private val rootNavigatorRepository: RootNavigatorRepository, ) : ScreenModel { private val _state = MutableStateFlow(InboxState()) @@ -35,7 +35,7 @@ class InboxViewModel( } fun onConversationClicked(conversation: ChatConversation) { - navigator.push(ChatScreen(conversation)) + rootNavigatorRepository.navigator.push(ChatScreen(conversation)) } fun onDeleteConversation(conversationId: Int) { diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt index 8c6dc8a..ed0ad82 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/ChatRepository.kt @@ -25,16 +25,30 @@ private val mockConversations = listOf( ChatConversation(id = 2, petId = 2, petName = "Luna", buyerUserId = 3, sellerUserId = -1, createdAt = 1717862400000L), ) -private val mockMessages = mapOf( - 1 to listOf( +private val mockAutoReplies = listOf( + "That sounds great!", + "Sure, when would you like to meet?", + "He/She is very friendly and loves cuddles.", + "The adoption fee is already included.", + "Yes, all vaccinations are up to date!", + "Feel free to come by this weekend.", + "I'll send you more photos shortly.", + "We'd love for them to find a good home!", + "Let me know if you have any other questions.", + "They get along well with other pets too.", +) + +private val mockMessageStore: MutableMap> = mutableMapOf( + 1 to mutableListOf( ChatMessage(id = 1, conversationId = 1, senderUserId = 2, content = "Hi! Is Buddy still available?", sentAt = 1717776000000L), ChatMessage(id = 2, conversationId = 1, senderUserId = -1, content = "Yes, come meet him anytime!", sentAt = 1717779600000L), ), - 2 to listOf( + 2 to mutableListOf( ChatMessage(id = 3, conversationId = 2, senderUserId = -1, content = "I'm interested in Luna.", sentAt = 1717862400000L), ChatMessage(id = 4, conversationId = 2, senderUserId = 3, content = "She's wonderful, very calm.", sentAt = 1717866000000L), ), ) +private var mockNextId = 100 class ChatRepository( private val service: ServiceClient, @@ -101,7 +115,7 @@ class ChatRepository( suspend fun getMessages(conversationId: Int): List { if (globalAppSettingsRepository.isMockedContentEnabled()) { - return mockMessages[conversationId] ?: emptyList() + return mockMessageStore.getOrPut(conversationId) { mutableListOf() }.toList() } return try { val response = service.httpClient.get("$baseUrl/$MESSAGES_PATH") { @@ -119,7 +133,12 @@ class ChatRepository( suspend fun sendMessage(conversationId: Int, content: String): ChatMessage? { if (globalAppSettingsRepository.isMockedContentEnabled()) { - return ChatMessage(id = 0, conversationId = conversationId, senderUserId = -1, content = content, sentAt = 0L) + val store = mockMessageStore.getOrPut(conversationId) { mutableListOf() } + val sent = ChatMessage(id = mockNextId++, conversationId = conversationId, senderUserId = -1, content = content, sentAt = 0L) + store.add(sent) + val reply = ChatMessage(id = mockNextId++, conversationId = conversationId, senderUserId = 2, content = mockAutoReplies[(store.size) % mockAutoReplies.size], sentAt = 0L) + store.add(reply) + return sent } return try { val response = service.httpClient.submitForm( From e8aa5b4139ce57b4b71ce0dd5cff9464bb8f6880 Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 15:04:58 +0200 Subject: [PATCH 08/18] Updated README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c0048b5..371c5d8 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ For bugs, questions and discussions please use [GitHub Issues](https://github.co ## License -Copyright 2023–2025 Multiplatform Kickstarter +Copyright 2023–2026 Multiplatform Kickstarter Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 843e961cec880924d1487f43691b48a547bd9b8e Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 15:13:56 +0200 Subject: [PATCH 09/18] CI issues --- .../repository/chat/Conversations.kt | 4 +- .../routes/ChatRoutes.kt | 141 ++++++++---------- 2 files changed, 67 insertions(+), 78 deletions(-) diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt index 3975f7b..ff06bea 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/repository/chat/Conversations.kt @@ -4,10 +4,12 @@ import com.multiplatformkickstarter.repository.user.Users import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.Table +private const val PET_NAME_MAX_LENGTH = 128 + object Conversations : Table() { val id: Column = integer("id").autoIncrement().uniqueIndex() val petId: Column = integer("petId") - val petName: Column = varchar("petName", 128) + val petName: Column = varchar("petName", PET_NAME_MAX_LENGTH) val buyerUserId: Column = integer("buyerUserId").references(Users.userId) val sellerUserId: Column = integer("sellerUserId").references(Users.userId) val createdAt: Column = long("createdAt") diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt index d2690e6..0cceb6b 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt @@ -48,89 +48,76 @@ fun Route.chat( userRepository: UserRepository, ) { authenticate(JWT_CONFIGURATION) { - get { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } - if (user == null) { - call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - return@get - } - val conversations = conversationsRepository.getConversationsForUser(user.userId) - call.respond(HttpStatusCode.OK, conversations) - } - - post { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } - if (user == null) { - call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - return@post - } - val params = call.receive() - val petId = params["petId"]?.toIntOrNull() - ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petId") - val petName = params["petName"] - ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petName") - val sellerUserId = params["sellerUserId"]?.toIntOrNull() - ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing sellerUserId") - - val conversation = conversationsRepository.createConversation( - petId = petId, - petName = petName, - buyerUserId = user.userId, - sellerUserId = sellerUserId, - ) - if (conversation != null) { - call.respond(HttpStatusCode.Created, conversation) - } else { - call.respond(HttpStatusCode.InternalServerError, "Failed to create conversation") - } - } + chatConversationRoutes(conversationsRepository, userRepository) + chatMessageRoutes(messagesRepository, userRepository) + } +} - delete { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } - if (user == null) { - call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - return@delete - } - val params = call.receive() - val conversationId = params["conversationId"]?.toIntOrNull() - ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing conversationId") +private fun Route.chatConversationRoutes( + conversationsRepository: ConversationsRepository, + userRepository: UserRepository, +) { + get { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + ?: return@get call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + call.respond(HttpStatusCode.OK, conversationsRepository.getConversationsForUser(user.userId)) + } - val deleted = conversationsRepository.deleteConversation(conversationId, user.userId) - call.respond(if (deleted) HttpStatusCode.OK else HttpStatusCode.NotFound) + post { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + ?: return@post call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val params = call.receive() + val petId = params["petId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petId") + val petName = params["petName"] + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petName") + val sellerUserId = params["sellerUserId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing sellerUserId") + val conversation = conversationsRepository.createConversation(petId, petName, user.userId, sellerUserId) + if (conversation != null) { + call.respond(HttpStatusCode.Created, conversation) + } else { + call.respond(HttpStatusCode.InternalServerError, "Failed to create conversation") } + } - get { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } - if (user == null) { - call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - return@get - } - val params = call.receive() - val conversationId = params["conversationId"]?.toIntOrNull() - ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing conversationId") - - val messages = messagesRepository.getMessages(conversationId) - call.respond(HttpStatusCode.OK, messages) - } + delete { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + ?: return@delete call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val params = call.receive() + val conversationId = params["conversationId"]?.toIntOrNull() + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing conversationId") + val deleted = conversationsRepository.deleteConversation(conversationId, user.userId) + call.respond(if (deleted) HttpStatusCode.OK else HttpStatusCode.NotFound) + } +} - post { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } - if (user == null) { - call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - return@post - } - val params = call.receive() - val conversationId = params["conversationId"]?.toIntOrNull() - ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing conversationId") - val content = params["content"] - ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing content") +private fun Route.chatMessageRoutes( + messagesRepository: MessagesRepository, + userRepository: UserRepository, +) { + get { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + ?: return@get call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val params = call.receive() + val conversationId = params["conversationId"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing conversationId") + call.respond(HttpStatusCode.OK, messagesRepository.getMessages(conversationId)) + } - val message = messagesRepository.sendMessage(conversationId, user.userId, content) - if (message != null) { - call.respond(HttpStatusCode.Created, message) - } else { - call.respond(HttpStatusCode.InternalServerError, "Failed to send message") - } + post { + val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + ?: return@post call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") + val params = call.receive() + val conversationId = params["conversationId"]?.toIntOrNull() + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing conversationId") + val content = params["content"] + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing content") + val message = messagesRepository.sendMessage(conversationId, user.userId, content) + if (message != null) { + call.respond(HttpStatusCode.Created, message) + } else { + call.respond(HttpStatusCode.InternalServerError, "Failed to send message") } } } From 39c8d2a30556a1dcd72c955a3a4a89d5a8ae1ddc Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 15:34:09 +0200 Subject: [PATCH 10/18] Added Tests and Updated README.md --- .github/workflows/basic.yml | 57 ++++-- README.md | 29 ++- backend/build.gradle.kts | 4 + .../ApplicationTest.kt | 15 +- .../multiplatformkickstarter/TestHelpers.kt | 183 ++++++++++++++++++ .../routes/ChatRoutesTest.kt | 137 +++++++++++++ .../routes/FavoritesRoutesTest.kt | 99 ++++++++++ .../routes/UserRoutesTest.kt | 109 +++++++++++ shared/build.gradle.kts | 1 + .../repositories/SessionRepositoryTest.kt | 61 ++++++ .../feature/inbox/ChatRepositoryMockTest.kt | 82 ++++++++ .../repositories/FakeMockedChatRepository.kt | 57 ++++++ 12 files changed, 806 insertions(+), 28 deletions(-) create mode 100644 backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt create mode 100644 backend/src/test/kotlin/com/multiplatformkickstarter/routes/ChatRoutesTest.kt create mode 100644 backend/src/test/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutesTest.kt create mode 100644 backend/src/test/kotlin/com/multiplatformkickstarter/routes/UserRoutesTest.kt create mode 100644 shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/data/repositories/SessionRepositoryTest.kt create mode 100644 shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatRepositoryMockTest.kt create mode 100644 shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/FakeMockedChatRepository.kt diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index b24807d..d8cbaf5 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -1,4 +1,4 @@ -name: Basic CI +name: CI on: push: @@ -7,20 +7,47 @@ on: branches: [ "master" ] jobs: - build: - + build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - cache: gradle - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew build + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build + run: ./gradlew assemble + + - name: Run backend tests + env: + JWT_SECRET: test-secret-for-ci-only + run: ./gradlew :backend:test + + - name: Run shared KMP tests (JVM) + run: ./gradlew :shared:jvmTest + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + backend/build/reports/tests/ + shared/build/reports/tests/ + retention-days: 7 + + - name: Publish test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: | + backend/build/test-results/**/*.xml + shared/build/test-results/**/*.xml diff --git a/README.md b/README.md index 371c5d8..241b69f 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ Use it as a launchpad for your own KMP application. - ✅ **Home** — pet listings with "Near Me" grid and "Last Search" carousel - ✅ **Search Listing** — filterable pet list by category - ✅ **Pet Detail** — full pet profile with favorite toggle -- ✅ **Favorites** — lists all favorited pets with unfavorite action -- ✅ **Inbox** — empty-state messages screen ready to extend +- ✅ **Favorites** — lists all favorited pets with unfavorite action, backed by a real API +- ✅ **Inbox** — conversation list with per-conversation chat screen, message bubbles, and send input +- ✅ **Chat** — real-time-style messaging per conversation with auto-reply in debug/mock mode - ✅ **Pet Upload** — form to submit a new pet listing - ✅ **Profile** — logged-in user profile with ratings and options - ✅ **Profile Detail** — public user profile with their listings @@ -82,6 +83,7 @@ Use it as a launchpad for your own KMP application. - ✅ **Kermit** — multiplatform logging - ✅ **Detekt** — static code analysis - ✅ **Ktlint** — code style linter +- ✅ **Automated tests** — backend route tests (Ktor `testApplication`) + shared KMP unit tests - ✅ **Version Catalogs** (`libs.versions.toml`) - ✅ **Firebase Analytics** (Android) - ✅ **Ktor Backend** — PostgreSQL + Tomcat + OpenAPI / Swagger @@ -93,7 +95,7 @@ Use it as a launchpad for your own KMP application. - ✅ Accessibility + color-accessible themes - ✅ Edge-to-edge display with `enableEdgeToEdge()` - ✅ Favorites powered by `StateFlow>` — reactive across the whole app -- ✅ Basic CI (GitHub Actions) +- ✅ CI (GitHub Actions) — build, lint, detekt, backend tests, shared KMP tests, test report upload --- @@ -153,10 +155,21 @@ Open `iosApp/iosApp.xcodeproj` in Xcode and run on a simulator or device. ### 5. Run the Backend ```bash -./gradlew :backend:run +JWT_SECRET=your-secret ./gradlew :backend:run ``` > Requires a running PostgreSQL instance. Configure the connection in `backend/src/main/resources/application.conf`. +> `JWT_SECRET` must be set as an environment variable. For local tests it defaults to a safe fallback automatically. + +### 6. Run Tests + +```bash +# Backend route tests +./gradlew :backend:test + +# Shared KMP unit tests (JVM) +./gradlew :shared:jvmTest +``` --- @@ -177,6 +190,14 @@ The Ktor backend exposes a REST API secured with **JWT authentication**: | `GET` | `/v1/profile` | Get current user profile | | `POST` | `/v1/profile/create` | Create user profile | | `PATCH` | `/v1/profile/update` | Update user profile | +| `GET` | `/v1/favorites` | List favorited pet IDs | +| `POST` | `/v1/favorites/add` | Add a pet to favorites | +| `DELETE` | `/v1/favorites/remove` | Remove a pet from favorites | +| `GET` | `/v1/chat/conversations` | List conversations for current user | +| `POST` | `/v1/chat/conversations/create` | Create a new conversation | +| `DELETE` | `/v1/chat/conversations/delete` | Delete a conversation | +| `GET` | `/v1/chat/messages` | Get messages for a conversation | +| `POST` | `/v1/chat/messages/send` | Send a message | OpenAPI docs available at `/openapi` when the server is running. diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index ae4b048..fe90e05 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -52,3 +52,7 @@ dependencies { testImplementation(libs.ktor.server.test.host) testImplementation(libs.kotlin.test.junit) } + +tasks.withType { + environment("JWT_SECRET", System.getenv("JWT_SECRET") ?: "test-secret-for-local-dev") +} diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/ApplicationTest.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/ApplicationTest.kt index 0b6e948..6f08204 100644 --- a/backend/src/test/kotlin/com/multiplatformkickstarter/ApplicationTest.kt +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/ApplicationTest.kt @@ -9,14 +9,11 @@ import kotlin.test.assertEquals class ApplicationTest { @Test - fun testRoot() = - testApplication { - application { - configureGeneralRouting() - } - client.get("/").apply { - assertEquals(HttpStatusCode.OK, status) - assertEquals("Hello World!", bodyAsText()) - } + fun `GET slash returns Hello World`() = testApplication { + application { configureGeneralRouting() } + client.get("/").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals("Hello World!", bodyAsText()) } + } } diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt new file mode 100644 index 0000000..c74caf1 --- /dev/null +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt @@ -0,0 +1,183 @@ +package com.multiplatformkickstarter + +import com.multiplatformkickstarter.app.common.model.GeoLocation +import com.multiplatformkickstarter.app.common.model.PetAge +import com.multiplatformkickstarter.app.common.model.PetCategory +import com.multiplatformkickstarter.app.common.model.PetGender +import com.multiplatformkickstarter.app.common.model.PetModel +import com.multiplatformkickstarter.app.common.model.PetSize +import com.multiplatformkickstarter.app.common.model.PetStatus +import com.multiplatformkickstarter.auth.JWT_CONFIGURATION +import com.multiplatformkickstarter.auth.JwtService +import com.multiplatformkickstarter.auth.UserSession +import com.multiplatformkickstarter.models.ConversationResponse +import com.multiplatformkickstarter.models.DatabaseUser +import com.multiplatformkickstarter.models.MessageResponse +import com.multiplatformkickstarter.repository.chat.ConversationsRepository +import com.multiplatformkickstarter.repository.chat.MessagesRepository +import com.multiplatformkickstarter.repository.favorites.FavoritesRepository +import com.multiplatformkickstarter.repository.pets.PetsRepository +import com.multiplatformkickstarter.repository.user.UserRepository +import com.multiplatformkickstarter.routes.chat +import com.multiplatformkickstarter.routes.favorites +import com.multiplatformkickstarter.routes.pets +import com.multiplatformkickstarter.routes.users +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.jwt.jwt +import io.ktor.server.resources.Resources +import io.ktor.server.routing.routing +import io.ktor.server.sessions.Sessions +import io.ktor.server.sessions.directorySessionStorage +import io.ktor.server.sessions.header +import io.ktor.server.testing.ApplicationTestBuilder +import java.io.File + +// ── Shared test fixtures ────────────────────────────────────────────────────── + +val testUser = DatabaseUser(userId = 1, email = "test@test.com", name = "Test User", passwordHash = "testhash") + +fun makePetModel(id: Int = 1, userId: Int = 1, title: String = "Buddy") = PetModel( + id = id, userId = userId, title = title, description = "A lovely pet", + images = listOf("http://example.com/img.jpg"), category = PetCategory.DOGS, + location = GeoLocation(41.4, 2.1), published = "2024-01-01T10:00", + modified = null, breed = "Labrador", age = PetAge.YOUNG, gender = PetGender.MALE, + size = PetSize.MEDIUM, color = "Brown", status = PetStatus.ADOPTABLE, shelterId = null, +) + +// ── Fake repositories ───────────────────────────────────────────────────────── + +class FakeUserRepository(seed: List = listOf(testUser)) : UserRepository { + private val users = seed.toMutableList() + + override suspend fun addUser(email: String, name: String, passwordHash: String): DatabaseUser? { + if (users.any { it.email == email }) return null + val user = DatabaseUser(userId = users.size + 1, email = email, name = name, passwordHash = passwordHash) + users.add(user) + return user + } + + override suspend fun findUser(userId: Int): DatabaseUser? = users.find { it.userId == userId } + + override suspend fun findUserByEmail(email: String): DatabaseUser? = users.find { it.email == email } +} + +class FakePetsRepository : PetsRepository { + private val pets = mutableListOf() + private var nextId = 1 + + override suspend fun addPet( + userId: Int, title: String, description: String, images: String, + category: Int, location: String, published: String, breed: String, + age: String, gender: String, size: String, color: String, status: String, shelterId: Int?, + ): PetModel { + val pet = makePetModel(id = nextId++, userId = userId, title = title) + pets.add(pet) + return pet + } + + override suspend fun getPet(petId: Int): PetModel = pets.first { it.id == petId } + + override suspend fun getPets(userId: Int): List = pets.filter { it.userId == userId } + + override suspend fun delete(petId: Int) { pets.removeIf { it.id == petId } } + + override suspend fun updatePet( + petId: Int, title: String?, description: String?, images: String?, + location: String?, modified: String?, breed: String?, age: String?, + gender: String?, size: String?, color: String?, status: String?, shelterId: Int?, + ): PetModel? { + val idx = pets.indexOfFirst { it.id == petId }.takeIf { it >= 0 } ?: return null + val updated = pets[idx].copy(title = title ?: pets[idx].title) + pets[idx] = updated + return updated + } +} + +class FakeFavoritesRepository : FavoritesRepository { + private val favorites = mutableSetOf>() + + override suspend fun addFavorite(userId: Int, petId: Int): Boolean = favorites.add(userId to petId) + + override suspend fun removeFavorite(userId: Int, petId: Int): Boolean = favorites.remove(userId to petId) + + override suspend fun getFavorites(userId: Int): List = + favorites.filter { it.first == userId }.map { it.second } + + override suspend fun isFavorite(userId: Int, petId: Int): Boolean = (userId to petId) in favorites +} + +class FakeConversationsRepository : ConversationsRepository { + val conversations = mutableListOf() + private var nextId = 1 + + override suspend fun createConversation( + petId: Int, petName: String, buyerUserId: Int, sellerUserId: Int, + ): ConversationResponse { + val conv = ConversationResponse(nextId++, petId, petName, buyerUserId, sellerUserId, 0L) + conversations.add(conv) + return conv + } + + override suspend fun getConversationsForUser(userId: Int): List = + conversations.filter { it.buyerUserId == userId || it.sellerUserId == userId } + + override suspend fun getConversation(conversationId: Int): ConversationResponse? = + conversations.find { it.id == conversationId } + + override suspend fun deleteConversation(conversationId: Int, userId: Int): Boolean = + conversations.removeIf { it.id == conversationId } +} + +class FakeMessagesRepository : MessagesRepository { + val messages = mutableListOf() + private var nextId = 1 + + override suspend fun sendMessage(conversationId: Int, senderUserId: Int, content: String): MessageResponse { + val msg = MessageResponse(nextId++, conversationId, senderUserId, content, 0L) + messages.add(msg) + return msg + } + + override suspend fun getMessages(conversationId: Int): List = + messages.filter { it.conversationId == conversationId } +} + +// ── Application builder helper ──────────────────────────────────────────────── + +fun ApplicationTestBuilder.setupTestApp( + userRepository: UserRepository = FakeUserRepository(), + petsRepository: PetsRepository = FakePetsRepository(), + favoritesRepository: FavoritesRepository = FakeFavoritesRepository(), + conversationsRepository: ConversationsRepository = FakeConversationsRepository(), + messagesRepository: MessagesRepository = FakeMessagesRepository(), +) { + val jwtService = JwtService() + val hashFunction = { s: String -> s } + + application { + install(Resources) + install(Sessions) { + header("user_session", directorySessionStorage(File("build/.sessions-test"))) + } + install(Authentication) { + jwt(JWT_CONFIGURATION) { + verifier(jwtService.verifier) + realm = "Test" + validate { + val id = it.payload.getClaim("id").asInt() + userRepository.findUser(id) + } + } + } + configureSerialization() + routing { + users(userRepository, jwtService, hashFunction) + pets(petsRepository, userRepository) + favorites(favoritesRepository, userRepository) + chat(conversationsRepository, messagesRepository, userRepository) + } + } +} + +fun JwtService.tokenForUser(user: DatabaseUser = testUser): String = generateToken(user) diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/routes/ChatRoutesTest.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/ChatRoutesTest.kt new file mode 100644 index 0000000..30693bf --- /dev/null +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/ChatRoutesTest.kt @@ -0,0 +1,137 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.FakeConversationsRepository +import com.multiplatformkickstarter.FakeMessagesRepository +import com.multiplatformkickstarter.auth.JwtService +import com.multiplatformkickstarter.setupTestApp +import com.multiplatformkickstarter.testUser +import com.multiplatformkickstarter.tokenForUser +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.forms.submitForm +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.server.testing.testApplication +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ChatRoutesTest { + + private val jwtService = JwtService() + private val token get() = jwtService.tokenForUser() + + @Test + fun `get conversations without auth returns 401`() = testApplication { + setupTestApp() + assertEquals(HttpStatusCode.Unauthorized, client.get(CHAT_CONVERSATIONS).status) + } + + @Test + fun `get conversations returns empty list for new user`() = testApplication { + setupTestApp() + val response = client.get(CHAT_CONVERSATIONS) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `create conversation returns 201`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = CHAT_CONVERSATION_CREATE, + formParameters = Parameters.build { + append("petId", "1") + append("petName", "Buddy") + append("sellerUserId", "2") + }, + ) { header("Authorization", "Bearer $token") } + assertEquals(HttpStatusCode.Created, response.status) + } + + @Test + fun `create conversation with missing petId returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = CHAT_CONVERSATION_CREATE, + formParameters = Parameters.build { append("petName", "Buddy") }, + ) { header("Authorization", "Bearer $token") } + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `created conversation appears in conversations list`() = testApplication { + val convRepo = FakeConversationsRepository() + setupTestApp(conversationsRepository = convRepo) + convRepo.createConversation(1, "Buddy", testUser.userId, 2) + val response = client.get(CHAT_CONVERSATIONS) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `delete conversation returns 200`() = testApplication { + val convRepo = FakeConversationsRepository() + setupTestApp(conversationsRepository = convRepo) + convRepo.createConversation(1, "Buddy", testUser.userId, 2) + val response = client.delete(CHAT_CONVERSATION_DELETE) { + header("Authorization", "Bearer $token") + url { parameters.append("conversationId", "1") } + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `delete non-existing conversation returns 404`() = testApplication { + setupTestApp() + val response = client.delete(CHAT_CONVERSATION_DELETE) { + header("Authorization", "Bearer $token") + url { parameters.append("conversationId", "99") } + } + assertEquals(HttpStatusCode.NotFound, response.status) + } + + @Test + fun `get messages without auth returns 401`() = testApplication { + setupTestApp() + assertEquals(HttpStatusCode.Unauthorized, client.get(CHAT_MESSAGES).status) + } + + @Test + fun `send message returns 201`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = CHAT_MESSAGES_SEND, + formParameters = Parameters.build { + append("conversationId", "1") + append("content", "Hello!") + }, + ) { header("Authorization", "Bearer $token") } + assertEquals(HttpStatusCode.Created, response.status) + } + + @Test + fun `send message with missing content returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = CHAT_MESSAGES_SEND, + formParameters = Parameters.build { append("conversationId", "1") }, + ) { header("Authorization", "Bearer $token") } + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `sent message appears in message list`() = testApplication { + val msgRepo = FakeMessagesRepository() + setupTestApp(messagesRepository = msgRepo) + msgRepo.sendMessage(1, testUser.userId, "Hello!") + val response = client.get(CHAT_MESSAGES) { + header("Authorization", "Bearer $token") + url { parameters.append("conversationId", "1") } + } + assertEquals(HttpStatusCode.OK, response.status) + } +} diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutesTest.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutesTest.kt new file mode 100644 index 0000000..2447f73 --- /dev/null +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutesTest.kt @@ -0,0 +1,99 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.FakeFavoritesRepository +import com.multiplatformkickstarter.FakeUserRepository +import com.multiplatformkickstarter.auth.JwtService +import com.multiplatformkickstarter.setupTestApp +import com.multiplatformkickstarter.testUser +import com.multiplatformkickstarter.tokenForUser +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.forms.submitForm +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.server.testing.testApplication +import kotlin.test.Test +import kotlin.test.assertEquals + +class FavoritesRoutesTest { + + private val jwtService = JwtService() + private val token get() = jwtService.tokenForUser() + + @Test + fun `get favorites without auth returns 401`() = testApplication { + setupTestApp() + val response = client.get(FAVORITES) + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `get favorites returns empty list for new user`() = testApplication { + setupTestApp() + val response = client.get(FAVORITES) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `add favorite returns 201`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = FAVORITES_ADD, + formParameters = Parameters.build { append("petId", "42") }, + ) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.Created, response.status) + } + + @Test + fun `add same favorite twice returns conflict`() = testApplication { + val favRepo = FakeFavoritesRepository() + setupTestApp(favoritesRepository = favRepo) + val params = Parameters.build { append("petId", "42") } + client.submitForm(url = FAVORITES_ADD, formParameters = params) { + header("Authorization", "Bearer $token") + } + val second = client.submitForm(url = FAVORITES_ADD, formParameters = params) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.Conflict, second.status) + } + + @Test + fun `add favorite with missing petId returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = FAVORITES_ADD, + formParameters = Parameters.build {}, + ) { + header("Authorization", "Bearer $token") + } + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `remove existing favorite returns 200`() = testApplication { + val favRepo = FakeFavoritesRepository() + setupTestApp(favoritesRepository = favRepo) + favRepo.addFavorite(testUser.userId, 42) + val response = client.delete(FAVORITES_REMOVE) { + header("Authorization", "Bearer $token") + url { parameters.append("petId", "42") } + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `remove non-existing favorite returns 404`() = testApplication { + setupTestApp() + val response = client.delete(FAVORITES_REMOVE) { + header("Authorization", "Bearer $token") + url { parameters.append("petId", "99") } + } + assertEquals(HttpStatusCode.NotFound, response.status) + } +} diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/routes/UserRoutesTest.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/UserRoutesTest.kt new file mode 100644 index 0000000..6ff0920 --- /dev/null +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/routes/UserRoutesTest.kt @@ -0,0 +1,109 @@ +package com.multiplatformkickstarter.routes + +import com.multiplatformkickstarter.FakeUserRepository +import com.multiplatformkickstarter.setupTestApp +import com.multiplatformkickstarter.testUser +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.post +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.server.testing.testApplication +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class UserRoutesTest { + + @Test + fun `create user returns 201 and jwt token`() = testApplication { + setupTestApp(userRepository = FakeUserRepository(emptyList())) + val response = client.submitForm( + url = USER_CREATE, + formParameters = Parameters.build { + append("email", "new@test.com") + append("name", "New User") + append("password", "secret") + }, + ) + assertEquals(HttpStatusCode.Created, response.status) + val token = response.bodyAsText() + assertTrue(token.isNotBlank(), "Expected JWT token in response body") + } + + @Test + fun `create user with duplicate email returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_CREATE, + formParameters = Parameters.build { + append("email", testUser.email) + append("name", "Duplicate") + append("password", "secret") + }, + ) + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `create user with missing fields returns unauthorized`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_CREATE, + formParameters = Parameters.build { + append("email", "missing@test.com") + // name and password omitted + }, + ) + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `login with correct credentials returns token and user_id header`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_LOGIN, + formParameters = Parameters.build { + append("email", testUser.email) + append("password", testUser.passwordHash) // identity hash in tests + }, + ) + assertEquals(HttpStatusCode.OK, response.status) + assertNotNull(response.headers["user_id"], "Expected user_id header") + assertTrue(response.bodyAsText().isNotBlank()) + } + + @Test + fun `login with wrong password returns bad request`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_LOGIN, + formParameters = Parameters.build { + append("email", testUser.email) + append("password", "wrongpassword") + }, + ) + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `login with missing fields returns unauthorized`() = testApplication { + setupTestApp() + val response = client.submitForm( + url = USER_LOGIN, + formParameters = Parameters.build { + append("email", testUser.email) + // password omitted + }, + ) + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `logout returns 200`() = testApplication { + setupTestApp() + val response = client.post(USER_LOGOUT) + assertEquals(HttpStatusCode.OK, response.status) + } +} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index d2803a8..f6656c4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -107,6 +107,7 @@ kotlin { } commonTest.dependencies { + implementation(kotlin("test")) implementation(libs.koin.test) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.test) diff --git a/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/data/repositories/SessionRepositoryTest.kt b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/data/repositories/SessionRepositoryTest.kt new file mode 100644 index 0000000..587533e --- /dev/null +++ b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/data/repositories/SessionRepositoryTest.kt @@ -0,0 +1,61 @@ +package com.multiplatformkickstarter.app.data.repositories + +import com.russhwolf.settings.MapSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SessionRepositoryTest { + + private fun repo() = SessionRepository(MapSettings()) + + @Test + fun `new repo is not logged in`() { + assertFalse(repo().isLoggedIn()) + } + + @Test + fun `initSession marks user as logged in`() { + val repo = repo() + repo.initSession(id = 42, email = "a@b.com", session = "sess", token = "tok") + assertTrue(repo.isLoggedIn()) + } + + @Test + fun `initSession stores userId`() { + val repo = repo() + repo.initSession(id = 7, email = "a@b.com", session = "sess", token = "tok") + assertEquals(7, repo.getUserId()) + } + + @Test + fun `initSession stores email`() { + val repo = repo() + repo.initSession(id = 1, email = "user@example.com", session = "s", token = "t") + assertEquals("user@example.com", repo.getEmail()) + } + + @Test + fun `initSession stores token`() { + val repo = repo() + repo.initSession(id = 1, email = "e", session = "s", token = "my-jwt-token") + assertEquals("my-jwt-token", repo.getToken()) + } + + @Test + fun `clear resets login state`() { + val repo = repo() + repo.initSession(id = 1, email = "e", session = "s", token = "t") + repo.clear() + assertFalse(repo.isLoggedIn()) + } + + @Test + fun `clear resets userId to default`() { + val repo = repo() + repo.initSession(id = 5, email = "e", session = "s", token = "t") + repo.clear() + assertEquals(-1, repo.getUserId()) + } +} diff --git a/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatRepositoryMockTest.kt b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatRepositoryMockTest.kt new file mode 100644 index 0000000..3a6dd9d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatRepositoryMockTest.kt @@ -0,0 +1,82 @@ +package com.multiplatformkickstarter.app.feature.inbox + +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage +import com.multiplatformkickstarter.app.feature.inbox.repositories.FakeMockedChatRepository +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ChatRepositoryMockTest { + + private fun makeRepo() = FakeMockedChatRepository() + + @Test + fun `getConversations returns non-empty list`() = runTest { + assertTrue(makeRepo().getConversations().isNotEmpty()) + } + + @Test + fun `getConversations returns two default conversations`() = runTest { + assertEquals(2, makeRepo().getConversations().size) + } + + @Test + fun `getMessages returns seeded messages for conversation 1`() = runTest { + assertTrue(makeRepo().getMessages(conversationId = 1).isNotEmpty()) + } + + @Test + fun `getMessages returns empty list for unknown conversation`() = runTest { + assertTrue(makeRepo().getMessages(conversationId = 999).isEmpty()) + } + + @Test + fun `sendMessage adds sent message and auto-reply`() = runTest { + val repo = makeRepo() + val before = repo.getMessages(1).size + repo.sendMessage(conversationId = 1, content = "Hi!") + assertEquals(before + 2, repo.getMessages(1).size) + } + + @Test + fun `sendMessage returns message with correct content`() = runTest { + val sent = makeRepo().sendMessage(conversationId = 1, content = "Test message") + assertNotNull(sent) + assertEquals("Test message", sent.content) + } + + @Test + fun `auto-reply comes from a different sender than sent message`() = runTest { + val repo = makeRepo() + val beforeSize = repo.getMessages(1).size + repo.sendMessage(conversationId = 1, content = "Hello") + val messages = repo.getMessages(1) + val sent = messages[beforeSize] + val reply = messages[beforeSize + 1] + assertTrue(sent.senderUserId != reply.senderUserId) + } + + @Test + fun `deleteConversation returns true`() = runTest { + assertTrue(makeRepo().deleteConversation(1)) + } + + @Test + fun `createConversation returns conversation with correct petId`() = runTest { + val conv = makeRepo().createConversation(petId = 1, petName = "Buddy", sellerUserId = 2) + assertNotNull(conv) + assertEquals(1, conv.petId) + } + + @Test + fun `multiple sends accumulate messages`() = runTest { + val repo = makeRepo() + val before = repo.getMessages(1).size + repo.sendMessage(1, "first") + repo.sendMessage(1, "second") + assertEquals(before + 4, repo.getMessages(1).size) + } +} diff --git a/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/FakeMockedChatRepository.kt b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/FakeMockedChatRepository.kt new file mode 100644 index 0000000..8b83779 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/multiplatformkickstarter/app/feature/inbox/repositories/FakeMockedChatRepository.kt @@ -0,0 +1,57 @@ +package com.multiplatformkickstarter.app.feature.inbox.repositories + +import com.multiplatformkickstarter.app.common.model.ChatConversation +import com.multiplatformkickstarter.app.common.model.ChatMessage + +private val autoReplies = listOf( + "That sounds great!", + "Sure, when would you like to meet?", + "He/She is very friendly and loves cuddles.", + "The adoption fee is already included.", + "Yes, all vaccinations are up to date!", + "Feel free to come by this weekend.", + "I'll send you more photos shortly.", + "We'd love for them to find a good home!", + "Let me know if you have any other questions.", + "They get along well with other pets too.", +) + +class FakeMockedChatRepository { + private val conversations = mutableListOf( + ChatConversation(id = 1, petId = 1, petName = "Buddy", buyerUserId = -1, sellerUserId = 2, createdAt = 0L), + ChatConversation(id = 2, petId = 2, petName = "Luna", buyerUserId = 3, sellerUserId = -1, createdAt = 0L), + ) + private val messageStore: MutableMap> = mutableMapOf( + 1 to mutableListOf( + ChatMessage(id = 1, conversationId = 1, senderUserId = 2, content = "Is Buddy available?", sentAt = 0L), + ChatMessage(id = 2, conversationId = 1, senderUserId = -1, content = "Yes, come meet him!", sentAt = 0L), + ), + 2 to mutableListOf( + ChatMessage(id = 3, conversationId = 2, senderUserId = -1, content = "Interested in Luna.", sentAt = 0L), + ChatMessage(id = 4, conversationId = 2, senderUserId = 3, content = "She is very calm.", sentAt = 0L), + ), + ) + private var nextId = 100 + + suspend fun getConversations(): List = conversations.toList() + + suspend fun createConversation(petId: Int, petName: String, sellerUserId: Int): ChatConversation? { + return conversations.firstOrNull { it.petId == petId } + ?: ChatConversation(id = nextId++, petId = petId, petName = petName, buyerUserId = -1, sellerUserId = sellerUserId, createdAt = 0L) + .also { conversations.add(it) } + } + + suspend fun deleteConversation(conversationId: Int): Boolean = true + + suspend fun getMessages(conversationId: Int): List = + messageStore.getOrElse(conversationId) { mutableListOf() }.toList() + + suspend fun sendMessage(conversationId: Int, content: String): ChatMessage { + val store = messageStore.getOrPut(conversationId) { mutableListOf() } + val sent = ChatMessage(id = nextId++, conversationId = conversationId, senderUserId = -1, content = content, sentAt = 0L) + store.add(sent) + val reply = ChatMessage(id = nextId++, conversationId = conversationId, senderUserId = 2, content = autoReplies[store.size % autoReplies.size], sentAt = 0L) + store.add(reply) + return sent + } +} From 4aadbd9fd35fbd9cd869399aefab63f6a8490d5c Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 15:44:45 +0200 Subject: [PATCH 11/18] Correct Desktop dependency --- composeApp/build.gradle.kts | 2 -- gradle/libs.versions.toml | 3 --- 2 files changed, 5 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ec8ef74..ab6f8cc 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -64,7 +64,6 @@ kotlin { } desktopMain.dependencies { implementation(compose.desktop.currentOs) - implementation(libs.skiko) implementation(compose.ui) implementation(compose.foundation) implementation(compose.material) @@ -73,7 +72,6 @@ kotlin { implementation(compose.materialIconsExtended) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.components.resources) - implementation(libs.skiko.macos.arm64) implementation(libs.androidx.compose.ui.util) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b22de4e..c2ba230 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,6 @@ junit = "4.13.2" androidGradlePlugin = "9.2.1" composeMultiplatform = "1.11.1" exposed = "1.3.0" -skiko = "0.148.1" ktlint = "14.2.0" detekt = "1.23.8" kotlinx-coroutines = "1.11.0" @@ -31,8 +30,6 @@ multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", vers multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" } multiplatform-settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "multiplatform-settings" } multiplatform-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatform-settings" } -skiko = { module = "org.jetbrains.skiko:skiko", version.ref = "skiko" } -skiko-macos-arm64 = { module = "org.jetbrains.skiko:skiko-awt-runtime-macos-arm64", version.ref = "skiko" } ktor-client = { module = "io.ktor:ktor-client-core", version.ref = "ktor-client" } kamel = "media.kamel:kamel-image:1.0.9" kamel-decoder-image-bitmap = "media.kamel:kamel-decoder-image-bitmap:1.0.9" From b8ed9328ef15a1c5a105f0fdb9b4abe430cc5eda Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 16:11:41 +0200 Subject: [PATCH 12/18] Adjusted size on desktop --- composeApp/src/desktopMain/kotlin/Main.kt | 8 ++- .../app/feature/favorites/FavoritesScreen.kt | 44 +++++++------- .../app/feature/inbox/ChatScreen.kt | 6 +- .../app/feature/inbox/InboxScreen.kt | 60 ++++++++++--------- .../app/feature/petupload/PetUploadScreen.kt | 6 ++ .../app/ui/screens/HomeTabScreen.kt | 40 +++++++------ .../app/ui/screens/PetDetailScreen.kt | 4 ++ .../app/ui/screens/ProfileTabScreen.kt | 12 ++-- .../app/ui/screens/SearchListingScreen.kt | 7 ++- 9 files changed, 113 insertions(+), 74 deletions(-) diff --git a/composeApp/src/desktopMain/kotlin/Main.kt b/composeApp/src/desktopMain/kotlin/Main.kt index 73004a3..38cac61 100644 --- a/composeApp/src/desktopMain/kotlin/Main.kt +++ b/composeApp/src/desktopMain/kotlin/Main.kt @@ -1,4 +1,6 @@ import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application import com.multiplatformkickstarter.app.MainApp import com.multiplatformkickstarter.app.di.commonModule @@ -10,7 +12,11 @@ fun main() = startKoin { modules(commonModule) } - Window(onCloseRequest = ::exitApplication) { + Window( + onCloseRequest = ::exitApplication, + state = WindowState(placement = WindowPlacement.Maximized), + title = "Adoptme", + ) { MultiplatformKickstarterTheme { MainApp() } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt index 19dc447..613b46a 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/favorites/FavoritesScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -27,6 +28,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp @@ -79,26 +81,28 @@ class FavoritesScreen : Screen { ) }, ) { paddingValues -> - if (state.favorites.isEmpty()) { - Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { - EmptyLayout( - title = localization.favoritesEmptyTitle, - description = localization.favoritesEmptyDescription, - localization = localization, - ) {} - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - items(state.favorites, key = { it.id }) { pet -> - FavoriteItem( - pet = pet, - onPetClicked = { viewModel.onPetDetailClicked(pet.id) }, - onUnfavoriteClicked = { viewModel.onUnfavoriteClicked(pet.id) }, - ) + Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.TopCenter) { + if (state.favorites.isEmpty()) { + Box(modifier = Modifier.widthIn(max = 600.dp).fillMaxSize()) { + EmptyLayout( + title = localization.favoritesEmptyTitle, + description = localization.favoritesEmptyDescription, + localization = localization, + ) {} + } + } else { + LazyColumn( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxSize(), + ) { + items(state.favorites, key = { it.id }) { pet -> + FavoriteItem( + pet = pet, + onPetClicked = { viewModel.onPetDetailClicked(pet.id) }, + onUnfavoriteClicked = { viewModel.onUnfavoriteClicked(pet.id) }, + ) + } } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt index 5f8aff2..41dfbb9 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/ChatScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -36,7 +37,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @@ -118,10 +118,11 @@ class ChatScreen(private val conversation: ChatConversation) : Screen { ) }, ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.TopCenter) { Column( modifier = Modifier + .widthIn(max = 600.dp) .fillMaxSize() - .padding(paddingValues) .imePadding(), ) { val listState = rememberLazyListState() @@ -152,6 +153,7 @@ class ChatScreen(private val conversation: ChatConversation) : Screen { sendLabel = localization.chatSendMessage, ) } + } } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt index c274375..9c0d4a8 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/inbox/InboxScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api @@ -58,34 +60,36 @@ class InboxScreen : Screen { ) }, ) { paddingValues -> - if (state.error != null) { - Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { - EmptyLayout( - title = localization.inboxEmptyTitle, - description = state.error ?: localization.inboxEmptyDescription, - localization = localization, - ) { viewModel.loadConversations() } - } - } else if (state.conversations.isEmpty()) { - Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { - EmptyLayout( - title = localization.inboxEmptyTitle, - description = localization.inboxEmptyDescription, - localization = localization, - ) {} - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - items(state.conversations, key = { it.id }) { conversation -> - ConversationItem( - conversation = conversation, - onClick = { viewModel.onConversationClicked(conversation) }, - onDelete = { viewModel.onDeleteConversation(conversation.id) }, - ) + Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.TopCenter) { + if (state.error != null) { + Box(modifier = Modifier.widthIn(max = 600.dp).fillMaxSize()) { + EmptyLayout( + title = localization.inboxEmptyTitle, + description = state.error ?: localization.inboxEmptyDescription, + localization = localization, + ) { viewModel.loadConversations() } + } + } else if (state.conversations.isEmpty()) { + Box(modifier = Modifier.widthIn(max = 600.dp).fillMaxSize()) { + EmptyLayout( + title = localization.inboxEmptyTitle, + description = localization.inboxEmptyDescription, + localization = localization, + ) {} + } + } else { + LazyColumn( + modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxSize(), + ) { + items(state.conversations, key = { it.id }) { conversation -> + ConversationItem( + conversation = conversation, + onClick = { viewModel.onConversationClicked(conversation) }, + onDelete = { viewModel.onDeleteConversation(conversation.id) }, + ) + } } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt index 63ad4df..4f8727b 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/feature/petupload/PetUploadScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -196,8 +197,11 @@ class PetUploadScreen : Screen { ) { val scrollState = rememberScrollState() + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Column( modifier = Modifier + .widthIn(max = 600.dp) + .fillMaxSize() .padding(it).padding(bottom = 46.dp) .background(MaterialTheme.colorScheme.background) .verticalScroll(scrollState) @@ -220,6 +224,7 @@ class PetUploadScreen : Screen { Box( modifier = Modifier + .widthIn(max = 600.dp) .fillMaxSize() ) { Box( @@ -260,6 +265,7 @@ class PetUploadScreen : Screen { } } } + } } if (showingModal.value) { Picker( diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt index ef7945e..767e21e 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/HomeTabScreen.kt @@ -14,9 +14,11 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.rememberScrollState @@ -135,27 +137,29 @@ class HomeTabScreen : Screen { ) } ) { paddingValues -> - BoxWithConstraints(Modifier.fillMaxSize().padding(paddingValues), propagateMinConstraints = true) { - val maxWidth = this.maxWidth + Box(Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.TopCenter) { + BoxWithConstraints(Modifier.widthIn(max = 600.dp).fillMaxHeight(), propagateMinConstraints = true) { + val maxWidth = this.maxWidth - Column( - modifier = Modifier.fillMaxWidth() - ) { - PetsSearchBar( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), - localization = localization - ) Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState) + modifier = Modifier.fillMaxWidth() ) { - BannerHeader(viewModel, localization) - CategoriesCarousel(viewModel, localization) - LastSearchCarousel(viewModel, localization, state) - NearMeListView(viewModel, state, localization, maxWidth) + PetsSearchBar( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), + localization = localization + ) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + ) { + BannerHeader(viewModel, localization) + CategoriesCarousel(viewModel, localization) + LastSearchCarousel(viewModel, localization, state) + NearMeListView(viewModel, state, localization, maxWidth) + } } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt index 4118d90..7bcc4a7 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/PetDetailScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -118,8 +119,10 @@ fun PetDetailView( ) } ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Column( modifier = Modifier + .widthIn(max = 600.dp) .fillMaxSize() .padding(bottom = 16.dp) .verticalScroll(scrollState) @@ -187,5 +190,6 @@ fun PetDetailView( ) } } + } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt index 9f0503b..d9b459e 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/ProfileTabScreen.kt @@ -7,9 +7,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -21,7 +24,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp @@ -86,9 +88,11 @@ class ProfileTabScreen : Screen { ) { val scrollState = rememberScrollState() MultiplatformKickstarterTheme { - Column(modifier = Modifier.statusBarsPadding().padding(bottom = 80.dp).verticalScroll(scrollState)) { - UserProfileData(state, viewModel) - OptionsList(localization, viewModel) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column(modifier = Modifier.widthIn(max = 600.dp).statusBarsPadding().padding(bottom = 80.dp).verticalScroll(scrollState)) { + UserProfileData(state, viewModel) + OptionsList(localization, viewModel) + } } } } diff --git a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt index 23c9f7b..8f89e54 100644 --- a/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt +++ b/shared/src/commonMain/kotlin/com/multiplatformkickstarter/app/ui/screens/SearchListingScreen.kt @@ -3,9 +3,12 @@ package com.multiplatformkickstarter.app.ui.screens import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -147,11 +150,12 @@ class SearchListingScreen( ) } ) { + Box(modifier = Modifier.fillMaxSize().padding(it), contentAlignment = Alignment.TopCenter) { Column( modifier = Modifier + .widthIn(max = 600.dp) .fillMaxSize() - .padding(it) .padding(bottom = 16.dp) .verticalScroll(scrollState) .background(MaterialTheme.colorScheme.background), @@ -170,6 +174,7 @@ class SearchListingScreen( EmptyLayout(localization = localization) {} } } + } } } } From 6f59bdd9e468fee03830465eeb74d40c7f7e3a6a Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 16:14:07 +0200 Subject: [PATCH 13/18] Corrected CI --- .github/workflows/basic.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index d8cbaf5..ca6c55a 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -43,11 +43,3 @@ jobs: backend/build/reports/tests/ shared/build/reports/tests/ retention-days: 7 - - - name: Publish test results - if: always() - uses: EnricoMi/publish-unit-test-result-action@v2 - with: - files: | - backend/build/test-results/**/*.xml - shared/build/test-results/**/*.xml From d06be95ab3abe9a722d50f23c5abd280e800b796 Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 16:21:16 +0200 Subject: [PATCH 14/18] Small iOS Correction --- iosApp/iosApp.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 0bcad6e..7ce67e6 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -177,7 +177,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode"; }; /* End PBXShellScriptBuildPhase section */ From 83c4dc6adab4d43ddf61b45580243f6168741be2 Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 16:27:41 +0200 Subject: [PATCH 15/18] Removed preview for app --- composeApp/src/commonMain/kotlin/App.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt index 0aa761d..1c9ccb1 100644 --- a/composeApp/src/commonMain/kotlin/App.kt +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -1,10 +1,8 @@ import androidx.compose.runtime.Composable import com.multiplatformkickstarter.app.MainApp -import androidx.compose.ui.tooling.preview.Preview @Suppress("FunctionName") @Composable -@Preview fun App() { MainApp() } From bd5115c46d7a93eb0fafbcc79bd6b8dad21e713e Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 16:38:12 +0200 Subject: [PATCH 16/18] Corrected tests --- .../com/multiplatformkickstarter/TestHelpers.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt b/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt index c74caf1..a91d532 100644 --- a/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt +++ b/backend/src/test/kotlin/com/multiplatformkickstarter/TestHelpers.kt @@ -18,6 +18,7 @@ import com.multiplatformkickstarter.repository.chat.MessagesRepository import com.multiplatformkickstarter.repository.favorites.FavoritesRepository import com.multiplatformkickstarter.repository.pets.PetsRepository import com.multiplatformkickstarter.repository.user.UserRepository +import com.multiplatformkickstarter.plugins.configureSerialization import com.multiplatformkickstarter.routes.chat import com.multiplatformkickstarter.routes.favorites import com.multiplatformkickstarter.routes.pets @@ -88,7 +89,18 @@ class FakePetsRepository : PetsRepository { gender: String?, size: String?, color: String?, status: String?, shelterId: Int?, ): PetModel? { val idx = pets.indexOfFirst { it.id == petId }.takeIf { it >= 0 } ?: return null - val updated = pets[idx].copy(title = title ?: pets[idx].title) + val old = pets[idx] + val updated = PetModel( + id = old.id, userId = old.userId, title = title ?: old.title, + description = description ?: old.description, images = old.images, + category = old.category, location = old.location, published = old.published, + modified = modified ?: old.modified, breed = breed ?: old.breed, + age = if (age != null) PetAge.valueOf(age) else old.age, + gender = if (gender != null) PetGender.valueOf(gender) else old.gender, + size = if (size != null) PetSize.valueOf(size) else old.size, + color = color ?: old.color, status = if (status != null) PetStatus.valueOf(status) else old.status, + shelterId = shelterId ?: old.shelterId, + ) pets[idx] = updated return updated } From eaa80d69371c84298353980fde5921fdf5a2b44b Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 17:39:49 +0200 Subject: [PATCH 17/18] Corrected code because test were failing --- .../routes/ChatRoutes.kt | 27 +++++++++---------- .../routes/FavoritesRoutes.kt | 27 +++++++------------ .../routes/UserRoute.kt | 14 +++++----- 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt index 0cceb6b..a7fa61e 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/ChatRoutes.kt @@ -6,19 +6,19 @@ import com.multiplatformkickstarter.auth.UserSession import com.multiplatformkickstarter.repository.chat.ConversationsRepository import com.multiplatformkickstarter.repository.chat.MessagesRepository import com.multiplatformkickstarter.repository.user.UserRepository +import com.multiplatformkickstarter.models.DatabaseUser import io.ktor.http.HttpStatusCode import io.ktor.http.Parameters import io.ktor.resources.Resource import io.ktor.server.application.call import io.ktor.server.auth.authenticate +import io.ktor.server.auth.principal import io.ktor.server.request.receive import io.ktor.server.resources.delete import io.ktor.server.resources.get import io.ktor.server.resources.post import io.ktor.server.response.respond import io.ktor.server.routing.Route -import io.ktor.server.sessions.get -import io.ktor.server.sessions.sessions const val CHAT = "$API_VERSION/chat" const val CHAT_CONVERSATIONS = "$CHAT/conversations" @@ -42,29 +42,29 @@ class ChatMessagesRoute @Resource(CHAT_MESSAGES_SEND) class ChatMessagesSendRoute +@Suppress("UnusedParameter") fun Route.chat( conversationsRepository: ConversationsRepository, messagesRepository: MessagesRepository, userRepository: UserRepository, ) { authenticate(JWT_CONFIGURATION) { - chatConversationRoutes(conversationsRepository, userRepository) - chatMessageRoutes(messagesRepository, userRepository) + chatConversationRoutes(conversationsRepository) + chatMessageRoutes(messagesRepository) } } private fun Route.chatConversationRoutes( conversationsRepository: ConversationsRepository, - userRepository: UserRepository, ) { get { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + val user = call.principal() ?: return@get call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") call.respond(HttpStatusCode.OK, conversationsRepository.getConversationsForUser(user.userId)) } post { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + val user = call.principal() ?: return@post call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") val params = call.receive() val petId = params["petId"]?.toIntOrNull() @@ -82,10 +82,9 @@ private fun Route.chatConversationRoutes( } delete { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + val user = call.principal() ?: return@delete call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - val params = call.receive() - val conversationId = params["conversationId"]?.toIntOrNull() + val conversationId = call.request.queryParameters["conversationId"]?.toIntOrNull() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing conversationId") val deleted = conversationsRepository.deleteConversation(conversationId, user.userId) call.respond(if (deleted) HttpStatusCode.OK else HttpStatusCode.NotFound) @@ -94,19 +93,17 @@ private fun Route.chatConversationRoutes( private fun Route.chatMessageRoutes( messagesRepository: MessagesRepository, - userRepository: UserRepository, ) { get { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + call.principal() ?: return@get call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - val params = call.receive() - val conversationId = params["conversationId"]?.toIntOrNull() + val conversationId = call.request.queryParameters["conversationId"]?.toIntOrNull() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing conversationId") call.respond(HttpStatusCode.OK, messagesRepository.getMessages(conversationId)) } post { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } + val user = call.principal() ?: return@post call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") val params = call.receive() val conversationId = params["conversationId"]?.toIntOrNull() diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt index 90a15ea..ea5c0ac 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt @@ -2,7 +2,7 @@ package com.multiplatformkickstarter.routes import com.multiplatformkickstarter.API_VERSION import com.multiplatformkickstarter.auth.JWT_CONFIGURATION -import com.multiplatformkickstarter.auth.UserSession +import com.multiplatformkickstarter.models.DatabaseUser import com.multiplatformkickstarter.repository.favorites.FavoritesRepository import com.multiplatformkickstarter.repository.user.UserRepository import io.ktor.http.HttpStatusCode @@ -10,14 +10,13 @@ import io.ktor.http.Parameters import io.ktor.resources.Resource import io.ktor.server.application.call import io.ktor.server.auth.authenticate +import io.ktor.server.auth.principal import io.ktor.server.request.receive import io.ktor.server.resources.delete import io.ktor.server.resources.get import io.ktor.server.resources.post import io.ktor.server.response.respond import io.ktor.server.routing.Route -import io.ktor.server.sessions.get -import io.ktor.server.sessions.sessions const val FAVORITES = "$API_VERSION/favorites" const val FAVORITES_ADD = "$FAVORITES/add" @@ -32,27 +31,22 @@ class FavoritesAddRoute @Resource(FAVORITES_REMOVE) class FavoritesRemoveRoute +@Suppress("UnusedParameter") fun Route.favorites( favoritesRepository: FavoritesRepository, userRepository: UserRepository, ) { authenticate(JWT_CONFIGURATION) { get { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } - if (user == null) { - call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - return@get - } + val user = call.principal() + ?: return@get call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") val petIds = favoritesRepository.getFavorites(user.userId) call.respond(HttpStatusCode.OK, petIds) } post { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } - if (user == null) { - call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - return@post - } + val user = call.principal() + ?: return@post call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") val params = call.receive() val petId = params["petId"]?.toIntOrNull() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing petId") @@ -61,11 +55,8 @@ fun Route.favorites( } delete { - val user = call.sessions.get()?.let { userRepository.findUser(it.userId) } - if (user == null) { - call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - return@delete - } + val user = call.principal() + ?: return@delete call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") val params = call.receive() val petId = params["petId"]?.toIntOrNull() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing petId") diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt index 8fbb13f..7a4076d 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/UserRoute.kt @@ -48,13 +48,15 @@ fun Route.users( val hash = hashFunction(password) try { val newUser = userRepository.addUser(email, name, hash) - newUser?.userId?.let { - call.sessions.set(UserSession(it)) - call.respondText( - jwtService.generateToken(newUser), - status = HttpStatusCode.Created, - ) + if (newUser == null) { + call.respond(HttpStatusCode.BadRequest, "Email already in use") + return@post } + call.sessions.set(UserSession(newUser.userId)) + call.respondText( + jwtService.generateToken(newUser), + status = HttpStatusCode.Created, + ) } catch (e: Throwable) { this@users.application.log.error("Failed to register user", e) call.respond(HttpStatusCode.BadRequest, "Problems creating User") From aec0340d436fe3a00ab9efb9949cf3abe6277975 Mon Sep 17 00:00:00 2001 From: Ferran Pons Date: Mon, 8 Jun 2026 17:52:43 +0200 Subject: [PATCH 18/18] Corrected code because test were failing --- .../com/multiplatformkickstarter/routes/FavoritesRoutes.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt index ea5c0ac..781e92e 100644 --- a/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt +++ b/backend/src/main/kotlin/com/multiplatformkickstarter/routes/FavoritesRoutes.kt @@ -57,8 +57,7 @@ fun Route.favorites( delete { val user = call.principal() ?: return@delete call.respond(HttpStatusCode.Unauthorized, "Problems retrieving User") - val params = call.receive() - val petId = params["petId"]?.toIntOrNull() + val petId = call.request.queryParameters["petId"]?.toIntOrNull() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing petId") val removed = favoritesRepository.removeFavorite(user.userId, petId) call.respond(if (removed) HttpStatusCode.OK else HttpStatusCode.NotFound)