diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/chatapp.iml b/.idea/chatapp.iml
index 70c0631..cd68f94 100644
--- a/.idea/chatapp.iml
+++ b/.idea/chatapp.iml
@@ -4,6 +4,7 @@
+
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
index a5f05a4..56619f4 100644
--- a/.idea/dataSources.xml
+++ b/.idea/dataSources.xml
@@ -1,16 +1,11 @@
-
+
postgresql
true
org.postgresql.Driver
- jdbc:postgresql://100.118.108.58:5432/chatapp_dev
-
-
-
-
-
+ jdbc:postgresql://zqsrv:5432/chatapp_dev
$ProjectFileDir$
diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml
new file mode 100644
index 0000000..87de61b
--- /dev/null
+++ b/.idea/sqldialects.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml
index 94a4997..c29ef3b 100644
--- a/android/.idea/deploymentTargetSelector.xml
+++ b/android/.idea/deploymentTargetSelector.xml
@@ -13,9 +13,6 @@
-
-
-
\ No newline at end of file
diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml
index b2c751a..991a888 100644
--- a/android/.idea/misc.xml
+++ b/android/.idea/misc.xml
@@ -1,6 +1,7 @@
+
-
+
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 2a023c5..c0aa0e1 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -54,7 +54,7 @@ android {
isMinifyEnabled = false
isDebuggable = true
applicationIdSuffix = ".debug" // lets you install both side by side
- buildConfigField("String", "BASE_URL", "\"http://zxq5-x1:8000\"")
+ buildConfigField("String", "BASE_URL", "\"https://chat.zxq5.dev\"")
}
}
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/MainActivity.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/MainActivity.kt
index a54f762..f655e14 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/MainActivity.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/MainActivity.kt
@@ -12,8 +12,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ChatBubbleOutline
-import androidx.compose.material.icons.outlined.PeopleOutline
+import androidx.compose.material.icons.outlined.Contacts
+import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
@@ -36,13 +38,14 @@ import dev.zxq5.chatapp.android.feature.auth.AuthScreen
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
import dev.zxq5.chatapp.android.feature.chat.ChatScreen
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
-import dev.zxq5.chatapp.android.feature.chat.Screen
+import dev.zxq5.chatapp.android.ui.Screen
import dev.zxq5.chatapp.android.feature.contacts.ContactsScreen
import dev.zxq5.chatapp.android.feature.settings.SettingsScreen
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
class MainActivity : ComponentActivity() {
+ @OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -91,7 +94,7 @@ class MainActivity : ComponentActivity() {
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
when (currentScreen) {
- Screen.CHAT -> ChatScreen(
+ Screen.MESSAGES -> ChatScreen(
viewModel = chatViewModel,
onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) },
onLogout = {
@@ -99,6 +102,14 @@ class MainActivity : ComponentActivity() {
chatViewModel.clearChat()
}
)
+ Screen.SPACES -> ChatScreen(
+ viewModel = chatViewModel,
+ onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) },
+ onLogout = {
+ authViewModel.logout()
+ chatViewModel.clearChat()
+ }
+ )
Screen.CONTACTS -> ContactsScreen()
Screen.SETTINGS -> SettingsScreen(
viewModel = settingsViewModel,
@@ -148,38 +159,54 @@ fun BottomDock(currentScreen: Screen, onNavigate: (Screen) -> Unit) {
RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp)
)
) {
+ val colors = NavigationBarItemDefaults.colors(
+ selectedIconColor = MaterialTheme.colorScheme.primary,
+ unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ indicatorColor = Color.Transparent
+ )
+
NavigationBarItem(
- selected = currentScreen == Screen.CHAT,
- onClick = { onNavigate(Screen.CHAT) },
+ selected = currentScreen == Screen.MESSAGES,
+ onClick = { onNavigate(Screen.MESSAGES) },
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
- colors = NavigationBarItemDefaults.colors(
- selectedIconColor = MaterialTheme.colorScheme.primary,
- unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
- indicatorColor = Color.Transparent
- )
+ colors = colors
)
+
NavigationBarItem(
selected = currentScreen == Screen.CONTACTS,
onClick = { onNavigate(Screen.CONTACTS) },
- icon = { Icon(Icons.Outlined.PeopleOutline, contentDescription = "Contacts") },
+ icon = { Icon(Icons.Outlined.Contacts, contentDescription = "Contacts") },
label = { Text("contacts", style = MaterialTheme.typography.labelSmall) },
- colors = NavigationBarItemDefaults.colors(
- selectedIconColor = MaterialTheme.colorScheme.primary,
- unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
- indicatorColor = Color.Transparent
- )
+ colors = colors
)
+
+ NavigationBarItem(
+ selected = currentScreen == Screen.SPACES,
+ onClick = { onNavigate(Screen.SPACES) },
+ icon = { Icon(Icons.Outlined.Forum, contentDescription = "Spaces") },
+ label = { Text("spaces", style = MaterialTheme.typography.labelSmall) },
+ colors = colors,
+ )
+
NavigationBarItem(
selected = currentScreen == Screen.SETTINGS,
onClick = { onNavigate(Screen.SETTINGS) },
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
- colors = NavigationBarItemDefaults.colors(
- selectedIconColor = MaterialTheme.colorScheme.primary,
- unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
- indicatorColor = Color.Transparent
- )
+ colors = colors
)
}
}
+
+//topBar = {
+// TopAppBar(
+// colors = TopAppBarDefaults.topAppBarColors(
+// containerColor = MaterialTheme.colorScheme.primaryContainer,
+// titleContentColor = MaterialTheme.colorScheme.primary,
+// ),
+// title = {
+// Text(if (BuildConfig.DEBUG) "Chatapp (Debug)" else "Chatapp",)
+// }
+// )
+//},
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt
index 9c20c01..23d7040 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/ChatViewModel.kt
@@ -3,12 +3,11 @@ package dev.zxq5.chatapp.android.feature.chat
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import dev.zxq5.chatapp.android.api.model.Channel
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import dev.zxq5.chatapp.android.api.model.Message
-import dev.zxq5.chatapp.android.api.model.Space
import dev.zxq5.chatapp.android.api.model.SpaceDto
import dev.zxq5.chatapp.android.core.service.MessageStreamService
+import dev.zxq5.chatapp.android.ui.Screen
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -24,7 +23,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
private val _channelId = MutableStateFlow(null)
val channelId: StateFlow = _channelId
- private val _currentScreen = MutableStateFlow(Screen.CHAT)
+ private val _currentScreen = MutableStateFlow(Screen.MESSAGES)
val currentScreen: StateFlow = _currentScreen
private val _currentUserId = MutableStateFlow(null)
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/Screen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/Screen.kt
deleted file mode 100644
index 3be28f6..0000000
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/chat/Screen.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package dev.zxq5.chatapp.android.feature.chat
-
-enum class Screen {
- CHAT, CONTACTS, SETTINGS
-}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/settings.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/settings.kt
index 50f0280..fb6dbd3 100644
--- a/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/settings.kt
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/feature/settings/settings.kt
@@ -58,6 +58,7 @@ import androidx.compose.ui.unit.sp
import android.util.Base64
import android.graphics.BitmapFactory
import androidx.compose.ui.text.style.TextAlign
+import dev.zxq5.chatapp.android.BuildConfig
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -100,6 +101,11 @@ fun SettingsScreen(
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
+ SettingsSection(title = "Build info") {
+ Text("Build: Chatapp ${if (BuildConfig.DEBUG) "debug" else "release" }", style = MaterialTheme.typography.bodyMedium)
+
+ }
+
SettingsSection(title = "profile") {
var displayName by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
@@ -359,7 +365,7 @@ fun SettingsScreen(
@Composable
fun SettingsSection(
title: String,
- color: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ color: Color = MaterialTheme.colorScheme.onSurfaceVariant ,
content: @Composable () -> Unit
) {
var expanded by remember { mutableStateOf(true) }
@@ -379,7 +385,7 @@ fun SettingsSection(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
- Text(title.lowercase(), style = MaterialTheme.typography.labelSmall, color = color)
+ Text(title.lowercase(), style = MaterialTheme.typography.bodyMedium, color = color)
Icon(
if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = null,
diff --git a/android/app/src/main/java/dev/zxq5/chatapp/android/ui/Screen.kt b/android/app/src/main/java/dev/zxq5/chatapp/android/ui/Screen.kt
new file mode 100644
index 0000000..3fa3c83
--- /dev/null
+++ b/android/app/src/main/java/dev/zxq5/chatapp/android/ui/Screen.kt
@@ -0,0 +1,5 @@
+package dev.zxq5.chatapp.android.ui
+
+enum class Screen {
+ MESSAGES, CONTACTS, SETTINGS, SPACES
+}
\ No newline at end of file
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 0e07f6e..4218f2f 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-agp = "9.1.0"
+agp = "9.4.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index f4f7ab9..7c097e0 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,8 +1,8 @@
#Tue Mar 31 13:46:27 BST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..adfa9bf
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,19 @@
+*.iml
+.kotlin
+.gradle
+**/build/
+xcuserdata
+!src/**/build/
+local.properties
+.idea
+.DS_Store
+captures
+.externalNativeBuild
+.cxx
+*.xcodeproj/*
+!*.xcodeproj/project.pbxproj
+!*.xcodeproj/xcshareddata/
+!*.xcodeproj/project.xcworkspace/
+!*.xcworkspace/contents.xcworkspacedata
+**/xcshareddata/WorkspaceSettings.xcsettings
+node_modules/
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..11f8a39
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,41 @@
+This is a Kotlin Multiplatform project targeting Android, Web, Desktop (JVM).
+
+* [/shared](./shared/src) is for code that will be shared across your Compose Multiplatform applications.
+ It contains several subfolders:
+ - [commonMain](./shared/src/commonMain/kotlin) is for code that’s common for all targets.
+ - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
+ For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
+ the [iosMain](./shared/src/iosMain/kotlin) folder would be the right place for such calls.
+ Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./shared/src/jvmMain/kotlin)
+ folder is the appropriate location.
+
+### Running the apps
+
+Use the run configurations provided by the run widget in your IDE's toolbar. You can also use these commands and options:
+
+- Android app: `./gradlew :androidApp:assembleDebug`
+- Desktop app:
+ - Hot reload: `./gradlew :desktopApp:hotRun --auto`
+ - Standard run: `./gradlew :desktopApp:run`
+- Web app:
+ - Wasm target (faster, modern browsers): `./gradlew :webApp:wasmJsBrowserDevelopmentRun`
+ - JS target (slower, supports older browsers): `./gradlew :webApp:jsBrowserDevelopmentRun`
+
+### Running tests
+
+Use the run button in your IDE's editor gutter, or run tests using Gradle tasks:
+
+- Android tests: `./gradlew :shared:testAndroidHostTest`
+- Desktop tests: `./gradlew :shared:jvmTest`
+- Web tests:
+ - Wasm target: `./gradlew :shared:wasmJsTest`
+ - JS target: `./gradlew :shared:jsTest`
+
+---
+
+Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html),
+[Compose Multiplatform](https://kotlinlang.org/compose-multiplatform/),
+[Kotlin/Wasm](https://kotl.in/wasm/)…
+
+We would appreciate your feedback on Compose/Web and Kotlin/Wasm in the public Slack channel [#compose-web](https://slack-chats.kotlinlang.org/c/compose-web).
+If you face any issues, please report them on [YouTrack](https://youtrack.jetbrains.com/newIssue?project=CMP).
\ No newline at end of file
diff --git a/frontend/androidApp/build.gradle.kts b/frontend/androidApp/build.gradle.kts
new file mode 100644
index 0000000..37f96f1
--- /dev/null
+++ b/frontend/androidApp/build.gradle.kts
@@ -0,0 +1,54 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.composeCompiler)
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_11
+ }
+}
+dependencies {
+ implementation(project(":shared"))
+
+ implementation(libs.androidx.activity.compose)
+
+ implementation(libs.compose.uiToolingPreview)
+ debugImplementation(libs.compose.uiTooling)
+}
+
+android {
+ namespace = "dev.zxq5.chatapp"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+
+ defaultConfig {
+ applicationId = "dev.zxq5.chatapp"
+ minSdk = libs.versions.android.minSdk.get().toInt()
+ targetSdk = libs.versions.android.targetSdk.get().toInt()
+ versionCode = 1
+ versionName = "1.0"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ buildFeatures {
+ compose = true
+ }
+}
\ No newline at end of file
diff --git a/frontend/androidApp/proguard-rules.pro b/frontend/androidApp/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/frontend/androidApp/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/frontend/androidApp/src/main/AndroidManifest.xml b/frontend/androidApp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..26403a7
--- /dev/null
+++ b/frontend/androidApp/src/main/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/androidApp/src/main/kotlin/dev/zxq5/chatapp/MainActivity.kt b/frontend/androidApp/src/main/kotlin/dev/zxq5/chatapp/MainActivity.kt
new file mode 100644
index 0000000..87d4b53
--- /dev/null
+++ b/frontend/androidApp/src/main/kotlin/dev/zxq5/chatapp/MainActivity.kt
@@ -0,0 +1,25 @@
+package dev.zxq5.chatapp
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ App()
+ }
+ }
+}
+
+@Preview
+@Composable
+fun AppAndroidPreview() {
+ App()
+}
\ No newline at end of file
diff --git a/frontend/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml b/frontend/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/frontend/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/androidApp/src/main/res/drawable/ic_launcher_background.xml b/frontend/androidApp/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..e93e11a
--- /dev/null
+++ b/frontend/androidApp/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/frontend/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/frontend/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..eca70cf
--- /dev/null
+++ b/frontend/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a571e60
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/frontend/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/frontend/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..61da551
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/frontend/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..c41dd28
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/frontend/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png b/frontend/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..db5080a
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/frontend/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..6dba46d
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/frontend/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/frontend/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..da31a87
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/frontend/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..15ac681
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/frontend/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/frontend/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b216f2d
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/frontend/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..f25a419
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/frontend/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..e96783c
Binary files /dev/null and b/frontend/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/frontend/androidApp/src/main/res/values/strings.xml b/frontend/androidApp/src/main/res/values/strings.xml
new file mode 100644
index 0000000..6c72cec
--- /dev/null
+++ b/frontend/androidApp/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Chatapp
+
\ No newline at end of file
diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts
new file mode 100644
index 0000000..46c397d
--- /dev/null
+++ b/frontend/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+ // this is necessary to avoid the plugins to be loaded multiple times
+ // in each subproject's classloader
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.androidMultiplatformLibrary) apply false
+ alias(libs.plugins.composeMultiplatform) apply false
+ alias(libs.plugins.composeCompiler) apply false
+ alias(libs.plugins.kotlinJvm) apply false
+ alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.buildkonfig) apply false
+}
\ No newline at end of file
diff --git a/frontend/desktopApp/build.gradle.kts b/frontend/desktopApp/build.gradle.kts
new file mode 100644
index 0000000..3efd1d3
--- /dev/null
+++ b/frontend/desktopApp/build.gradle.kts
@@ -0,0 +1,28 @@
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+
+plugins {
+ alias(libs.plugins.kotlinJvm)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+}
+
+dependencies {
+ implementation(project(":shared"))
+
+ implementation(compose.desktop.currentOs)
+ implementation(libs.kotlinx.coroutinesSwing)
+
+ implementation(libs.compose.uiToolingPreview)
+}
+
+compose.desktop {
+ application {
+ mainClass = "dev.zxq5.chatapp.MainKt"
+
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+ packageName = "dev.zxq5.chatapp"
+ packageVersion = "1.0.0"
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/desktopApp/src/main/kotlin/dev/zxq5/chatapp/main.kt b/frontend/desktopApp/src/main/kotlin/dev/zxq5/chatapp/main.kt
new file mode 100644
index 0000000..4ed313b
--- /dev/null
+++ b/frontend/desktopApp/src/main/kotlin/dev/zxq5/chatapp/main.kt
@@ -0,0 +1,13 @@
+package dev.zxq5.chatapp
+
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+
+fun main() = application {
+ Window(
+ onCloseRequest = ::exitApplication,
+ title = "Chatapp",
+ ) {
+ App()
+ }
+}
\ No newline at end of file
diff --git a/frontend/gradle.properties b/frontend/gradle.properties
new file mode 100644
index 0000000..2dc57ad
--- /dev/null
+++ b/frontend/gradle.properties
@@ -0,0 +1,12 @@
+#Kotlin
+kotlin.code.style=official
+kotlin.daemon.jvmargs=-Xmx3072M
+
+#Gradle
+org.gradle.jvmargs=-Xmx3072M -Dfile.encoding=UTF-8
+org.gradle.configuration-cache=true
+org.gradle.caching=true
+
+#Android
+android.nonTransitiveRClass=true
+android.useAndroidX=true
\ No newline at end of file
diff --git a/frontend/gradle/gradle-daemon-jvm.properties b/frontend/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..5301309
--- /dev/null
+++ b/frontend/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/e8f7f2860374272b4c64643d36d8f0dc/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/77785cb3076de1b8f41fcbc6540556c0/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e8f7f2860374272b4c64643d36d8f0dc/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/77785cb3076de1b8f41fcbc6540556c0/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3932798aff0bd10c6286eaaff40c6f0a/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/6fa62408db9bcb00d7e180251d5f068c/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e8f7f2860374272b4c64643d36d8f0dc/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/77785cb3076de1b8f41fcbc6540556c0/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/da24493caf79e03d5c254365c5ad7ac1/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/df839bfdbcd8a8e829b9cf1be59c478b/redirect
+toolchainVendor=AZUL
+toolchainVersion=21
\ No newline at end of file
diff --git a/frontend/gradle/libs.versions.toml b/frontend/gradle/libs.versions.toml
new file mode 100644
index 0000000..a4458b0
--- /dev/null
+++ b/frontend/gradle/libs.versions.toml
@@ -0,0 +1,63 @@
+[versions]
+appVersion = "0.1.0"
+
+agp = "9.4.1"
+android-compileSdk = "37"
+android-minSdk = "31"
+android-targetSdk = "37"
+androidx-activity = "1.13.0"
+androidx-appcompat = "1.8.0"
+androidx-core = "1.19.0"
+androidx-espresso = "3.7.0"
+androidx-lifecycle = "2.11.0"
+androidx-testExt = "1.3.0"
+buildkonfigGradlePlugin = "0.22.0"
+composeMultiplatform = "1.12.0"
+junit = "4.13.2"
+kotlin = "2.4.20"
+kotlin-wrappers = "2026.9.1"
+kotlinGradlePlugin = "2.3.20"
+kotlinx-coroutines = "1.11.0"
+kotlinxDatetime = "0.8.0"
+material3 = "1.12.0-alpha03"
+composeMaterialIcons = "1.7.3"
+buildkonfig = "0.22.0"
+qrose = "1.3.0"
+cryptography-core = "0.6.0"
+
+[libraries]
+buildkonfig-gradle-plugin = { module = "com.codingfeline.buildkonfig:buildkonfig-gradle-plugin", version.ref = "buildkonfigGradlePlugin" }
+kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlinGradlePlugin" }
+kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
+kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
+junit = { module = "junit:junit", version.ref = "junit" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
+androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
+androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
+compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
+androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
+androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
+compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
+compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
+compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
+compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIcons" }
+compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
+compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
+compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
+kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
+wrappers-browser = { module = "org.jetbrains.kotlin-wrappers:kotlin-browser", version.ref = "kotlin-wrappers" }
+qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" }
+cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography-core" }
+cryptography = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-core"}
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
+composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
+composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfig"}
diff --git a/frontend/gradle/wrapper/gradle-wrapper.jar b/frontend/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..b1b8ef5
Binary files /dev/null and b/frontend/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/frontend/gradle/wrapper/gradle-wrapper.properties b/frontend/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..1cbec39
--- /dev/null
+++ b/frontend/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Thu Sep 17 22:10:57 BST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/frontend/gradlew b/frontend/gradlew
new file mode 100755
index 0000000..b9bb139
--- /dev/null
+++ b/frontend/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/frontend/gradlew.bat b/frontend/gradlew.bat
new file mode 100644
index 0000000..24c62d5
--- /dev/null
+++ b/frontend/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/frontend/kotlin-js-store/wasm/yarn.lock b/frontend/kotlin-js-store/wasm/yarn.lock
new file mode 100644
index 0000000..5f4567d
--- /dev/null
+++ b/frontend/kotlin-js-store/wasm/yarn.lock
@@ -0,0 +1,8 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@js-joda/core@3.2.0":
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
+ integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
diff --git a/frontend/settings.gradle.kts b/frontend/settings.gradle.kts
new file mode 100644
index 0000000..52597c2
--- /dev/null
+++ b/frontend/settings.gradle.kts
@@ -0,0 +1,37 @@
+rootProject.name = "Chatapp"
+
+pluginManagement {
+ repositories {
+ google {
+ mavenContent {
+ includeGroupAndSubgroups("androidx")
+ includeGroupAndSubgroups("com.android")
+ includeGroupAndSubgroups("com.google")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ google {
+ mavenContent {
+ includeGroupAndSubgroups("androidx")
+ includeGroupAndSubgroups("com.android")
+ includeGroupAndSubgroups("com.google")
+ }
+ }
+ mavenCentral()
+ }
+}
+
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+
+include(":androidApp")
+include(":desktopApp")
+include(":shared")
+include(":webApp")
\ No newline at end of file
diff --git a/frontend/shared/build.gradle.kts b/frontend/shared/build.gradle.kts
new file mode 100644
index 0000000..e659cea
--- /dev/null
+++ b/frontend/shared/build.gradle.kts
@@ -0,0 +1,103 @@
+import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING
+import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.BOOLEAN
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.androidMultiplatformLibrary)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+ alias(libs.plugins.buildkonfig)
+}
+
+buildkonfig {
+ packageName = "dev.zxq5.chatapp"
+
+ defaultConfigs {
+ buildConfigField(STRING, "VERSION_NAME", libs.versions.appVersion.get())
+ buildConfigField(BOOLEAN, "IS_DEBUG", "true") // default
+ }
+
+ // override per target/source set
+ targetConfigs {
+ create("android") {}
+ }
+}
+
+buildscript {
+ repositories {
+ mavenCentral()
+ }
+ dependencies {
+ classpath(libs.kotlin.gradle.plugin)
+ classpath(libs.buildkonfig.gradle.plugin)
+ }
+}
+
+kotlin {
+ jvm()
+
+ js {
+ browser()
+ binaries.executable()
+ }
+
+ @OptIn(ExperimentalWasmDsl::class)
+ wasmJs {
+ browser()
+ binaries.executable()
+ }
+
+ android {
+ namespace = "dev.zxq5.chatapp.shared"
+ compileSdk = libs.versions.android.compileSdk.get().toInt()
+ minSdk = libs.versions.android.minSdk.get().toInt()
+
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_11
+ }
+ androidResources {
+ enable = true
+ }
+ withHostTest {
+ isIncludeAndroidResources = true
+ }
+ withDeviceTestBuilder {
+ sourceSetTreeName = "test"
+ }.configure {
+ instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+ }
+
+ sourceSets {
+ androidMain.dependencies {
+ implementation(libs.compose.uiToolingPreview)
+ implementation(libs.compose.uiTooling)
+ }
+ commonMain.dependencies {
+ implementation(libs.compose.runtime)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.ui)
+ implementation(libs.compose.components.resources)
+ implementation(libs.compose.uiToolingPreview)
+ implementation(libs.androidx.lifecycle.viewmodelCompose)
+ implementation(libs.androidx.lifecycle.runtimeCompose)
+ implementation(libs.compose.materialIconsExtended)
+ implementation(libs.qrose)
+ implementation(libs.cryptography)
+ implementation(libs.kotlinx.datetime)
+ }
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
+ }
+// jsMain.dependencies {
+// implementation(libs.wrappers.browser)
+// }
+ }
+}
+
+dependencies {
+ androidRuntimeClasspath(libs.compose.uiTooling)
+}
\ No newline at end of file
diff --git a/frontend/shared/src/androidHostTest/kotlin/dev/zxq5/chatapp/SharedLogicAndroidHostTest.kt b/frontend/shared/src/androidHostTest/kotlin/dev/zxq5/chatapp/SharedLogicAndroidHostTest.kt
new file mode 100644
index 0000000..7c48586
--- /dev/null
+++ b/frontend/shared/src/androidHostTest/kotlin/dev/zxq5/chatapp/SharedLogicAndroidHostTest.kt
@@ -0,0 +1,12 @@
+package dev.zxq5.chatapp
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SharedLogicAndroidHostTest {
+
+ @Test
+ fun example() {
+ assertEquals(3, 1 + 2)
+ }
+}
\ No newline at end of file
diff --git a/frontend/shared/src/androidMain/kotlin/dev/zxq5/chatapp/Platform.android.kt b/frontend/shared/src/androidMain/kotlin/dev/zxq5/chatapp/Platform.android.kt
new file mode 100644
index 0000000..520fd54
--- /dev/null
+++ b/frontend/shared/src/androidMain/kotlin/dev/zxq5/chatapp/Platform.android.kt
@@ -0,0 +1,9 @@
+package dev.zxq5.chatapp
+
+import android.os.Build
+
+class AndroidPlatform : Platform {
+ override val name: String = "Android ${Build.VERSION.SDK_INT}"
+}
+
+actual fun getPlatform(): Platform = AndroidPlatform()
\ No newline at end of file
diff --git a/frontend/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/frontend/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml
new file mode 100644
index 0000000..1ffc948
--- /dev/null
+++ b/frontend/shared/src/commonMain/composeResources/drawable/compose-multiplatform.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/App.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/App.kt
new file mode 100644
index 0000000..e813a6a
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/App.kt
@@ -0,0 +1,82 @@
+package dev.zxq5.chatapp
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
+
+import dev.zxq5.chatapp.ui.pages.Page
+import dev.zxq5.chatapp.ui.pages.SettingsPage
+
+@Composable
+@Preview
+fun App() {
+ ChatappTheme {
+ var currentScreen by remember { mutableStateOf(SettingsPage) }
+ var authenticated by remember { mutableStateOf(false) }
+
+ if (!authenticated) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ bottomBar = { NavBar(currentScreen) { screen ->
+ currentScreen = screen
+ }},
+ topBar = { currentScreen.TopBar() }
+ ) { innerPadding ->
+ Box(modifier = Modifier.padding(innerPadding)) {
+ currentScreen.Content()
+ }
+ }
+ } else {
+ Scaffold() {}
+ }
+ }
+}
+
+
+@Composable
+fun NavBar(currentScreen: Page, onNavigate: (Page) -> Unit) {
+ NavigationBar(
+ containerColor = MaterialTheme.colorScheme.background,
+ tonalElevation = 0.dp,
+ modifier = Modifier
+ .height(80.dp)
+ .border(
+ 0.5.dp,
+ MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f),
+ RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp)
+ )
+ ) {
+ val colors = NavigationBarItemDefaults.colors(
+ selectedIconColor = MaterialTheme.colorScheme.primary,
+ unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ indicatorColor = Color.Transparent
+ )
+
+ for (entry in Page.entries) {
+ NavigationBarItem(
+ selected = currentScreen == entry,
+ onClick = { onNavigate(entry) },
+ icon = { Icon(entry.icon, contentDescription = entry.title) },
+ label = { Text(entry.title, style = MaterialTheme.typography.labelSmall) },
+ colors = colors
+ )
+ }
+ }
+}
+
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/Platform.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/Platform.kt
new file mode 100644
index 0000000..ffaebf0
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/Platform.kt
@@ -0,0 +1,7 @@
+package dev.zxq5.chatapp
+
+interface Platform {
+ val name: String
+}
+
+expect fun getPlatform(): Platform
\ No newline at end of file
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/model/Message.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/model/Message.kt
new file mode 100644
index 0000000..21c8824
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/model/Message.kt
@@ -0,0 +1,13 @@
+package dev.zxq5.chatapp.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlin.time.Instant
+
+@Serializable
+data class Message(
+ @SerialName("user_id") val userId: Int,
+ @SerialName("display_name") val displayName: String,
+ @SerialName("text") val content: String,
+ val timestamp: Instant
+)
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/components/Section.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/components/Section.kt
new file mode 100644
index 0000000..ba0b32f
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/components/Section.kt
@@ -0,0 +1,66 @@
+package dev.zxq5.chatapp.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun ExpandingSection(
+ title: String,
+ content: @Composable () -> Unit
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(12.dp))
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expanded = !expanded }
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(title, style = MaterialTheme.typography.titleLarge)
+ Icon(
+ if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+
+ AnimatedVisibility(visible = expanded) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).padding(bottom = 8.dp)) {
+ content()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/ContactsPage.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/ContactsPage.kt
new file mode 100644
index 0000000..255a4f1
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/ContactsPage.kt
@@ -0,0 +1,164 @@
+package dev.zxq5.chatapp.ui.pages
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Nfc
+import androidx.compose.material.icons.outlined.Contacts
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.whyoleg.cryptography.BinarySize.Companion.bits
+import dev.whyoleg.cryptography.CryptographyProvider
+import dev.whyoleg.cryptography.algorithms.HKDF
+import dev.whyoleg.cryptography.algorithms.SHA256
+import dev.whyoleg.cryptography.random.CryptographyRandom
+import dev.zxq5.chatapp.ui.AppButton
+import dev.zxq5.chatapp.ui.components.ExpandingSection
+import dev.zxq5.chatapp.ui.SettingsField
+import dev.zxq5.chatapp.util.ActionState
+import io.github.alexzhirkevich.qrose.rememberQrCodePainter
+
+data object ContactsPage: Page(title = "Contacts", icon = Icons.Outlined.Contacts) {
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ override fun Content() {
+ var contacts by remember { mutableStateOf(listOf("zxq5", "admin")) }
+ var showAddSection by remember { mutableStateOf(false) }
+ val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden, enabledValues = setOf(
+ SheetValue.Expanded, SheetValue.Hidden, SheetValue.PartiallyExpanded
+ ))
+
+ Scaffold(
+ floatingActionButton = {
+ ExtendedFloatingActionButton(
+ text = { Text("add new contact") },
+ icon = { Icon(Icons.Filled.Add, contentDescription = "") },
+ onClick = {
+ showAddSection = true
+ }
+ )
+ }
+ ) { padding ->
+ Column {
+ for (contact in contacts) {
+ Card { Text(contact) }
+ }
+ }
+ if (showAddSection) {
+ ModalBottomSheet(
+ onDismissRequest = { showAddSection = false },
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text("Add new contact", style = MaterialTheme.typography.titleLarge)
+ ExpandingSection("Add Via QR Code") {
+ val payloadString = createKey().toString()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Image(
+ painter = rememberQrCodePainter(payloadString),
+ contentDescription = "Your contact QR code",
+ modifier = Modifier
+ .fillMaxWidth(0.8f)
+ .aspectRatio(1f)
+ )
+ // TODO onclick!
+ AppButton(label = "Scan A friend's QR", onClick = {})
+ }
+ }
+
+ ExpandingSection("Add Via NFC (Android)") {
+ var showNFCDropdown by remember { mutableStateOf(false) }
+ val innerSheetState =
+ rememberBottomSheetState(initialValue = SheetValue.Hidden)
+
+ AppButton(label = "Add Friend", onClick = { showNFCDropdown = true })
+
+ if (showNFCDropdown) {
+ ModalBottomSheet(
+ onDismissRequest = { showNFCDropdown = false },
+ sheetState = innerSheetState
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxWidth().aspectRatio(1f),
+ ) {
+ Icon(
+ Icons.Filled.Nfc, "NFC Icon",
+ modifier = Modifier.fillMaxWidth(0.5f).aspectRatio(1f)
+ )
+ }
+ }
+ }
+ }
+
+ ExpandingSection("Add via Username") {
+ var usernameInput by remember { mutableStateOf("") }
+ var success by remember { mutableStateOf(ActionState.NotReady) }
+ SettingsField(
+ label = "username",
+ value = usernameInput,
+ onValueChange = { usernameInput = it },
+ buttonLabel = "Add friend",
+ actionState = success,
+ onClick = {}
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fun createKey(): ByteArray {
+ val hkdf = CryptographyProvider.Default.get(HKDF)
+
+// Input key material -- e.g., a shared secret from ECDH
+ val inputKeyMaterial = ByteArray(32) // placeholder for actual key material
+
+ val salt = CryptographyRandom.nextBytes(32)
+
+ val derivation = hkdf.secretDerivation(
+ digest = SHA256,
+ outputSize = 256.bits,
+ salt = salt,
+ info = "encryption-key".encodeToByteArray()
+ )
+
+ val derivedKey: ByteArray = derivation.deriveSecretToByteArrayBlocking(inputKeyMaterial)
+ return derivedKey
+}
\ No newline at end of file
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/MessengerPage.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/MessengerPage.kt
new file mode 100644
index 0000000..dd43471
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/MessengerPage.kt
@@ -0,0 +1,93 @@
+package dev.zxq5.chatapp.ui.pages
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ChatBubbleOutline
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import dev.zxq5.chatapp.model.Message
+import dev.zxq5.chatapp.util.appFormat
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.format
+import kotlinx.datetime.format.DateTimeFormat
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+data object MessengerPage: Page(title = "Messages", icon = Icons.Outlined.ChatBubbleOutline) {
+ @Composable
+ override fun Content() {
+ val myUID = 0
+ val messages = listOf(
+ Message(userId = 0, displayName = "zxq5", content = "hello world", timestamp = Clock.System.now()),
+ Message(userId = 1, displayName = "zxq4", content = "hello world", timestamp = Clock.System.now()),
+ Message(userId = 2, displayName = "admin", content = "hello world", timestamp = Clock.System.now())
+ )
+
+ Column {
+ for (message in messages) {
+ MessageBubble(message, myUID)
+ }
+ }
+ }
+}
+
+@Composable
+fun MessageBubble(message: Message, currentUID: Int) {
+ val time = remember(message.timestamp) {
+ Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).appFormat()
+ }
+
+ val isMyMessage = message.userId == currentUID
+ val alignment = if (isMyMessage) Alignment.End else Alignment.Start
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = alignment
+ ) {
+ Surface(
+ color = if (isMyMessage) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
+ shape = RoundedCornerShape(
+ topStart = 14.dp,
+ topEnd = 14.dp,
+ bottomStart = if (isMyMessage) 14.dp else 4.dp,
+ bottomEnd = if (isMyMessage) 4.dp else 14.dp
+ ),
+ border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
+ if (!isMyMessage) {
+ Text(
+ message.displayName?.lowercase() ?: "unknown",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
+ modifier = Modifier.padding(bottom = 2.dp)
+ )
+ }
+ Text(
+ text = message.content,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f)
+ )
+ }
+ }
+ Text(
+ text = time,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
+ )
+ }
+}
\ No newline at end of file
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/Page.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/Page.kt
new file mode 100644
index 0000000..1146145
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/Page.kt
@@ -0,0 +1,31 @@
+package dev.zxq5.chatapp.ui.pages
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+
+sealed class Page(val title: String, val icon: ImageVector) {
+ @Composable
+ abstract fun Content()
+
+ @Composable
+ open fun TopBar() {
+ TopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ titleContentColor = MaterialTheme.colorScheme.primary,
+ ),
+ title = {
+ Text(title)
+ }
+ )
+ }
+
+ companion object {
+ val entries by lazy { listOf(MessengerPage, SpacesPage, ContactsPage, SettingsPage) }
+ }
+}
+
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/SettingsPage.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/SettingsPage.kt
new file mode 100644
index 0000000..296b68e
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/SettingsPage.kt
@@ -0,0 +1,172 @@
+package dev.zxq5.chatapp.ui.pages
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import dev.zxq5.chatapp.BuildKonfig
+import dev.zxq5.chatapp.ui.AppButton
+import dev.zxq5.chatapp.ui.SettingsField
+import dev.zxq5.chatapp.ui.components.ExpandingSection
+import dev.zxq5.chatapp.util.ActionState
+
+data object SettingsPage: Page(title = "Settings", icon = Icons.Outlined.Settings) {
+ @Composable
+ override fun Content() {
+ Scaffold { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ ExpandingSection("Build Info") {
+ Text("ChatApp v${BuildKonfig.VERSION_NAME} ${if (BuildKonfig.IS_DEBUG) " (Debug)" else ""}")
+ }
+
+ ExpandingSection("Profile") {
+ var displayName by remember { mutableStateOf("") }
+ var username by remember { mutableStateOf("") }
+
+ SettingsField(
+ label = "display name",
+ value = displayName,
+ onValueChange = { displayName = it },
+ buttonLabel = "update",
+ actionState = ActionState.NotReady,
+ onClick = {}
+ )
+
+ Spacer(Modifier.height(16.dp))
+
+ SettingsField(
+ label = "username",
+ value = username,
+ onValueChange = { username = it },
+ buttonLabel = "update",
+ actionState = ActionState.NotReady,
+ onClick = {}
+ )
+ }
+
+ ExpandingSection("Security") {
+ ChangePasswordDropdown()
+
+ }
+
+ ExpandingSection("Session") {
+ Text("Logout")
+ }
+
+ ExpandingSection("Danger Zone") {
+ Text("Delete Account")
+ }
+ }
+ }
+ }
+}
+
+class AuthenticationDialog {
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun Ui(): Boolean? {
+ var showDialog by remember { mutableStateOf(true) }
+ var sheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded)
+ var password by remember { mutableStateOf("") }
+
+ ModalBottomSheet(
+ onDismissRequest = { showDialog = false },
+ sheetState = sheetState
+ ) {
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("enter your password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp)
+ )
+ }
+
+ AppButton(
+ onClick = {}, // todo
+ label = "update password",
+ enabled = password.isNotEmpty() && password.isNotEmpty(),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ if (!showDialog) return false
+ if
+ }
+}
+
+
+
+@Composable
+fun ChangePasswordDropdown() = ExpandingSection("Change Password") {
+ var oldPassword by remember { mutableStateOf("") }
+ var newPassword by remember { mutableStateOf("") }
+ var confirmPassword by remember { mutableStateOf("") }
+
+ Text("change password", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
+ OutlinedTextField(
+ value = oldPassword,
+ onValueChange = { oldPassword = it },
+ label = { Text("old password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp)
+ )
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(
+ value = newPassword,
+ onValueChange = { newPassword = it },
+ label = { Text("new password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp)
+ )
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(
+ value = confirmPassword,
+ onValueChange = { confirmPassword = it },
+ label = { Text("new password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp)
+ )
+ Spacer(Modifier.height(12.dp))
+ AppButton(
+ onClick = {}, // todo
+ label = "update password",
+ enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty(),
+ modifier = Modifier.fillMaxWidth()
+ )
+}
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/SpacesPage.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/SpacesPage.kt
new file mode 100644
index 0000000..2fb6ce4
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/pages/SpacesPage.kt
@@ -0,0 +1,12 @@
+package dev.zxq5.chatapp.ui.pages
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Forum
+import androidx.compose.runtime.Composable
+
+data object SpacesPage: Page(title = "Spaces", icon = Icons.Outlined.Forum) {
+ @Composable
+ override fun Content() {
+
+ }
+}
\ No newline at end of file
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/style/Color.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/style/Color.kt
new file mode 100644
index 0000000..fec32eb
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/style/Color.kt
@@ -0,0 +1,20 @@
+package dev.zxq5.chatapp.android.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Black = Color(0xFF0A0A0A)
+val DarkGrey = Color(0xFF0D0D0D)
+val Grey = Color(0xFF141414)
+val LightGrey = Color(0xFF1E1E1E)
+val White = Color(0xFFE8E8E8)
+
+val TextPrimary = Color(0xFFE8E8E8)
+val TextSecondary = Color(0xFF888888)
+val TextTertiary = Color(0xFF555555)
+val TextMuted = Color(0xFF333333)
+
+val Border = Color(0xFF1A1A1A)
+val BorderLight = Color(0xFF222222)
+
+val Red = Color(0xFFFF0000)
+val Surface = Color(0xFF111111)
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/style/Theme.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/style/Theme.kt
new file mode 100644
index 0000000..855f7a9
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/style/Theme.kt
@@ -0,0 +1,33 @@
+package dev.zxq5.chatapp.android.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+
+private val DarkColorScheme = darkColorScheme(
+ primary = White,
+ onPrimary = Black,
+ secondary = LightGrey,
+ onSecondary = White,
+ tertiary = Grey,
+ background = Black,
+ onBackground = White,
+ surface = Black,
+ onSurface = White,
+ surfaceVariant = Grey,
+ onSurfaceVariant = TextSecondary,
+ outline = Border,
+ outlineVariant = BorderLight
+)
+
+@Composable
+fun ChatappTheme(
+ darkTheme: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = DarkColorScheme
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content
+ )
+}
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/textfield.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/textfield.kt
new file mode 100644
index 0000000..a19bcec
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/ui/textfield.kt
@@ -0,0 +1,110 @@
+package dev.zxq5.chatapp.ui
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import dev.zxq5.chatapp.util.ActionState
+
+
+@Composable
+fun SettingsField(
+ label: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+ buttonLabel: String,
+ actionState: ActionState,
+ onClick: () -> Unit
+) {
+ Column {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(label) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = if (actionState == ActionState.Success) Color.Green else MaterialTheme.colorScheme.primary
+ )
+ )
+ Spacer(Modifier.height(8.dp))
+ SuccessButton(
+ onClick = onClick,
+ label = buttonLabel,
+ actionState = actionState,
+ enabled = value.isNotBlank(),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+
+@Composable
+fun SuccessButton(
+ onClick: () -> Unit,
+ label: String,
+ actionState: ActionState,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ baseColor: Color = MaterialTheme.colorScheme.primary,
+ contentColor: Color = MaterialTheme.colorScheme.onPrimary,
+ successColor: Color = Color.Green.copy(alpha = 0.8f)
+) {
+ val backgroundColor by animateColorAsState(
+ targetValue = if (actionState == ActionState.Success) successColor else baseColor,
+ animationSpec = tween(500)
+ )
+
+ Button(
+ onClick = onClick,
+ modifier = modifier,
+ enabled = enabled,
+ shape = RoundedCornerShape(8.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = backgroundColor,
+ contentColor = if (actionState == ActionState.Success) Color.White else contentColor
+ )
+ ) {
+ Text(if (actionState == ActionState.Success) "success" else label)
+ }
+}
+
+@Composable
+fun AppButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ label: String = ""
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier.fillMaxWidth(),
+ enabled = enabled,
+ shape = RoundedCornerShape(8.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
+ ),
+ ) {
+ Text(label)
+ }
+}
\ No newline at end of file
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/util/ActionState.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/util/ActionState.kt
new file mode 100644
index 0000000..26cec00
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/util/ActionState.kt
@@ -0,0 +1,8 @@
+package dev.zxq5.chatapp.util
+
+enum class ActionState {
+ NotReady,
+ Pending,
+ Success,
+ Failure
+}
\ No newline at end of file
diff --git a/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/util/DateTime.kt b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/util/DateTime.kt
new file mode 100644
index 0000000..9d21ad2
--- /dev/null
+++ b/frontend/shared/src/commonMain/kotlin/dev/zxq5/chatapp/util/DateTime.kt
@@ -0,0 +1,35 @@
+package dev.zxq5.chatapp.util
+
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.format
+import kotlinx.datetime.todayIn
+import kotlinx.datetime.format.*
+import kotlin.time.Clock
+
+fun LocalDateTime.appFormat(timeZone: TimeZone = TimeZone.currentSystemDefault()): String {
+ val today = Clock.System.todayIn(timeZone)
+
+ val formatter = (if (this.date == today) {
+ LocalDateTime.Format {
+ hour()
+ char(':')
+ minute()
+ }
+ } else {
+ LocalDateTime.Format {
+ day()
+ char('/')
+ monthNumber()
+ char('/')
+ year()
+ char(' ')
+ hour()
+ char(':')
+ minute()
+ }
+ })
+
+ return this.format(formatter)
+}
diff --git a/frontend/shared/src/commonTest/kotlin/dev/zxq5/chatapp/SharedCommonTest.kt b/frontend/shared/src/commonTest/kotlin/dev/zxq5/chatapp/SharedCommonTest.kt
new file mode 100644
index 0000000..85102aa
--- /dev/null
+++ b/frontend/shared/src/commonTest/kotlin/dev/zxq5/chatapp/SharedCommonTest.kt
@@ -0,0 +1,12 @@
+package dev.zxq5.chatapp
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SharedCommonTest {
+
+ @Test
+ fun example() {
+ assertEquals(3, 1 + 2)
+ }
+}
\ No newline at end of file
diff --git a/frontend/shared/src/jsMain/kotlin/dev/zxq5/chatapp/Platform.js.kt b/frontend/shared/src/jsMain/kotlin/dev/zxq5/chatapp/Platform.js.kt
new file mode 100644
index 0000000..c5de0cd
--- /dev/null
+++ b/frontend/shared/src/jsMain/kotlin/dev/zxq5/chatapp/Platform.js.kt
@@ -0,0 +1,14 @@
+package dev.zxq5.chatapp
+
+import web.navigator.navigator
+
+class JsPlatform: Platform {
+ private val userAgent = navigator.userAgent
+ private val browserList = listOf("Chrome", "Firefox", "Safari", "Edge")
+
+ override val name: String = userAgent.findAnyOf(browserList, ignoreCase = true)
+ ?.let { (startIndex) -> userAgent.substring(startIndex).substringBefore(" ") }
+ ?: "Unknown"
+}
+
+actual fun getPlatform(): Platform = JsPlatform()
\ No newline at end of file
diff --git a/frontend/shared/src/jvmMain/kotlin/dev/zxq5/chatapp/Platform.jvm.kt b/frontend/shared/src/jvmMain/kotlin/dev/zxq5/chatapp/Platform.jvm.kt
new file mode 100644
index 0000000..bc533cb
--- /dev/null
+++ b/frontend/shared/src/jvmMain/kotlin/dev/zxq5/chatapp/Platform.jvm.kt
@@ -0,0 +1,7 @@
+package dev.zxq5.chatapp
+
+class JVMPlatform: Platform {
+ override val name: String = "Java ${System.getProperty("java.version")}"
+}
+
+actual fun getPlatform(): Platform = JVMPlatform()
\ No newline at end of file
diff --git a/frontend/shared/src/jvmTest/kotlin/dev/zxq5/chatapp/SharedLogicDesktopTest.kt b/frontend/shared/src/jvmTest/kotlin/dev/zxq5/chatapp/SharedLogicDesktopTest.kt
new file mode 100644
index 0000000..87ee05c
--- /dev/null
+++ b/frontend/shared/src/jvmTest/kotlin/dev/zxq5/chatapp/SharedLogicDesktopTest.kt
@@ -0,0 +1,12 @@
+package dev.zxq5.chatapp
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SharedLogicDesktopTest {
+
+ @Test
+ fun example() {
+ assertEquals(3, 1 + 2)
+ }
+}
\ No newline at end of file
diff --git a/frontend/shared/src/wasmJsMain/kotlin/dev/zxq5/chatapp/Platform.wasmJs.kt b/frontend/shared/src/wasmJsMain/kotlin/dev/zxq5/chatapp/Platform.wasmJs.kt
new file mode 100644
index 0000000..f11d7c0
--- /dev/null
+++ b/frontend/shared/src/wasmJsMain/kotlin/dev/zxq5/chatapp/Platform.wasmJs.kt
@@ -0,0 +1,7 @@
+package dev.zxq5.chatapp
+
+class WasmPlatform: Platform {
+ override val name: String = "Web with Kotlin/Wasm"
+}
+
+actual fun getPlatform(): Platform = WasmPlatform()
\ No newline at end of file
diff --git a/frontend/shared/src/webTest/kotlin/dev/zxq5/chatapp/SharedLogicWebTest.kt b/frontend/shared/src/webTest/kotlin/dev/zxq5/chatapp/SharedLogicWebTest.kt
new file mode 100644
index 0000000..2b0d4ad
--- /dev/null
+++ b/frontend/shared/src/webTest/kotlin/dev/zxq5/chatapp/SharedLogicWebTest.kt
@@ -0,0 +1,12 @@
+package dev.zxq5.chatapp
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SharedLogicWebTest {
+
+ @Test
+ fun example() {
+ assertEquals(3, 1 + 2)
+ }
+}
\ No newline at end of file
diff --git a/frontend/webApp/build.gradle.kts b/frontend/webApp/build.gradle.kts
new file mode 100644
index 0000000..1d8e205
--- /dev/null
+++ b/frontend/webApp/build.gradle.kts
@@ -0,0 +1,28 @@
+import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+}
+
+kotlin {
+ js {
+ browser()
+ binaries.executable()
+ }
+
+ @OptIn(ExperimentalWasmDsl::class)
+ wasmJs {
+ browser()
+ binaries.executable()
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(project(":shared"))
+
+ implementation(libs.compose.ui)
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/webApp/src/webMain/kotlin/dev/zxq5/chatapp/main.kt b/frontend/webApp/src/webMain/kotlin/dev/zxq5/chatapp/main.kt
new file mode 100644
index 0000000..b9485cd
--- /dev/null
+++ b/frontend/webApp/src/webMain/kotlin/dev/zxq5/chatapp/main.kt
@@ -0,0 +1,11 @@
+package dev.zxq5.chatapp
+
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.window.ComposeViewport
+
+@OptIn(ExperimentalComposeUiApi::class)
+fun main() {
+ ComposeViewport {
+ App()
+ }
+}
\ No newline at end of file
diff --git a/frontend/webApp/src/webMain/resources/index.html b/frontend/webApp/src/webMain/resources/index.html
new file mode 100644
index 0000000..36b9083
--- /dev/null
+++ b/frontend/webApp/src/webMain/resources/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Chatapp
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/webApp/src/webMain/resources/styles.css b/frontend/webApp/src/webMain/resources/styles.css
new file mode 100644
index 0000000..0549b10
--- /dev/null
+++ b/frontend/webApp/src/webMain/resources/styles.css
@@ -0,0 +1,7 @@
+html, body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+}
\ No newline at end of file