frontend v0.4.0

This commit is contained in:
2026-04-06 01:02:39 +01:00
parent 7c9b733813
commit d33eee1281
36 changed files with 821 additions and 185 deletions
+4 -1
View File
@@ -2,7 +2,10 @@
<module type="JAVA_MODULE" version="4"> <module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true"> <component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output /> <exclude-output />
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/backend/target" />
</content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="chatapp dev" uuid="81992477-fd6f-427e-a27e-7378c26db6ef">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://100.118.108.58:5432/chatapp_dev</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/android" />
</GradleProjectSettings>
</option>
</component>
</project>
+1
View File
@@ -1,4 +1,5 @@
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" project-jdk-name="25" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" project-jdk-name="25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />
</component> </component>
+3
View File
@@ -1,7 +1,9 @@
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
/keystore.properties
/.idea/caches /.idea/caches
/.idea/.cache
/.idea/libraries /.idea/libraries
/.idea/modules.xml /.idea/modules.xml
/.idea/workspace.xml /.idea/workspace.xml
@@ -13,3 +15,4 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
release/
+8
View File
@@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-04-02T14:33:39.814557661Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=00319362N000094" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
<SelectionState runConfigName="MainActivity"> <SelectionState runConfigName="MainActivity">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
+34 -3
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
@@ -8,6 +10,25 @@ android {
namespace = "dev.zxq5.chatapp.android" namespace = "dev.zxq5.chatapp.android"
compileSdk = 35 compileSdk = 35
val keystorePropertiesFile = rootProject.file("local.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
signingConfigs {
create("release") {
storeFile = file("${System.getProperty("user.home")}/keystores/chatapp.jks")
storePassword = keystoreProperties["KEYSTORE_PASSWORD"] as String?
?: System.getenv("KEYSTORE_PASSWORD")
?: ""
keyAlias = "chatapp"
keyPassword = keystoreProperties["KEY_PASSWORD"] as String?
?: System.getenv("KEY_PASSWORD")
?: ""
}
}
defaultConfig { defaultConfig {
applicationId = "dev.zxq5.chatapp.android" applicationId = "dev.zxq5.chatapp.android"
minSdk = 26 minSdk = 26
@@ -20,19 +41,30 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true // shrinks code
isShrinkResources = true // removes unused resources
signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
buildConfigField("String", "BASE_URL", "\"https://chat.zxq5.dev\"")
}
debug {
isMinifyEnabled = false
isDebuggable = true
applicationIdSuffix = ".debug" // lets you install both side by side
buildConfigField("String", "BASE_URL", "\"http://zxq5-x1:8000\"")
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }
@@ -44,7 +76,6 @@ dependencies {
implementation(libs.ktor.client.auth) // Auth plugin implementation(libs.ktor.client.auth) // Auth plugin
// Kotlinx Serialization // Kotlinx Serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// Coroutines // Coroutines
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
@@ -73,4 +104,4 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
} }
+27 -1
View File
@@ -18,4 +18,30 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
# Ktor
-keep class io.ktor.** { *; }
-keep class kotlinx.coroutines.** { *; }
# Kotlinx serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keep,includedescriptorclasses class dev.zxq5.chatapp.android.**$$serializer { *; }
-keepclassmembers class dev.zxq5.chatapp.android.** {
*** Companion;
}
-keepclasseswithmembers class dev.zxq5.chatapp.android.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep model classes (serialization needs these)
-keep class dev.zxq5.chatapp.android.api.model.** { *; }
-keep class dev.zxq5.chatapp.android.data.model.** { *; }
# Fix for missing errorprone and javax annotations used by Tink and other libraries
-dontwarn com.google.errorprone.annotations.**
-dontwarn javax.annotation.**
# Fix for missing java.lang.management referenced by Ktor (not available on Android)
-dontwarn java.lang.management.**
+10
View File
@@ -3,6 +3,10 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<application <application
android:name=".ChatApplication" android:name=".ChatApplication"
@@ -15,6 +19,12 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Chatapp" android:theme="@style/Theme.Chatapp"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<service
android:name=".core.service.MessageStreamService"
android:foregroundServiceType="dataSync"
android:exported="false"/>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -1,6 +1,9 @@
package dev.zxq5.chatapp.android package dev.zxq5.chatapp.android
import android.app.Application import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import dev.zxq5.chatapp.android.core.data.TokenStore import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.data.repository.AuthRepository import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.ChatRepository import dev.zxq5.chatapp.android.data.repository.ChatRepository
@@ -8,6 +11,10 @@ import dev.zxq5.chatapp.android.data.repository.SettingsRepository
class ChatApplication : Application() { class ChatApplication : Application() {
object AppState {
var isInForeground = false
}
val tokenStore by lazy { TokenStore(this) } val tokenStore by lazy { TokenStore(this) }
val authRepository by lazy { AuthRepository(tokenStore) } val authRepository by lazy { AuthRepository(tokenStore) }
val chatRepository by lazy { ChatRepository(tokenStore) } val chatRepository by lazy { ChatRepository(tokenStore) }
@@ -15,5 +22,30 @@ class ChatApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createNotificationChannels()
}
private fun createNotificationChannels() {
val messageChannel = NotificationChannel(
"messages",
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "New message notifications"
enableVibration(true)
}
// add this — required for the foreground service persistent notification
val serviceChannel = NotificationChannel(
"service",
"Background connection",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Keeps messages running in background"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(messageChannel)
manager.createNotificationChannel(serviceChannel)
} }
} }
@@ -4,25 +4,42 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Settings
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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import dev.zxq5.chatapp.android.core.data.TokenStore import dev.zxq5.chatapp.android.ChatApplication.AppState
import dev.zxq5.chatapp.android.data.repository.AuthRepository import dev.zxq5.chatapp.android.core.service.MessageStreamService
import dev.zxq5.chatapp.android.data.repository.AuthState import dev.zxq5.chatapp.android.data.repository.AuthState
import dev.zxq5.chatapp.android.data.repository.ChatRepository import dev.zxq5.chatapp.android.feature.auth.AuthScreen
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel 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.ChatViewModel
import dev.zxq5.chatapp.android.feature.chat.Screen import dev.zxq5.chatapp.android.feature.chat.Screen
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel import dev.zxq5.chatapp.android.feature.contacts.ContactsScreen
import dev.zxq5.chatapp.android.feature.auth.AuthScreen
import dev.zxq5.chatapp.android.feature.chat.ChatScreen
import dev.zxq5.chatapp.android.feature.settings.SettingsScreen import dev.zxq5.chatapp.android.feature.settings.SettingsScreen
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -30,7 +47,6 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val app = application as ChatApplication val app = application as ChatApplication
val tokenStore = app.tokenStore
val authRepository = app.authRepository val authRepository = app.authRepository
val chatRepository = app.chatRepository val chatRepository = app.chatRepository
val settingsRepository = app.settingsRepository val settingsRepository = app.settingsRepository
@@ -44,37 +60,126 @@ class MainActivity : ComponentActivity() {
val authState by authViewModel.authState.collectAsState() val authState by authViewModel.authState.collectAsState()
val currentScreen by chatViewModel.currentScreen.collectAsState() val currentScreen by chatViewModel.currentScreen.collectAsState()
val selectedChannelId by chatViewModel.channelId.collectAsState()
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> LaunchedEffect(authState) {
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) { when (authState) {
when (authState) { AuthState.Authenticated -> MessageStreamService.start(this@MainActivity)
AuthState.Authenticated -> { AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity)
when (currentScreen) { AuthState.AwaitingTotp -> {}
Screen.CHAT -> ChatScreen( }
viewModel = chatViewModel, }
onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) },
onLogout = { LaunchedEffect(Unit) {
authViewModel.logout() intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let {
chatViewModel.clearChat() chatViewModel.switchChannel(it.toLong())
} }
) }
Screen.SETTINGS -> SettingsScreen(
viewModel = settingsViewModel, if (authState == AuthState.Authenticated) {
onBack = { chatViewModel.navigateTo(Screen.CHAT) }, Scaffold(
onLogout = { modifier = Modifier.fillMaxSize(),
authViewModel.logout() bottomBar = {
chatViewModel.clearChat() // Only show bottom bar if we are NOT inside a specific chat channel
} if (selectedChannelId == null) {
) BottomDock(
} currentScreen = currentScreen,
onNavigate = { chatViewModel.navigateTo(it) }
)
} }
AuthState.AwaitingTotp, AuthState.Unauthenticated -> { }
AuthScreen(viewModel = authViewModel) ) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
when (currentScreen) {
Screen.CHAT -> ChatScreen(
viewModel = chatViewModel,
onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) },
onLogout = {
authViewModel.logout()
chatViewModel.clearChat()
}
)
Screen.CONTACTS -> ContactsScreen()
Screen.SETTINGS -> SettingsScreen(
viewModel = settingsViewModel,
onLogout = {
authViewModel.logout()
chatViewModel.clearChat()
}
)
} }
} }
} }
} else {
AuthScreen(viewModel = authViewModel)
} }
} }
} }
} }
override fun onResume() {
super.onResume()
AppState.isInForeground = true
}
override fun onPause() {
super.onPause()
AppState.isInForeground = false
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let { channelId ->
MessageStreamService.instance?.activeChannelId = channelId.toLong()
}
}
}
@Composable
fun BottomDock(currentScreen: Screen, onNavigate: (Screen) -> 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)
)
) {
NavigationBarItem(
selected = currentScreen == Screen.CHAT,
onClick = { onNavigate(Screen.CHAT) },
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
)
)
NavigationBarItem(
selected = currentScreen == Screen.CONTACTS,
onClick = { onNavigate(Screen.CONTACTS) },
icon = { Icon(Icons.Outlined.PeopleOutline, 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
)
)
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
)
)
}
} }
@@ -1,10 +1,11 @@
package dev.zxq5.chatapp.android.api package dev.zxq5.chatapp.android.api
import android.util.Log import android.util.Log
import dev.zxq5.chatapp.android.BuildConfig
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.LoginRequest import dev.zxq5.chatapp.android.api.model.LoginRequest
import dev.zxq5.chatapp.android.api.model.LoginResponse import dev.zxq5.chatapp.android.api.model.LoginResponse
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.error.ApiResult import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.api.model.SignupRequest import dev.zxq5.chatapp.android.api.model.SignupRequest
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@@ -1,14 +1,17 @@
package dev.zxq5.chatapp.android.api package dev.zxq5.chatapp.android.api
import dev.zxq5.chatapp.android.core.BASE_URL import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.Message import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.SendMessage import dev.zxq5.chatapp.android.api.model.SendMessage
import dev.zxq5.chatapp.android.api.model.SpaceDto
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.prepareGet import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
@@ -16,12 +19,15 @@ import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.readUTF8Line import io.ktor.utils.io.readLine
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
class ChatClient(private val token: String) { class ChatClient(private val token: String) {
private val http = HttpClient(Android) { private val http = HttpClient(Android) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true }) json(Json { ignoreUnknownKeys = true })
@@ -33,18 +39,21 @@ class ChatClient(private val token: String) {
} }
} }
suspend fun sendMessage(channelId: Int, userId: Int, text: String) { suspend fun getAccessibleChannels(): List<SpaceDto> = http.get("${BASE_URL}/api/accessible_channels").body()
@OptIn(ExperimentalTime::class)
suspend fun sendMessage(channelId: Long, userId: Int, text: String) {
http.post("${BASE_URL}/api/chat/$channelId") { http.post("${BASE_URL}/api/chat/$channelId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(SendMessage(user_id = userId, text = text, timestamp = System.currentTimeMillis())) setBody(SendMessage(user_id = userId, text = text, timestamp = Clock.System.now()))
} }
} }
fun messageStream(channelId: Int): Flow<Message> = flow { fun messageStream(channelId: Long): Flow<Message> = flow {
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response -> http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
val channel = response.bodyAsChannel() val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) { while (!channel.isClosedForRead) {
val line = channel.readUTF8Line(256) ?: break val line = channel.readLine() ?: break
if (line.startsWith("data:")) { if (line.startsWith("data:")) {
val json = line.removePrefix("data:").trim() val json = line.removePrefix("data:").trim()
runCatching { Json.decodeFromString<Message>(json) } runCatching { Json.decodeFromString<Message>(json) }
@@ -54,4 +63,3 @@ class ChatClient(private val token: String) {
} }
} }
} }
@@ -1,6 +1,7 @@
package dev.zxq5.chatapp.android.api package dev.zxq5.chatapp.android.api
import android.util.Log import android.util.Log
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest
import dev.zxq5.chatapp.android.api.model.DisplayNameRequest import dev.zxq5.chatapp.android.api.model.DisplayNameRequest
import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest
@@ -10,7 +11,6 @@ import dev.zxq5.chatapp.android.api.model.TotpStatus
import dev.zxq5.chatapp.android.api.model.UsernameRequest import dev.zxq5.chatapp.android.api.model.UsernameRequest
import dev.zxq5.chatapp.android.api.model.TotpDeleteRequest import dev.zxq5.chatapp.android.api.model.TotpDeleteRequest
import dev.zxq5.chatapp.android.api.model.PasswordRequest import dev.zxq5.chatapp.android.api.model.PasswordRequest
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.error.ApiResult import dev.zxq5.chatapp.android.core.error.ApiResult
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
@@ -0,0 +1,15 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable
data class Channel @OptIn(ExperimentalTime::class) constructor(
val id: Long,
val name: String,
val description: String? = null,
val space_id: Long,
val created_at: Instant,
val updated_at: Instant
)
@@ -1,11 +1,13 @@
package dev.zxq5.chatapp.android.api.model package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable @Serializable
data class Message( data class Message @OptIn(ExperimentalTime::class) constructor(
val user_id: Int, val user_id: Int,
val display_name: String, val display_name: String,
val text: String, val text: String,
val timestamp: Long val timestamp: Instant
) )
@@ -1,10 +1,12 @@
package dev.zxq5.chatapp.android.api.model package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable @Serializable
data class SendMessage( data class SendMessage @OptIn(ExperimentalTime::class) constructor(
val user_id: Int, val user_id: Int,
val text: String, val text: String,
val timestamp: Long val timestamp: Instant
) )
@@ -0,0 +1,28 @@
package dev.zxq5.chatapp.android.api.model
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@Serializable
data class Space @OptIn(ExperimentalTime::class) constructor(
val id: Long,
val name: String,
val description: String? = null,
val owner_id: Long,
val created_at: Instant,
val updated_at: Instant
)
@Serializable
data class SpaceDto @OptIn(ExperimentalTime::class) constructor(
val channels: List<Channel>,
val id: Long,
val name: String,
val description: String? = null,
val owner_id: Long,
val created_at: Instant,
val updated_at: Instant
)
@@ -1,3 +1,3 @@
package dev.zxq5.chatapp.android.core package dev.zxq5.chatapp.android.core
const val BASE_URL = "http://zxq5-x1:8000" //const val BASE_URL = "http://zxq5-x1:8000"
@@ -0,0 +1,104 @@
package dev.zxq5.chatapp.android.core.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import dev.zxq5.chatapp.android.ChatApplication
import dev.zxq5.chatapp.android.data.repository.ChatRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
// core/service/MessageStreamService.kt
class MessageStreamService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var notificationService: NotificationService
private lateinit var chatRepository: ChatRepository
// which channel the user is currently looking at
// set by the ViewModel when the user opens/closes a channel
var activeChannelId: Long? = null
set(value) {
field = value
Log.d("Service", "activeChannelId set to $value")
if (value != null) {
// restart stream with new channel
currentStreamJob?.cancel()
observeMessages()
}
}
private var currentStreamJob: kotlinx.coroutines.Job? = null
companion object {
var instance: MessageStreamService? = null
fun start(context: Context) {
val intent = Intent(context, MessageStreamService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, MessageStreamService::class.java))
}
}
override fun onCreate() {
super.onCreate()
instance = this
notificationService = NotificationService(this)
chatRepository = (application as ChatApplication).chatRepository
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(
NotificationService.FOREGROUND_NOTIFICATION_ID,
notificationService.buildForegroundNotification()
)
observeMessages()
return START_STICKY // restart if killed
}
private fun observeMessages() {
val channelId = activeChannelId ?: chatRepository.getLastActiveChannel()
Log.d("Service", "observeMessages called, channelId=$channelId")
if (channelId == null) {
Log.d("Service", "No channel to observe, waiting for switchChannel")
return
}
Log.d("Service", "Starting stream for channel $channelId")
currentStreamJob = serviceScope.launch {
chatRepository.messageStream(channelId)
.catch { e -> Log.e("Service", "Stream error", e) }
.collect { message ->
if (!ChatApplication.AppState.isInForeground) { // no channel focused, always notify
notificationService.showMessageNotification(
conversationId = activeChannelId.toString(),
senderName = message.display_name,
messagePreview = message.text.take(80)
)
}
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
instance = null
serviceScope.cancel()
}
}
@@ -0,0 +1,94 @@
package dev.zxq5.chatapp.android.core.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import dev.zxq5.chatapp.android.MainActivity
import dev.zxq5.chatapp.android.R
class NotificationService(private val context: Context) {
companion object {
const val CHANNEL_ID = "messages"
const val FOREGROUND_NOTIFICATION_ID = 1 // ← this needs to exist
}
private val manager = context.getSystemService(NotificationManager::class.java)
fun createChannels() {
// channel for new message notifications
val messageChannel = NotificationChannel(
CHANNEL_ID,
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
enableVibration(true)
}
// channel for the persistent foreground service notification
// low importance so it doesn't make noise
val serviceChannel = NotificationChannel(
"service",
"Background connection",
NotificationManager.IMPORTANCE_LOW
)
val mgr = context.getSystemService(NotificationManager::class.java)
mgr.createNotificationChannel(messageChannel)
mgr.createNotificationChannel(serviceChannel)
}
fun buildForegroundNotification(): Notification {
return NotificationCompat.Builder(context, "service")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("chatapp")
.setContentText("Connected")
.setOngoing(true)
.setSilent(true)
.build()
}
fun showMessageNotification(
conversationId: String,
senderName: String,
messagePreview: String, // for E2E this would be "New message" — no plaintext
notificationId: Int = conversationId.hashCode()
) {
// intent that opens the app to the right conversation when tapped
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("conversation_id", conversationId)
}
val pendingIntent = PendingIntent.getActivity(
context,
notificationId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, "messages")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName)
.setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true) // dismiss on tap
.build()
manager.notify(notificationId, notification)
}
fun dismissNotification(conversationId: String) {
manager.cancel(conversationId.hashCode())
}
fun dismissAll() {
manager.cancelAll()
}
}
@@ -3,6 +3,7 @@ package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ChatClient import dev.zxq5.chatapp.android.api.ChatClient
import dev.zxq5.chatapp.android.core.data.TokenStore import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.api.model.Message import dev.zxq5.chatapp.android.api.model.Message
import dev.zxq5.chatapp.android.api.model.SpaceDto
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
@@ -11,6 +12,8 @@ class ChatRepository(private val tokenStore: TokenStore) {
private var _chatClient: ChatClient? = null private var _chatClient: ChatClient? = null
private var _lastToken: String? = null private var _lastToken: String? = null
private var _lastActiveChannel: Long? = null
private fun getChatClient(): ChatClient? { private fun getChatClient(): ChatClient? {
val token = tokenStore.get() ?: return null val token = tokenStore.get() ?: return null
if (_chatClient == null || token != _lastToken) { if (_chatClient == null || token != _lastToken) {
@@ -25,14 +28,23 @@ class ChatRepository(private val tokenStore: TokenStore) {
_lastToken = null _lastToken = null
} }
fun getLastActiveChannel(): Long? {
return _lastActiveChannel
}
fun getUserId() = tokenStore.getUserId() fun getUserId() = tokenStore.getUserId()
suspend fun sendMessage(channelId: Int, text: String) { suspend fun getAccessibleChannels(): List<SpaceDto> {
return getChatClient()?.getAccessibleChannels() ?: emptyList()
}
suspend fun sendMessage(channelId: Long, text: String) {
val userId = tokenStore.getUserId() ?: return val userId = tokenStore.getUserId() ?: return
getChatClient()?.sendMessage(channelId, userId, text) getChatClient()?.sendMessage(channelId, userId, text)
} }
fun messageStream(channelId: Int): Flow<Message> { fun messageStream(channelId: Long): Flow<Message> {
_lastActiveChannel = channelId
return getChatClient()?.messageStream(channelId) ?: emptyFlow() return getChatClient()?.messageStream(channelId) ?: emptyFlow()
} }
} }
@@ -2,6 +2,7 @@ package dev.zxq5.chatapp.android.feature.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.core.service.MessageStreamService
import dev.zxq5.chatapp.android.data.repository.AuthRepository import dev.zxq5.chatapp.android.data.repository.AuthRepository
import dev.zxq5.chatapp.android.data.repository.LoginResult import dev.zxq5.chatapp.android.data.repository.LoginResult
import dev.zxq5.chatapp.android.data.repository.SignupResult import dev.zxq5.chatapp.android.data.repository.SignupResult
@@ -3,8 +3,12 @@ package dev.zxq5.chatapp.android.feature.chat
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.data.repository.ChatRepository
import dev.zxq5.chatapp.android.api.model.Message 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 kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -12,15 +16,13 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() { class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList()) private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages val messages: StateFlow<List<Message>> = _messages
private val _channelId = MutableStateFlow<Int?>(null) private val _channelId = MutableStateFlow<Long?>(null)
val channelId: StateFlow<Int?> = _channelId val channelId: StateFlow<Long?> = _channelId
private val _currentScreen = MutableStateFlow(Screen.CHAT) private val _currentScreen = MutableStateFlow(Screen.CHAT)
val currentScreen: StateFlow<Screen> = _currentScreen val currentScreen: StateFlow<Screen> = _currentScreen
@@ -28,11 +30,35 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
private val _currentUserId = MutableStateFlow<Int?>(null) private val _currentUserId = MutableStateFlow<Int?>(null)
val currentUserId: StateFlow<Int?> = _currentUserId val currentUserId: StateFlow<Int?> = _currentUserId
private val _spaces = MutableStateFlow<List<SpaceDto>>(emptyList())
val spaces: StateFlow<List<SpaceDto>> = _spaces
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
private val _channelError = MutableStateFlow<String?>(null)
val channelError: StateFlow<String?> = _channelError
private var streamJob: Job? = null private var streamJob: Job? = null
init { init {
_currentUserId.value = chatRepository.getUserId() _currentUserId.value = chatRepository.getUserId()
observeChannel() observeChannel()
loadAccessibleChannels()
}
fun loadAccessibleChannels() {
_error.value = null
viewModelScope.launch {
runCatching {
chatRepository.getAccessibleChannels()
}.onSuccess { data ->
_spaces.value = data
}.onFailure { e ->
Log.e("Chat", "Failed to load spaces", e)
_error.value = "Failed to load channels: ${e.message}"
}
}
} }
private fun observeChannel() { private fun observeChannel() {
@@ -40,11 +66,13 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
_channelId.collect { id -> _channelId.collect { id ->
streamJob?.cancel() streamJob?.cancel()
_messages.value = emptyList() _messages.value = emptyList()
_channelError.value = null
if (id != null) { if (id != null) {
streamJob = launch { streamJob = launch {
chatRepository.messageStream(id) chatRepository.messageStream(id)
.catch { e -> .catch { e ->
Log.e("Chat", "Stream error", e) Log.e("Chat", "Stream error", e)
_channelError.value = "Connection lost: ${e.message}"
} }
.collect { message -> .collect { message ->
_messages.update { it + message } _messages.update { it + message }
@@ -59,12 +87,14 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
_currentScreen.value = screen _currentScreen.value = screen
} }
fun switchChannel(id: Int?) { fun switchChannel(id: Long?) {
_channelId.value = id _channelId.value = id
MessageStreamService.instance?.activeChannelId = id
if (id != null) { if (id != null) {
// Refresh user ID just in case it wasn't available at init // Refresh user ID just in case it wasn't available at init
_currentUserId.value = chatRepository.getUserId() _currentUserId.value = chatRepository.getUserId()
chatRepository.resetClient()
} }
} }
@@ -78,6 +108,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
) )
}.onFailure { e -> }.onFailure { e ->
Log.e("Chat", "Send message error", e) Log.e("Chat", "Send message error", e)
_channelError.value = "Failed to send message"
} }
} }
} }
@@ -86,8 +117,14 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
_messages.value = emptyList() _messages.value = emptyList()
_channelId.value = null _channelId.value = null
_currentUserId.value = null _currentUserId.value = null
_error.value = null
_channelError.value = null
streamJob?.cancel() streamJob?.cancel()
chatRepository.resetClient() chatRepository.resetClient()
MessageStreamService.instance?.activeChannelId = null
}
fun clearChannelError() {
_channelError.value = null
} }
} }
@@ -1,5 +1,5 @@
package dev.zxq5.chatapp.android.feature.chat package dev.zxq5.chatapp.android.feature.chat
enum class Screen { enum class Screen {
CHAT, SETTINGS CHAT, CONTACTS, SETTINGS
} }
@@ -28,22 +28,21 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -56,26 +55,29 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.api.model.Channel
import dev.zxq5.chatapp.android.api.model.Message import dev.zxq5.chatapp.android.api.model.Message
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
import kotlin.time.ExperimentalTime
@Composable @Composable
fun ChatScreen( fun ChatScreen(
viewModel: ChatViewModel, viewModel: ChatViewModel,
onNavigateToSettings: () -> Unit, onNavigateToSettings: () -> Unit,
onLogout: () -> Unit // Note: logout is now part of SettingsScreen in this UI, but we'll keep the param for now onLogout: () -> Unit
) { ) {
val selectedChannelId by viewModel.channelId.collectAsState() val selectedChannelId by viewModel.channelId.collectAsState()
if (selectedChannelId == null) { if (selectedChannelId == null) {
ChannelListScreen( ChannelListScreen(
viewModel = viewModel, viewModel = viewModel,
onChannelSelect = { viewModel.switchChannel(it) }, onChannelSelect = { viewModel.switchChannel(it) }
onNavigateToSettings = onNavigateToSettings
) )
} else { } else {
MessageScreen( MessageScreen(
@@ -90,20 +92,15 @@ fun ChatScreen(
@Composable @Composable
fun ChannelListScreen( fun ChannelListScreen(
viewModel: ChatViewModel, viewModel: ChatViewModel,
onChannelSelect: (Int) -> Unit, onChannelSelect: (Long) -> Unit
onNavigateToSettings: () -> Unit
) { ) {
val spaces by viewModel.spaces.collectAsState()
val error by viewModel.error.collectAsState()
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
topBar = { topBar = {
Column { Column {
Spacer(Modifier.height(8.dp))
Text(
"contacts",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 20.dp)
)
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
@@ -115,103 +112,69 @@ fun ChannelListScreen(
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
titleContentColor = MaterialTheme.colorScheme.onSurface titleContentColor = MaterialTheme.colorScheme.onSurface
) ),
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
) )
Text( Text(
"5 channels · end-to-end encrypted", "Public channels - dms coming soon.",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp) modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp)
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
}
Row( }
modifier = Modifier ) { padding ->
.fillMaxWidth() if (error != null) {
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)) Column(
.padding(horizontal = 20.dp, vertical = 8.dp), modifier = Modifier.fillMaxSize().padding(padding),
verticalAlignment = Alignment.CenterVertically horizontalAlignment = Alignment.CenterHorizontally,
) { verticalArrangement = Arrangement.Center
Box( ) {
modifier = Modifier Text(
.size(6.dp) text = error!!,
.clip(CircleShape) color = MaterialTheme.colorScheme.error,
.background(MaterialTheme.colorScheme.primary) textAlign = TextAlign.Center,
) modifier = Modifier.padding(16.dp)
Spacer(Modifier.width(10.dp)) )
Text( Button(onClick = { viewModel.loadAccessibleChannels() }) {
"global · walkie talkie", Icon(Icons.Default.Refresh, contentDescription = null)
style = MaterialTheme.typography.labelSmall, Spacer(Modifier.width(8.dp))
color = MaterialTheme.colorScheme.onSurfaceVariant, Text("Retry")
modifier = Modifier.weight(1f)
)
Surface(
color = Color.Transparent,
border = border(0.5.dp, MaterialTheme.colorScheme.outlineVariant),
shape = RoundedCornerShape(4.dp)
) {
Text(
"hold to talk",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
}, } else {
bottomBar = { BottomDock(viewModel, onNavigateToSettings) } LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
) { padding -> spaces.forEach { spaceDto ->
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) { item {
items(10) { i -> Text(
val id = i + 1 text = spaceDto.name.lowercase(),
ChannelItem(id = id, onClick = { onChannelSelect(id) }) modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
HorizontalDivider( style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.primary,
thickness = 0.5.dp, fontWeight = FontWeight.Bold
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f) )
) }
items(spaceDto.channels) { channel ->
ChannelItem(channel = channel, onClick = { onChannelSelect(channel.id) })
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)
)
}
item {
Spacer(Modifier.height(16.dp))
}
}
} }
} }
} }
} }
@Composable @Composable
fun BottomDock(viewModel: ChatViewModel, onNavigateToSettings: () -> Unit) { fun ChannelItem(channel: Channel, onClick: () -> Unit) {
val currentScreen by viewModel.currentScreen.collectAsState()
NavigationBar(
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
modifier = Modifier.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp))
) {
NavigationBarItem(
selected = currentScreen == Screen.CHAT,
onClick = { viewModel.navigateTo(Screen.CHAT) },
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
)
)
NavigationBarItem(
selected = currentScreen == Screen.SETTINGS,
onClick = onNavigateToSettings,
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
)
)
}
}
@Composable
fun ChannelItem(id: Int, onClick: () -> Unit) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -227,7 +190,7 @@ fun ChannelItem(id: Int, onClick: () -> Unit) {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
"C$id", channel.name.take(1).uppercase(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -235,31 +198,30 @@ fun ChannelItem(id: Int, onClick: () -> Unit) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "channel $id", text = channel.name,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Text( if (channel.description != null) {
text = "tap to join", Text(
style = MaterialTheme.typography.labelSmall, text = channel.description,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) style = MaterialTheme.typography.labelSmall,
) color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
} }
Text(
"14:22",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit) { fun MessageScreen(channelId: Long, viewModel: ChatViewModel, onBack: () -> Unit) {
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsState()
val currentUserId by viewModel.currentUserId.collectAsState() val currentUserId by viewModel.currentUserId.collectAsState()
val channelError by viewModel.channelError.collectAsState()
var input by remember { mutableStateOf("") } var input by remember { mutableStateOf("") }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(messages.size) { LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
@@ -267,8 +229,16 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
} }
} }
LaunchedEffect(channelError) {
channelError?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearChannelError()
}
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
@@ -294,6 +264,7 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
) )
} }
}, },
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent containerColor = Color.Transparent
) )
@@ -391,10 +362,13 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
} }
} }
@OptIn(ExperimentalTime::class)
@Composable @Composable
fun MessageBubble(message: Message, currentUserId: Int?) { fun MessageBubble(message: Message, currentUserId: Int?) {
val time = remember(message.timestamp) { val time = remember(message.timestamp) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(message.timestamp)).lowercase() DateFormat.getTimeInstance(DateFormat.SHORT)
.format(Date(message.timestamp.toEpochMilliseconds()))
.lowercase()
} }
val isMe = currentUserId != null && message.user_id == currentUserId val isMe = currentUserId != null && message.user_id == currentUserId
@@ -0,0 +1,52 @@
package dev.zxq5.chatapp.android.feature.contacts
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsScreen() {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Text(
"contacts",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = "Contacts coming soon",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -63,7 +63,6 @@ import androidx.compose.ui.text.style.TextAlign
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
val is2faEnabled by viewModel.is2faEnabled.collectAsState() val is2faEnabled by viewModel.is2faEnabled.collectAsState()
@@ -88,15 +87,7 @@ fun SettingsScreen(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
}, },
navigationIcon = { windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
) )
} }
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"/>
</vector>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="chatapp_dev@100.118.108.58" uuid="b14acf5d-6750-469b-8aea-59c8343eb11c">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://100.118.108.58:5432/chatapp_dev</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/sql/schema.sql" value="b14acf5d-6750-469b-8aea-59c8343eb11c" />
<file url="file://$PROJECT_DIR$/src/repo/user_repo.rs" value="b14acf5d-6750-469b-8aea-59c8343eb11c" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
</modules>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/sql/schema.sql" dialect="PostgreSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>