frontend v0.4.0
This commit is contained in:
Generated
+4
-1
@@ -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>
|
||||||
|
|||||||
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">
|
<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>
|
||||||
|
|||||||
@@ -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
@@ -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" />
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+27
-1
@@ -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.**
|
||||||
|
|||||||
@@ -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"
|
||||||
+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.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>
|
||||||
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