frontend v0.4.0
This commit is contained in:
Generated
+4
-1
@@ -2,7 +2,10 @@
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<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="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
||||
Generated
+17
@@ -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>
|
||||
Generated
+11
@@ -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>
|
||||
Generated
+1
@@ -1,4 +1,5 @@
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="25" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/keystore.properties
|
||||
/.idea/caches
|
||||
/.idea/.cache
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
@@ -13,3 +15,4 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
release/
|
||||
+8
@@ -4,6 +4,14 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<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 runConfigName="MainActivity">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
@@ -8,6 +10,25 @@ android {
|
||||
namespace = "dev.zxq5.chatapp.android"
|
||||
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 {
|
||||
applicationId = "dev.zxq5.chatapp.android"
|
||||
minSdk = 26
|
||||
@@ -20,19 +41,30 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true // shrinks code
|
||||
isShrinkResources = true // removes unused resources
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +76,6 @@ dependencies {
|
||||
implementation(libs.ktor.client.auth) // Auth plugin
|
||||
// Kotlinx Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Coroutines
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
|
||||
Vendored
+26
@@ -19,3 +19,29 @@
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-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.**
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<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
|
||||
android:name=".ChatApplication"
|
||||
@@ -15,6 +19,12 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Chatapp"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<service
|
||||
android:name=".core.service.MessageStreamService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package dev.zxq5.chatapp.android
|
||||
|
||||
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.data.repository.AuthRepository
|
||||
import dev.zxq5.chatapp.android.data.repository.ChatRepository
|
||||
@@ -8,6 +11,10 @@ import dev.zxq5.chatapp.android.data.repository.SettingsRepository
|
||||
|
||||
class ChatApplication : Application() {
|
||||
|
||||
object AppState {
|
||||
var isInForeground = false
|
||||
}
|
||||
|
||||
val tokenStore by lazy { TokenStore(this) }
|
||||
val authRepository by lazy { AuthRepository(tokenStore) }
|
||||
val chatRepository by lazy { ChatRepository(tokenStore) }
|
||||
@@ -15,5 +22,30 @@ class ChatApplication : Application() {
|
||||
|
||||
override fun 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.compose.setContent
|
||||
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.height
|
||||
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.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||
import dev.zxq5.chatapp.android.data.repository.AuthRepository
|
||||
import dev.zxq5.chatapp.android.ChatApplication.AppState
|
||||
import dev.zxq5.chatapp.android.core.service.MessageStreamService
|
||||
import dev.zxq5.chatapp.android.data.repository.AuthState
|
||||
import dev.zxq5.chatapp.android.data.repository.ChatRepository
|
||||
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
|
||||
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.feature.settings.SettingsViewModel
|
||||
import dev.zxq5.chatapp.android.feature.auth.AuthScreen
|
||||
import dev.zxq5.chatapp.android.feature.chat.ChatScreen
|
||||
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() {
|
||||
@@ -30,7 +47,6 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val app = application as ChatApplication
|
||||
val tokenStore = app.tokenStore
|
||||
val authRepository = app.authRepository
|
||||
val chatRepository = app.chatRepository
|
||||
val settingsRepository = app.settingsRepository
|
||||
@@ -44,11 +60,36 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
val authState by authViewModel.authState.collectAsState()
|
||||
val currentScreen by chatViewModel.currentScreen.collectAsState()
|
||||
val selectedChannelId by chatViewModel.channelId.collectAsState()
|
||||
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) {
|
||||
LaunchedEffect(authState) {
|
||||
when (authState) {
|
||||
AuthState.Authenticated -> {
|
||||
AuthState.Authenticated -> MessageStreamService.start(this@MainActivity)
|
||||
AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity)
|
||||
AuthState.AwaitingTotp -> {}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let {
|
||||
chatViewModel.switchChannel(it.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
if (authState == AuthState.Authenticated) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
// Only show bottom bar if we are NOT inside a specific chat channel
|
||||
if (selectedChannelId == null) {
|
||||
BottomDock(
|
||||
currentScreen = currentScreen,
|
||||
onNavigate = { chatViewModel.navigateTo(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.padding(innerPadding)) {
|
||||
when (currentScreen) {
|
||||
Screen.CHAT -> ChatScreen(
|
||||
viewModel = chatViewModel,
|
||||
@@ -58,9 +99,9 @@ class MainActivity : ComponentActivity() {
|
||||
chatViewModel.clearChat()
|
||||
}
|
||||
)
|
||||
Screen.CONTACTS -> ContactsScreen()
|
||||
Screen.SETTINGS -> SettingsScreen(
|
||||
viewModel = settingsViewModel,
|
||||
onBack = { chatViewModel.navigateTo(Screen.CHAT) },
|
||||
onLogout = {
|
||||
authViewModel.logout()
|
||||
chatViewModel.clearChat()
|
||||
@@ -68,13 +109,77 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
AuthState.AwaitingTotp, AuthState.Unauthenticated -> {
|
||||
}
|
||||
} 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
|
||||
|
||||
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.LoginResponse
|
||||
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.api.model.SignupRequest
|
||||
import io.ktor.client.HttpClient
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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.SendMessage
|
||||
import dev.zxq5.chatapp.android.api.model.SpaceDto
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.plugins.auth.Auth
|
||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||
import io.ktor.client.plugins.auth.providers.bearer
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.prepareGet
|
||||
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.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.serialization.json.Json
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
class ChatClient(private val token: String) {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
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") {
|
||||
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 ->
|
||||
val channel = response.bodyAsChannel()
|
||||
while (!channel.isClosedForRead) {
|
||||
val line = channel.readUTF8Line(256) ?: break
|
||||
val line = channel.readLine() ?: break
|
||||
if (line.startsWith("data:")) {
|
||||
val json = line.removePrefix("data:").trim()
|
||||
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
|
||||
|
||||
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.DisplayNameRequest
|
||||
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.TotpDeleteRequest
|
||||
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 io.ktor.client.HttpClient
|
||||
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
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Serializable
|
||||
data class Message(
|
||||
data class Message @OptIn(ExperimentalTime::class) constructor(
|
||||
val user_id: Int,
|
||||
val display_name: String,
|
||||
val text: String,
|
||||
val timestamp: Long
|
||||
val timestamp: Instant
|
||||
)
|
||||
@@ -1,10 +1,12 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Serializable
|
||||
data class SendMessage(
|
||||
data class SendMessage @OptIn(ExperimentalTime::class) constructor(
|
||||
val user_id: Int,
|
||||
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
|
||||
|
||||
const val BASE_URL = "http://zxq5-x1:8000"
|
||||
//const val BASE_URL = "http://zxq5-x1:8000"
|
||||
+104
@@ -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()
|
||||
}
|
||||
}
|
||||
+94
@@ -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()
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -3,6 +3,7 @@ package dev.zxq5.chatapp.android.data.repository
|
||||
import dev.zxq5.chatapp.android.api.ChatClient
|
||||
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||
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.emptyFlow
|
||||
|
||||
@@ -11,6 +12,8 @@ class ChatRepository(private val tokenStore: TokenStore) {
|
||||
private var _chatClient: ChatClient? = null
|
||||
private var _lastToken: String? = null
|
||||
|
||||
private var _lastActiveChannel: Long? = null
|
||||
|
||||
private fun getChatClient(): ChatClient? {
|
||||
val token = tokenStore.get() ?: return null
|
||||
if (_chatClient == null || token != _lastToken) {
|
||||
@@ -25,14 +28,23 @@ class ChatRepository(private val tokenStore: TokenStore) {
|
||||
_lastToken = null
|
||||
}
|
||||
|
||||
fun getLastActiveChannel(): Long? {
|
||||
return _lastActiveChannel
|
||||
}
|
||||
|
||||
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
|
||||
getChatClient()?.sendMessage(channelId, userId, text)
|
||||
}
|
||||
|
||||
fun messageStream(channelId: Int): Flow<Message> {
|
||||
fun messageStream(channelId: Long): Flow<Message> {
|
||||
_lastActiveChannel = channelId
|
||||
return getChatClient()?.messageStream(channelId) ?: emptyFlow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package dev.zxq5.chatapp.android.feature.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.LoginResult
|
||||
import dev.zxq5.chatapp.android.data.repository.SignupResult
|
||||
|
||||
@@ -3,8 +3,12 @@ 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 kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -12,15 +16,13 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
|
||||
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
|
||||
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val messages: StateFlow<List<Message>> = _messages
|
||||
|
||||
private val _channelId = MutableStateFlow<Int?>(null)
|
||||
val channelId: StateFlow<Int?> = _channelId
|
||||
private val _channelId = MutableStateFlow<Long?>(null)
|
||||
val channelId: StateFlow<Long?> = _channelId
|
||||
|
||||
private val _currentScreen = MutableStateFlow(Screen.CHAT)
|
||||
val currentScreen: StateFlow<Screen> = _currentScreen
|
||||
@@ -28,11 +30,35 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
private val _currentUserId = MutableStateFlow<Int?>(null)
|
||||
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
|
||||
|
||||
init {
|
||||
_currentUserId.value = chatRepository.getUserId()
|
||||
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() {
|
||||
@@ -40,11 +66,13 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
_channelId.collect { id ->
|
||||
streamJob?.cancel()
|
||||
_messages.value = emptyList()
|
||||
_channelError.value = null
|
||||
if (id != null) {
|
||||
streamJob = launch {
|
||||
chatRepository.messageStream(id)
|
||||
.catch { e ->
|
||||
Log.e("Chat", "Stream error", e)
|
||||
_channelError.value = "Connection lost: ${e.message}"
|
||||
}
|
||||
.collect { message ->
|
||||
_messages.update { it + message }
|
||||
@@ -59,12 +87,14 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
_currentScreen.value = screen
|
||||
}
|
||||
|
||||
fun switchChannel(id: Int?) {
|
||||
fun switchChannel(id: Long?) {
|
||||
_channelId.value = id
|
||||
|
||||
MessageStreamService.instance?.activeChannelId = id
|
||||
|
||||
if (id != null) {
|
||||
// Refresh user ID just in case it wasn't available at init
|
||||
_currentUserId.value = chatRepository.getUserId()
|
||||
chatRepository.resetClient()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +108,7 @@ class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
)
|
||||
}.onFailure { 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()
|
||||
_channelId.value = null
|
||||
_currentUserId.value = null
|
||||
_error.value = null
|
||||
_channelError.value = null
|
||||
streamJob?.cancel()
|
||||
chatRepository.resetClient()
|
||||
MessageStreamService.instance?.activeChannelId = null
|
||||
}
|
||||
|
||||
fun clearChannelError() {
|
||||
_channelError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package dev.zxq5.chatapp.android.feature.chat
|
||||
|
||||
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.automirrored.filled.ArrowBack
|
||||
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.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -56,26 +55,29 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.style.TextAlign
|
||||
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 java.text.DateFormat
|
||||
import java.util.Date
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: ChatViewModel,
|
||||
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()
|
||||
|
||||
if (selectedChannelId == null) {
|
||||
ChannelListScreen(
|
||||
viewModel = viewModel,
|
||||
onChannelSelect = { viewModel.switchChannel(it) },
|
||||
onNavigateToSettings = onNavigateToSettings
|
||||
onChannelSelect = { viewModel.switchChannel(it) }
|
||||
)
|
||||
} else {
|
||||
MessageScreen(
|
||||
@@ -90,20 +92,15 @@ fun ChatScreen(
|
||||
@Composable
|
||||
fun ChannelListScreen(
|
||||
viewModel: ChatViewModel,
|
||||
onChannelSelect: (Int) -> Unit,
|
||||
onNavigateToSettings: () -> Unit
|
||||
onChannelSelect: (Long) -> Unit
|
||||
) {
|
||||
val spaces by viewModel.spaces.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
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(
|
||||
title = {
|
||||
Text(
|
||||
@@ -115,103 +112,69 @@ fun ChannelListScreen(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
),
|
||||
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||
|
||||
)
|
||||
Text(
|
||||
"5 channels · end-to-end encrypted",
|
||||
"Public channels - dms coming soon.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f))
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
"global · walkie talkie",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = { BottomDock(viewModel, onNavigateToSettings) }
|
||||
) { padding ->
|
||||
if (error != null) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
Button(onClick = { viewModel.loadAccessibleChannels() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
items(10) { i ->
|
||||
val id = i + 1
|
||||
ChannelItem(id = id, onClick = { onChannelSelect(id) })
|
||||
spaces.forEach { spaceDto ->
|
||||
item {
|
||||
Text(
|
||||
text = spaceDto.name.lowercase(),
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
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
|
||||
fun BottomDock(viewModel: ChatViewModel, onNavigateToSettings: () -> 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) {
|
||||
fun ChannelItem(channel: Channel, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -227,7 +190,7 @@ fun ChannelItem(id: Int, onClick: () -> Unit) {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"C$id",
|
||||
channel.name.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -235,31 +198,30 @@ fun ChannelItem(id: Int, onClick: () -> Unit) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "channel $id",
|
||||
text = channel.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (channel.description != null) {
|
||||
Text(
|
||||
text = "tap to join",
|
||||
text = channel.description,
|
||||
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)
|
||||
@Composable
|
||||
fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit) {
|
||||
fun MessageScreen(channelId: Long, viewModel: ChatViewModel, onBack: () -> Unit) {
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val currentUserId by viewModel.currentUserId.collectAsState()
|
||||
val channelError by viewModel.channelError.collectAsState()
|
||||
var input by remember { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
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(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
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(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
@@ -391,10 +362,13 @@ fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun MessageBubble(message: Message, currentUserId: Int?) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
fun SettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit
|
||||
) {
|
||||
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
|
||||
@@ -88,15 +87,7 @@ fun SettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||
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>
|
||||
Generated
+12
@@ -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>
|
||||
Generated
+17
@@ -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>
|
||||
Generated
+7
@@ -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>
|
||||
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Generated
+7
@@ -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>
|
||||
Generated
+6
@@ -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>
|
||||
Reference in New Issue
Block a user