Init
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
3
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/compiler.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
18
.idea/deploymentTargetSelector.xml
generated
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2026-03-06T22:11:28.794766722Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="LocalEmulator" identifier="path=/home/cardinalnsk/.config/.android/avd/Pixel_6.avd" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
13
.idea/deviceManager.xml
generated
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
19
.idea/gradle.xml
generated
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/migrations.xml
generated
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectMigrations">
|
||||||
|
<option name="MigrateToGradleLocalJavaHome">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
.idea/misc.xml
generated
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/runConfigurations.xml
generated
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
1
app/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
106
app/build.gradle.kts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.hilt)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.google.services)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "org.yobble.messenger"
|
||||||
|
compileSdk {
|
||||||
|
version = release(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "org.yobble.messenger"
|
||||||
|
minSdk = 28
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
buildConfigField("String", "BASE_URL", "\"https://dev.api.yobble.org/\"")
|
||||||
|
buildConfigField("String", "USER_AGENT", "\"yobble android/${versionName}\"")
|
||||||
|
buildConfigField("String", "SOCKET_PATH", "\"/socket.io/\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Core
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
// Compose
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
|
// Hilt
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.android.compiler)
|
||||||
|
implementation(libs.hilt.navigation.compose)
|
||||||
|
|
||||||
|
// Network
|
||||||
|
implementation(libs.retrofit)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.okhttp.logging.interceptor)
|
||||||
|
implementation(libs.retrofit.kotlinx.serialization)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
// Firebase
|
||||||
|
implementation(platform(libs.firebase.bom))
|
||||||
|
implementation(libs.firebase.messaging)
|
||||||
|
|
||||||
|
// Socket.IO
|
||||||
|
implementation(libs.socket.io.client)
|
||||||
|
|
||||||
|
// Image loading
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
// Security
|
||||||
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|
||||||
|
// Test
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
29
app/google-services.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "1058456897662",
|
||||||
|
"project_id": "yobble",
|
||||||
|
"storage_bucket": "yobble.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:1058456897662:android:0c9ec958c52ba6df09f02f",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "org.yobble.messenger"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBhP8fd4Vbj-2lheoQpwf4KubGHerwR6eU"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
21
app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package org.yobble.messenger
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("ru.cardinalnsk.yobble", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".YobbleApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Yobble">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.Yobble">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".data.remote.fcm.YobbleFcmService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
65
app/src/main/java/org/yobble/messenger/MainActivity.kt
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package org.yobble.messenger
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import org.yobble.messenger.presentation.navigation.AppNavGraph
|
||||||
|
import org.yobble.messenger.presentation.navigation.Routes
|
||||||
|
import org.yobble.messenger.ui.theme.YobbleTheme
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var sessionManager: SessionManager
|
||||||
|
|
||||||
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { _ -> }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
requestNotificationPermission()
|
||||||
|
|
||||||
|
val startDestination = if (sessionManager.isLoggedIn) Routes.HOME else Routes.LOGIN
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
YobbleTheme {
|
||||||
|
YobbleAppContent(startDestination = startDestination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun YobbleAppContent(startDestination: String) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
AppNavGraph(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination
|
||||||
|
)
|
||||||
|
}
|
||||||
56
app/src/main/java/org/yobble/messenger/YobbleApp.kt
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package org.yobble.messenger
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class YobbleApp : Application(), ImageLoaderFactory {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
return ImageLoader.Builder(this)
|
||||||
|
.okHttpClient(okHttpClient)
|
||||||
|
.crossfade(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannels() {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
|
val messagesChannel = NotificationChannel(
|
||||||
|
CHANNEL_MESSAGES,
|
||||||
|
"Messages",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "New message notifications"
|
||||||
|
}
|
||||||
|
|
||||||
|
val generalChannel = NotificationChannel(
|
||||||
|
CHANNEL_GENERAL,
|
||||||
|
"General",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "General notifications"
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.createNotificationChannels(listOf(messagesChannel, generalChannel))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_MESSAGES = "messages"
|
||||||
|
const val CHANNEL_GENERAL = "general"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package org.yobble.messenger.data.local
|
||||||
|
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.api.AuthApi
|
||||||
|
import org.yobble.messenger.data.remote.safeApiCall
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PushTokenManager @Inject constructor(
|
||||||
|
private val authApi: AuthApi,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val json: Json
|
||||||
|
) {
|
||||||
|
suspend fun registerPushToken() {
|
||||||
|
if (!sessionManager.isLoggedIn) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val token = FirebaseMessaging.getInstance().token.await()
|
||||||
|
safeApiCall(json) { authApi.updatePushToken(token) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Firebase not available or network error — silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
package org.yobble.messenger.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SessionManager @Inject constructor(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
) {
|
||||||
|
private val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"yobble_session_prefs",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
|
||||||
|
private val localPrefs: SharedPreferences = context.getSharedPreferences(
|
||||||
|
"yobble_local_prefs", Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
|
||||||
|
var accessToken: String?
|
||||||
|
get() = prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||||
|
set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) }
|
||||||
|
|
||||||
|
var refreshToken: String?
|
||||||
|
get() = prefs.getString(KEY_REFRESH_TOKEN, null)
|
||||||
|
set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) }
|
||||||
|
|
||||||
|
var userId: String?
|
||||||
|
get() = prefs.getString(KEY_USER_ID, null)
|
||||||
|
set(value) = prefs.edit { putString(KEY_USER_ID, value) }
|
||||||
|
|
||||||
|
val isLoggedIn: Boolean
|
||||||
|
get() = accessToken != null
|
||||||
|
|
||||||
|
fun saveSession(accessToken: String, refreshToken: String, userId: String) {
|
||||||
|
prefs.edit {
|
||||||
|
putString(KEY_ACCESS_TOKEN, accessToken)
|
||||||
|
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||||
|
.putString(KEY_USER_ID, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSession() {
|
||||||
|
prefs.edit { clear() }
|
||||||
|
localPrefs.edit { clear() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLastReadMessageId(chatId: String, messageId: String) {
|
||||||
|
localPrefs.edit { putString("last_read_$chatId", messageId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLastReadMessageId(chatId: String): String? {
|
||||||
|
return localPrefs.getString("last_read_$chatId", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveDraft(chatId: String, text: String) {
|
||||||
|
if (text.isBlank()) {
|
||||||
|
localPrefs.edit { remove("draft_$chatId") }
|
||||||
|
} else {
|
||||||
|
localPrefs.edit { putString("draft_$chatId", text) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDraft(chatId: String): String {
|
||||||
|
return localPrefs.getString("draft_$chatId", null) ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||||
|
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||||
|
private const val KEY_USER_ID = "user_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package org.yobble.messenger.data.remote
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yobble.messenger.data.remote.dto.ErrorResponseDto
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
sealed class NetworkResult<out T> {
|
||||||
|
data class Success<T>(val data: T) : NetworkResult<T>()
|
||||||
|
data class Error(
|
||||||
|
val code: Int,
|
||||||
|
val errors: List<FieldError> = emptyList(),
|
||||||
|
val message: String? = null
|
||||||
|
) : NetworkResult<Nothing>()
|
||||||
|
data class Exception(val throwable: Throwable) : NetworkResult<Nothing>()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FieldError(val field: String, val message: String)
|
||||||
|
|
||||||
|
suspend fun <T> safeApiCall(json: Json, apiCall: suspend () -> Response<T>): NetworkResult<T> {
|
||||||
|
return try {
|
||||||
|
val response = apiCall()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()
|
||||||
|
if (body != null) {
|
||||||
|
NetworkResult.Success(body)
|
||||||
|
} else {
|
||||||
|
NetworkResult.Error(response.code(), message = "Empty response body")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorBody = response.errorBody()?.string()
|
||||||
|
val errors = if (errorBody != null) {
|
||||||
|
try {
|
||||||
|
val errorResponse = json.decodeFromString<ErrorResponseDto>(errorBody)
|
||||||
|
errorResponse.errors.map { FieldError(it.field, it.message) }
|
||||||
|
} catch (e: kotlin.Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
NetworkResult.Error(response.code(), errors, response.message())
|
||||||
|
}
|
||||||
|
} catch (e: kotlin.Exception) {
|
||||||
|
NetworkResult.Exception(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package org.yobble.messenger.data.remote.api
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.dto.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface AuthApi {
|
||||||
|
|
||||||
|
@POST("v1/auth/login/password")
|
||||||
|
suspend fun login(
|
||||||
|
@Body request: LoginRequestDto,
|
||||||
|
@Header("X-Client-Type") clientType: String = "android"
|
||||||
|
): Response<LoginResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/auth/login/code")
|
||||||
|
suspend fun requestLoginCode(
|
||||||
|
@Body request: LoginCodeRequestDto,
|
||||||
|
@Header("X-Client-Type") clientType: String = "android"
|
||||||
|
): Response<BaseResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/auth/login/verify_code")
|
||||||
|
suspend fun verifyCode(
|
||||||
|
@Body request: VerifyCodeRequestDto,
|
||||||
|
@Header("X-Client-Type") clientType: String = "android"
|
||||||
|
): Response<LoginResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/auth/register")
|
||||||
|
suspend fun register(
|
||||||
|
@Body request: RegisterRequestDto
|
||||||
|
): Response<RegisterResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/auth/token/refresh")
|
||||||
|
suspend fun refreshToken(
|
||||||
|
@Body request: TokenRefreshRequestDto
|
||||||
|
): Response<TokenRefreshResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/auth/password/change")
|
||||||
|
suspend fun changePassword(
|
||||||
|
@Body request: ChangePasswordRequestDto
|
||||||
|
): Response<BaseResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/auth/sessions/update_push_token")
|
||||||
|
suspend fun updatePushToken(
|
||||||
|
@Query("fcm_token") fcmToken: String
|
||||||
|
): Response<BaseResponseDto>
|
||||||
|
|
||||||
|
@GET("v1/auth/sessions/list")
|
||||||
|
suspend fun getSessionsList(): Response<SessionsListResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/auth/sessions/revoke/{session_id}")
|
||||||
|
suspend fun revokeSession(
|
||||||
|
@Path("session_id") sessionId: String
|
||||||
|
): Response<BaseResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/auth/sessions/revoke_all_except_current")
|
||||||
|
suspend fun revokeAllExceptCurrent(): Response<BaseResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package org.yobble.messenger.data.remote.api
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.dto.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface ChatPrivateApi {
|
||||||
|
|
||||||
|
@GET("v1/chat/private/list")
|
||||||
|
suspend fun getChatList(
|
||||||
|
@Query("offset") offset: Int = 0,
|
||||||
|
@Query("limit") limit: Int = 20
|
||||||
|
): Response<PrivateChatListResponseDto>
|
||||||
|
|
||||||
|
@GET("v1/chat/private/history")
|
||||||
|
suspend fun getChatHistory(
|
||||||
|
@Query("chat_id") chatId: String,
|
||||||
|
@Query("before_message_id") beforeMessageId: Int? = null,
|
||||||
|
@Query("limit") limit: Int = 30
|
||||||
|
): Response<PrivateChatHistoryResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/chat/private/create")
|
||||||
|
suspend fun createChat(
|
||||||
|
@Query("target_user_id") targetUserId: String
|
||||||
|
): Response<PrivateChatCreateResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/chat/private/send")
|
||||||
|
suspend fun sendMessage(
|
||||||
|
@Body request: PrivateMessageSendRequestDto
|
||||||
|
): Response<PrivateMessageSendResponseDto>
|
||||||
|
|
||||||
|
@HTTP(method = "DELETE", path = "v1/chat/private/delete", hasBody = true)
|
||||||
|
suspend fun deleteChat(
|
||||||
|
@Body request: PrivateChatDeleteRequestDto
|
||||||
|
): Response<BaseResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package org.yobble.messenger.data.remote.api
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.dto.UserSearchResponseDto
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
interface FeedApi {
|
||||||
|
|
||||||
|
@GET("v1/feed/user/search")
|
||||||
|
suspend fun searchUsers(
|
||||||
|
@Query("query") query: String
|
||||||
|
): Response<UserSearchResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package org.yobble.messenger.data.remote.api
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.dto.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface ProfileApi {
|
||||||
|
|
||||||
|
@GET("v1/profile/me")
|
||||||
|
suspend fun getMyProfile(): Response<ProfileResponseDto>
|
||||||
|
|
||||||
|
@PUT("v1/profile/edit")
|
||||||
|
suspend fun editProfile(
|
||||||
|
@Body request: ProfileUpdateRequestDto
|
||||||
|
): Response<BaseResponseDto>
|
||||||
|
|
||||||
|
@GET("v1/profile/{user_id}")
|
||||||
|
suspend fun getUserProfile(
|
||||||
|
@Path("user_id") userId: String
|
||||||
|
): Response<ProfileByUserIdResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package org.yobble.messenger.data.remote.api
|
||||||
|
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import org.yobble.messenger.data.remote.dto.UploadAvatarResponseDto
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Multipart
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Part
|
||||||
|
|
||||||
|
interface StorageApi {
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("v1/storage/upload/avatar")
|
||||||
|
suspend fun uploadAvatar(
|
||||||
|
@Part file: MultipartBody.Part
|
||||||
|
): Response<UploadAvatarResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package org.yobble.messenger.data.remote.api
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.dto.*
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface UserApi {
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
|
||||||
|
@GET("v1/user/contact/list")
|
||||||
|
suspend fun getContacts(
|
||||||
|
@Query("offset") offset: Int = 0,
|
||||||
|
@Query("limit") limit: Int = 20
|
||||||
|
): Response<ContactListResponseDto>
|
||||||
|
|
||||||
|
@GET("v1/user/contact/count")
|
||||||
|
suspend fun getContactCount(): Response<ContactCountResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/user/contact/add")
|
||||||
|
suspend fun addContact(
|
||||||
|
@Body request: ContactCreateRequestDto
|
||||||
|
): Response<ContactCreateResponseDto>
|
||||||
|
|
||||||
|
@PATCH("v1/user/contact/update")
|
||||||
|
suspend fun updateContact(
|
||||||
|
@Body request: ContactUpdateRequestDto
|
||||||
|
): Response<BaseResponseDto>
|
||||||
|
|
||||||
|
@HTTP(method = "DELETE", path = "v1/user/contact/remove", hasBody = true)
|
||||||
|
suspend fun removeContact(
|
||||||
|
@Body request: ContactDeleteRequestDto
|
||||||
|
): Response<BaseResponseDto>
|
||||||
|
|
||||||
|
// Blacklist
|
||||||
|
|
||||||
|
@GET("v1/user/blacklist/list")
|
||||||
|
suspend fun getBlacklist(
|
||||||
|
@Query("offset") offset: Int = 0,
|
||||||
|
@Query("limit") limit: Int = 20
|
||||||
|
): Response<BlacklistListResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/user/blacklist/add")
|
||||||
|
suspend fun addToBlacklist(
|
||||||
|
@Body request: BlacklistCreateRequestDto
|
||||||
|
): Response<BlacklistCreateResponseDto>
|
||||||
|
|
||||||
|
@HTTP(method = "DELETE", path = "v1/user/blacklist/remove", hasBody = true)
|
||||||
|
suspend fun removeFromBlacklist(
|
||||||
|
@Body request: BlacklistDeleteRequestDto
|
||||||
|
): Response<BaseResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package org.yobble.messenger.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequestDto(
|
||||||
|
@SerialName("login") val login: String,
|
||||||
|
@SerialName("password") val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginCodeRequestDto(
|
||||||
|
@SerialName("login") val login: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VerifyCodeRequestDto(
|
||||||
|
@SerialName("login") val login: String,
|
||||||
|
@SerialName("otp") val otp: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterRequestDto(
|
||||||
|
@SerialName("login") val login: String,
|
||||||
|
@SerialName("password") val password: String,
|
||||||
|
@SerialName("invite") val invite: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TokenRefreshRequestDto(
|
||||||
|
@SerialName("access_token") val accessToken: String,
|
||||||
|
@SerialName("refresh_token") val refreshToken: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChangePasswordRequestDto(
|
||||||
|
@SerialName("old_password") val oldPassword: String,
|
||||||
|
@SerialName("new_password") val newPassword: String
|
||||||
|
)
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package org.yobble.messenger.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BaseResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: MessageDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageDataDto(
|
||||||
|
@SerialName("message") val message: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginResponseDto(
|
||||||
|
@SerialName("status") val status: String = "fine",
|
||||||
|
@SerialName("data") val data: LoginDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginDataDto(
|
||||||
|
@SerialName("access_token") val accessToken: String,
|
||||||
|
@SerialName("refresh_token") val refreshToken: String,
|
||||||
|
@SerialName("user_id") val userId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: RegisterDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterDataDto(
|
||||||
|
@SerialName("message") val message: String,
|
||||||
|
@SerialName("user_id") val userId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TokenRefreshResponseDto(
|
||||||
|
@SerialName("status") val status: String = "fine",
|
||||||
|
@SerialName("data") val data: TokenDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TokenDataDto(
|
||||||
|
@SerialName("access_token") val accessToken: String,
|
||||||
|
@SerialName("refresh_token") val refreshToken: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ErrorResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("errors") val errors: List<ErrorItemDto>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ErrorItemDto(
|
||||||
|
@SerialName("field") val field: String,
|
||||||
|
@SerialName("message") val message: String
|
||||||
|
)
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
package org.yobble.messenger.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
// region Requests
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateMessageSendRequestDto(
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("content") val content: String? = null,
|
||||||
|
@SerialName("message_type") val messageType: List<String> = listOf("text")
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatDeleteRequestDto(
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("delete_for_both") val deleteForBoth: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Responses
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatListResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: PrivateChatListDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatListDataDto(
|
||||||
|
@SerialName("items") val items: List<PrivateChatListItemDto>,
|
||||||
|
@SerialName("has_more") val hasMore: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatListItemDto(
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("chat_type") val chatType: String,
|
||||||
|
@SerialName("chat_data") val chatData: ProfileByUserIdDataDto? = null,
|
||||||
|
@SerialName("last_message") val lastMessage: MessageItemDto? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("unread_count") val unreadCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageItemDto(
|
||||||
|
@SerialName("message_id") val messageId: Int,
|
||||||
|
@SerialName("message_type") val messageType: List<String>,
|
||||||
|
@SerialName("forward_metadata") val forwardMetadata: MessageForwardDto? = null,
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("sender_id") val senderId: String,
|
||||||
|
@SerialName("sender_data") val senderData: ProfileByUserIdDataDto? = null,
|
||||||
|
@SerialName("content") val content: String? = null,
|
||||||
|
@SerialName("media_link") val mediaLink: JsonObject? = null,
|
||||||
|
@SerialName("is_viewed") val isViewed: Boolean,
|
||||||
|
@SerialName("viewed_at") val viewedAt: String? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("updated_at") val updatedAt: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageForwardDto(
|
||||||
|
@SerialName("forward_type") val forwardType: String? = null,
|
||||||
|
@SerialName("forward_sender_id") val forwardSenderId: String? = null,
|
||||||
|
@SerialName("forward_message_id") val forwardMessageId: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatHistoryResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: PrivateChatHistoryDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatHistoryDataDto(
|
||||||
|
@SerialName("items") val items: List<MessageItemDto>,
|
||||||
|
@SerialName("has_more") val hasMore: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatCreateResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: PrivateChatCreateDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatCreateDataDto(
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("chat_type") val chatType: String,
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("message") val message: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateMessageSendResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: PrivateMessageSendDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateMessageSendDataDto(
|
||||||
|
@SerialName("message_id") val messageId: Int,
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// endregion
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
package org.yobble.messenger.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// region Contact Requests
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactCreateRequestDto(
|
||||||
|
@SerialName("user_id") val userId: String? = null,
|
||||||
|
@SerialName("login") val login: String? = null,
|
||||||
|
@SerialName("friend_code") val friendCode: String? = null,
|
||||||
|
@SerialName("custom_name") val customName: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactUpdateRequestDto(
|
||||||
|
@SerialName("user_id") val userId: String,
|
||||||
|
@SerialName("custom_name") val customName: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactDeleteRequestDto(
|
||||||
|
@SerialName("user_id") val userId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Contact Responses
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactListResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: ContactListDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactListDataDto(
|
||||||
|
@SerialName("items") val items: List<ContactInfoDto>,
|
||||||
|
@SerialName("has_more") val hasMore: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactInfoDto(
|
||||||
|
@SerialName("user_id") val userId: String,
|
||||||
|
@SerialName("login") val login: String? = null,
|
||||||
|
@SerialName("full_name") val fullName: String? = null,
|
||||||
|
@SerialName("custom_name") val customName: String? = null,
|
||||||
|
@SerialName("friend_code") val friendCode: Boolean = false,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("last_seen_at") val lastSeenAt: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactCreateResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: ContactInfoDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactCountResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: ContactCountDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContactCountDataDto(
|
||||||
|
@SerialName("count") val count: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Blacklist Requests
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BlacklistCreateRequestDto(
|
||||||
|
@SerialName("user_id") val userId: String? = null,
|
||||||
|
@SerialName("login") val login: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BlacklistDeleteRequestDto(
|
||||||
|
@SerialName("user_id") val userId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Blacklist Responses
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BlacklistListResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: BlacklistListDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BlacklistListDataDto(
|
||||||
|
@SerialName("items") val items: List<BlacklistInfoDto>,
|
||||||
|
@SerialName("has_more") val hasMore: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BlacklistInfoDto(
|
||||||
|
@SerialName("user_id") val userId: String,
|
||||||
|
@SerialName("login") val login: String? = null,
|
||||||
|
@SerialName("full_name") val fullName: String? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BlacklistCreateResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: BlacklistInfoDto
|
||||||
|
)
|
||||||
|
|
||||||
|
// endregion
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package org.yobble.messenger.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserSearchResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: UserSearchDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserSearchDataDto(
|
||||||
|
@SerialName("users") val users: List<UserSearchResultDto>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserSearchResultDto(
|
||||||
|
@SerialName("user_id") val userId: String,
|
||||||
|
@SerialName("profile") val profile: ProfileByUserIdDataDto? = null
|
||||||
|
)
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
package org.yobble.messenger.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// region Requests
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProfileUpdateRequestDto(
|
||||||
|
@SerialName("full_name") val fullName: String? = null,
|
||||||
|
@SerialName("bio") val bio: String? = null,
|
||||||
|
@SerialName("profile_permissions") val profilePermissions: ProfilePermissionsRequestDto? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProfilePermissionsRequestDto(
|
||||||
|
@SerialName("is_searchable") val isSearchable: Boolean? = null,
|
||||||
|
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean? = null,
|
||||||
|
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean? = null,
|
||||||
|
@SerialName("show_profile_photo_to_non_contacts") val showProfilePhotoToNonContacts: Boolean? = null,
|
||||||
|
@SerialName("last_seen_visibility") val lastSeenVisibility: Int? = null,
|
||||||
|
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean? = null,
|
||||||
|
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean? = null,
|
||||||
|
@SerialName("allow_server_chats") val allowServerChats: Boolean? = null,
|
||||||
|
@SerialName("public_invite_permission") val publicInvitePermission: Int? = null,
|
||||||
|
@SerialName("group_invite_permission") val groupInvitePermission: Int? = null,
|
||||||
|
@SerialName("call_permission") val callPermission: Int? = null,
|
||||||
|
@SerialName("force_auto_delete_messages_in_private") val forceAutoDeleteMessagesInPrivate: Boolean? = null,
|
||||||
|
@SerialName("max_message_auto_delete_seconds") val maxMessageAutoDeleteSeconds: Int? = null,
|
||||||
|
@SerialName("auto_delete_after_days") val autoDeleteAfterDays: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Responses
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProfileResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: ProfileDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProfileDataDto(
|
||||||
|
@SerialName("user_id") val userId: String,
|
||||||
|
@SerialName("login") val login: String,
|
||||||
|
@SerialName("full_name") val fullName: String? = null,
|
||||||
|
@SerialName("bio") val bio: String? = null,
|
||||||
|
@SerialName("is_verified") val isVerified: Boolean? = false,
|
||||||
|
@SerialName("rating") val rating: RatingDataDto,
|
||||||
|
@SerialName("balances") val balances: List<WalletBalanceDto>,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("avatars") val avatars: AvatarsBlockDto? = null,
|
||||||
|
@SerialName("profile_permissions") val profilePermissions: MyProfilePermissionsDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProfileByUserIdResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: ProfileByUserIdDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProfileByUserIdDataDto(
|
||||||
|
@SerialName("user_id") val userId: String,
|
||||||
|
@SerialName("login") val login: String? = null,
|
||||||
|
@SerialName("full_name") val fullName: String? = null,
|
||||||
|
@SerialName("custom_name") val customName: String? = null,
|
||||||
|
@SerialName("bio") val bio: String? = null,
|
||||||
|
@SerialName("is_verified") val isVerified: Boolean? = false,
|
||||||
|
@SerialName("is_system") val isSystem: Boolean? = false,
|
||||||
|
@SerialName("rating") val rating: RatingDataDto,
|
||||||
|
@SerialName("last_seen") val lastSeen: Int? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("avatars") val avatars: AvatarsBlockDto? = null,
|
||||||
|
@SerialName("permissions") val permissions: PermissionsResponseDto,
|
||||||
|
@SerialName("profile_permissions") val profilePermissions: UserProfilePermissionsDto,
|
||||||
|
@SerialName("relationship") val relationship: RelationshipStatusDto
|
||||||
|
)
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Shared models
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RatingDataDto(
|
||||||
|
@SerialName("rating") val rating: Double? = null,
|
||||||
|
@SerialName("status") val status: String = "unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WalletBalanceDto(
|
||||||
|
@SerialName("currency") val currency: String,
|
||||||
|
@SerialName("balance") val balance: String,
|
||||||
|
@SerialName("display_balance") val displayBalance: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AvatarsBlockDto(
|
||||||
|
@SerialName("current") val current: AvatarItemDto? = null,
|
||||||
|
@SerialName("history") val history: List<AvatarItemDto> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AvatarItemDto(
|
||||||
|
@SerialName("file_id") val fileId: String? = null,
|
||||||
|
@SerialName("mime") val mime: String? = null,
|
||||||
|
@SerialName("size") val size: Int? = null,
|
||||||
|
@SerialName("width") val width: Int? = null,
|
||||||
|
@SerialName("height") val height: Int? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PermissionsResponseDto(
|
||||||
|
@SerialName("you_can_send_message") val youCanSendMessage: Boolean,
|
||||||
|
@SerialName("you_can_public_invite_permission") val youCanPublicInvite: Boolean,
|
||||||
|
@SerialName("you_can_group_invite_permission") val youCanGroupInvite: Boolean,
|
||||||
|
@SerialName("you_can_call_permission") val youCanCall: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RelationshipStatusDto(
|
||||||
|
@SerialName("is_target_in_contacts_of_current_user") val isTargetInContacts: Boolean,
|
||||||
|
@SerialName("is_current_user_in_contacts_of_target") val isCurrentInTargetContacts: Boolean,
|
||||||
|
@SerialName("is_target_user_blocked_by_current_user") val isTargetBlocked: Boolean,
|
||||||
|
@SerialName("is_current_user_in_blacklist_of_target") val isCurrentInBlacklist: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MyProfilePermissionsDto(
|
||||||
|
@SerialName("is_searchable") val isSearchable: Boolean,
|
||||||
|
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean,
|
||||||
|
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean,
|
||||||
|
@SerialName("show_profile_photo_to_non_contacts") val showProfilePhotoToNonContacts: Boolean,
|
||||||
|
@SerialName("last_seen_visibility") val lastSeenVisibility: Int,
|
||||||
|
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean,
|
||||||
|
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean,
|
||||||
|
@SerialName("allow_server_chats") val allowServerChats: Boolean,
|
||||||
|
@SerialName("public_invite_permission") val publicInvitePermission: Int,
|
||||||
|
@SerialName("group_invite_permission") val groupInvitePermission: Int,
|
||||||
|
@SerialName("call_permission") val callPermission: Int,
|
||||||
|
@SerialName("force_auto_delete_messages_in_private") val forceAutoDeleteMessagesInPrivate: Boolean,
|
||||||
|
@SerialName("max_message_auto_delete_seconds") val maxMessageAutoDeleteSeconds: Int? = null,
|
||||||
|
@SerialName("auto_delete_after_days") val autoDeleteAfterDays: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserProfilePermissionsDto(
|
||||||
|
@SerialName("is_searchable") val isSearchable: Boolean? = null,
|
||||||
|
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean,
|
||||||
|
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean,
|
||||||
|
@SerialName("allow_server_chats") val allowServerChats: Boolean,
|
||||||
|
@SerialName("force_auto_delete_messages_in_private") val forceAutoDeleteMessagesInPrivate: Boolean,
|
||||||
|
@SerialName("max_message_auto_delete_seconds") val maxMessageAutoDeleteSeconds: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// endregion
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package org.yobble.messenger.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionsListResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: SessionsListDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionsListDataDto(
|
||||||
|
@SerialName("sessions") val sessions: List<UserSessionItemDto>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserSessionItemDto(
|
||||||
|
@SerialName("id") val id: String,
|
||||||
|
@SerialName("ip_address") val ipAddress: String? = null,
|
||||||
|
@SerialName("user_agent") val userAgent: String? = null,
|
||||||
|
@SerialName("client_type") val clientType: String,
|
||||||
|
@SerialName("is_active") val isActive: Boolean,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("last_refresh_at") val lastRefreshAt: String,
|
||||||
|
@SerialName("is_current") val isCurrent: Boolean
|
||||||
|
)
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package org.yobble.messenger.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UploadAvatarResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: UploadAvatarDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UploadAvatarDataDto(
|
||||||
|
@SerialName("file_id") val fileId: String
|
||||||
|
)
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package org.yobble.messenger.data.remote.fcm
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.MainActivity
|
||||||
|
import org.yobble.messenger.R
|
||||||
|
import org.yobble.messenger.YobbleApp
|
||||||
|
import org.yobble.messenger.data.local.PushTokenManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class YobbleFcmService : FirebaseMessagingService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var pushTokenManager: PushTokenManager
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
scope.launch {
|
||||||
|
pushTokenManager.registerPushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
super.onMessageReceived(message)
|
||||||
|
|
||||||
|
val title = message.notification?.title
|
||||||
|
?: message.data["title"]
|
||||||
|
?: "Yobble"
|
||||||
|
val body = message.notification?.body
|
||||||
|
?: message.data["body"]
|
||||||
|
?: return
|
||||||
|
|
||||||
|
showNotification(title, body, message.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotification(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
data: Map<String, String>
|
||||||
|
) {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
data.forEach { (key, value) -> putExtra(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
System.currentTimeMillis().toInt(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val channelId = data["channel"] ?: YobbleApp.CHANNEL_MESSAGES
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(this)
|
||||||
|
.notify(System.currentTimeMillis().toInt(), notification)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
// POST_NOTIFICATIONS permission not granted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package org.yobble.messenger.data.remote.interceptor
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.yobble.messenger.BuildConfig
|
||||||
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class AuthInterceptor @Inject constructor(
|
||||||
|
private val sessionManager: SessionManager
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val token = sessionManager.accessToken
|
||||||
|
|
||||||
|
val newRequest = request.newBuilder()
|
||||||
|
.header("User-Agent", BuildConfig.USER_AGENT)
|
||||||
|
.apply {
|
||||||
|
if (token != null) {
|
||||||
|
header("Authorization", "Bearer $token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(newRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package org.yobble.messenger.data.remote.interceptor
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.Authenticator
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.Route
|
||||||
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import org.yobble.messenger.data.remote.api.AuthApi
|
||||||
|
import org.yobble.messenger.data.remote.dto.TokenRefreshRequestDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Provider
|
||||||
|
|
||||||
|
class TokenAuthenticator @Inject constructor(
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val authApiProvider: Provider<AuthApi>
|
||||||
|
) : Authenticator {
|
||||||
|
|
||||||
|
override fun authenticate(route: Route?, response: Response): Request? {
|
||||||
|
val accessToken = sessionManager.accessToken ?: return null
|
||||||
|
val refreshToken = sessionManager.refreshToken ?: return null
|
||||||
|
|
||||||
|
synchronized(this) {
|
||||||
|
val currentToken = sessionManager.accessToken
|
||||||
|
if (currentToken != null && currentToken != accessToken) {
|
||||||
|
return response.request.newBuilder()
|
||||||
|
.header("Authorization", "Bearer $currentToken")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val newTokens = runBlocking {
|
||||||
|
try {
|
||||||
|
val refreshResponse = authApiProvider.get().refreshToken(
|
||||||
|
TokenRefreshRequestDto(
|
||||||
|
accessToken = accessToken,
|
||||||
|
refreshToken = refreshToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (refreshResponse.isSuccessful) {
|
||||||
|
refreshResponse.body()?.data
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (newTokens != null) {
|
||||||
|
sessionManager.saveSession(
|
||||||
|
accessToken = newTokens.accessToken,
|
||||||
|
refreshToken = newTokens.refreshToken,
|
||||||
|
userId = sessionManager.userId ?: ""
|
||||||
|
)
|
||||||
|
response.request.newBuilder()
|
||||||
|
.header("Authorization", "Bearer ${newTokens.accessToken}")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
sessionManager.clearSession()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
package org.yobble.messenger.data.remote.socket
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.socket.client.IO
|
||||||
|
import io.socket.client.Socket
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.yobble.messenger.BuildConfig
|
||||||
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
sealed class SocketEvent {
|
||||||
|
data class NewMessage(val data: JSONObject) : SocketEvent()
|
||||||
|
data class Connected(val data: JSONObject?) : SocketEvent()
|
||||||
|
data object Disconnected : SocketEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SocketManager @Inject constructor(
|
||||||
|
private val sessionManager: SessionManager
|
||||||
|
) {
|
||||||
|
private var socket: Socket? = null
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<SocketEvent>(extraBufferCapacity = 64)
|
||||||
|
val events: SharedFlow<SocketEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
val isConnected: Boolean
|
||||||
|
get() = socket?.connected() == true
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
if (socket?.connected() == true) {
|
||||||
|
Log.d(TAG, "Already connected, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val token = sessionManager.accessToken
|
||||||
|
if (token == null) {
|
||||||
|
Log.w(TAG, "No access token, cannot connect socket")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect old socket if exists
|
||||||
|
socket?.let {
|
||||||
|
Log.d(TAG, "Cleaning up old socket")
|
||||||
|
it.disconnect()
|
||||||
|
it.off()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val baseUrl = BuildConfig.BASE_URL.trimEnd('/')
|
||||||
|
Log.i(TAG, "Connecting to: $baseUrl")
|
||||||
|
|
||||||
|
// OkHttpClient for socket.io (no auth interceptor — we pass token manually)
|
||||||
|
val okHttpClient = OkHttpClient.Builder().build()
|
||||||
|
IO.setDefaultOkHttpCallFactory(okHttpClient)
|
||||||
|
IO.setDefaultOkHttpWebSocketFactory(okHttpClient)
|
||||||
|
|
||||||
|
val options = IO.Options().apply {
|
||||||
|
// Auth map (socket.io v4 handshake)
|
||||||
|
auth = mapOf("token" to token)
|
||||||
|
// Also pass token as query param (like iOS connectParams)
|
||||||
|
query = "token=$token"
|
||||||
|
// Extra headers (like iOS)
|
||||||
|
extraHeaders = mapOf(
|
||||||
|
"Authorization" to listOf("Bearer $token"),
|
||||||
|
"User-Agent" to listOf(BuildConfig.USER_AGENT)
|
||||||
|
)
|
||||||
|
path = "/socket.io/"
|
||||||
|
transports = arrayOf("websocket", "polling")
|
||||||
|
reconnection = true
|
||||||
|
reconnectionAttempts = Int.MAX_VALUE
|
||||||
|
reconnectionDelay = 2000
|
||||||
|
reconnectionDelayMax = 5000
|
||||||
|
timeout = 20000
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = IO.socket(baseUrl, options).apply {
|
||||||
|
// Debug: log ALL incoming events
|
||||||
|
onAnyIncoming { args ->
|
||||||
|
Log.d(TAG, "<<< event: ${args.joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
on(Socket.EVENT_CONNECT) {
|
||||||
|
Log.i(TAG, "===== CONNECTED ===== SID: ${id()}")
|
||||||
|
val data = if (it.isNotEmpty()) it[0] as? JSONObject else null
|
||||||
|
_events.tryEmit(SocketEvent.Connected(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
on(Socket.EVENT_DISCONNECT) { args ->
|
||||||
|
val reason = if (args.isNotEmpty()) args[0] else "unknown"
|
||||||
|
Log.w(TAG, "===== DISCONNECTED ===== reason: $reason")
|
||||||
|
_events.tryEmit(SocketEvent.Disconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
on(Socket.EVENT_CONNECT_ERROR) { args ->
|
||||||
|
val error = if (args.isNotEmpty()) args[0] else "Unknown"
|
||||||
|
Log.e(TAG, "===== CONNECT ERROR ===== $error")
|
||||||
|
if (error is Exception) {
|
||||||
|
Log.e(TAG, "Error details:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server confirms connection
|
||||||
|
on("connected") { args ->
|
||||||
|
Log.i(TAG, "Server 'connected' event: ${args.firstOrNull()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat: server responds with "pong" via "message" event
|
||||||
|
on("message") { args ->
|
||||||
|
Log.d(TAG, "message event: ${args.firstOrNull()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private chat new message (matches iOS: chat_private:new_message)
|
||||||
|
on("chat_private:new_message") { args ->
|
||||||
|
Log.i(TAG, "chat_private:new_message, args: ${args.size}")
|
||||||
|
handleNewMessage(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also listen to chat:new_message (from web example)
|
||||||
|
on("chat:new_message") { args ->
|
||||||
|
Log.i(TAG, "chat:new_message, args: ${args.size}")
|
||||||
|
handleNewMessage(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
on("achievement:received") { args ->
|
||||||
|
Log.i(TAG, "achievement:received: ${args.firstOrNull()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
on("achievement:progress") { args ->
|
||||||
|
Log.i(TAG, "achievement:progress: ${args.firstOrNull()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Calling socket.connect()...")
|
||||||
|
connect()
|
||||||
|
Log.d(TAG, "socket.connect() called, connected=${connected()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Socket connection failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNewMessage(args: Array<Any>) {
|
||||||
|
if (args.isEmpty()) return
|
||||||
|
val raw = args[0]
|
||||||
|
val data = when (raw) {
|
||||||
|
is JSONObject -> raw
|
||||||
|
is String -> try { JSONObject(raw) } catch (_: Exception) { null }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (data != null) {
|
||||||
|
Log.i(TAG, "New message data: $data")
|
||||||
|
_events.tryEmit(SocketEvent.NewMessage(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
Log.d(TAG, "Disconnecting socket")
|
||||||
|
socket?.disconnect()
|
||||||
|
socket?.off()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SocketManager"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
package org.yobble.messenger.data.repository
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yobble.messenger.data.local.PushTokenManager
|
||||||
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.api.AuthApi
|
||||||
|
import org.yobble.messenger.data.remote.dto.*
|
||||||
|
import org.yobble.messenger.data.remote.safeApiCall
|
||||||
|
import org.yobble.messenger.domain.repository.AuthRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
|
private val authApi: AuthApi,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val pushTokenManager: PushTokenManager,
|
||||||
|
private val json: Json
|
||||||
|
) : AuthRepository {
|
||||||
|
|
||||||
|
override suspend fun login(login: String, password: String): NetworkResult<LoginResponseDto> {
|
||||||
|
val result = safeApiCall(json) {
|
||||||
|
authApi.login(LoginRequestDto(login, password))
|
||||||
|
}
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
sessionManager.saveSession(
|
||||||
|
accessToken = result.data.data.accessToken,
|
||||||
|
refreshToken = result.data.data.refreshToken,
|
||||||
|
userId = result.data.data.userId
|
||||||
|
)
|
||||||
|
pushTokenManager.registerPushToken()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun requestLoginCode(login: String): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
authApi.requestLoginCode(LoginCodeRequestDto(login))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun verifyCode(login: String, otp: String): NetworkResult<LoginResponseDto> {
|
||||||
|
val result = safeApiCall(json) {
|
||||||
|
authApi.verifyCode(VerifyCodeRequestDto(login, otp))
|
||||||
|
}
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
sessionManager.saveSession(
|
||||||
|
accessToken = result.data.data.accessToken,
|
||||||
|
refreshToken = result.data.data.refreshToken,
|
||||||
|
userId = result.data.data.userId
|
||||||
|
)
|
||||||
|
pushTokenManager.registerPushToken()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun register(login: String, password: String, invite: String?): NetworkResult<RegisterResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
authApi.register(RegisterRequestDto(login, password, invite))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updatePushToken(fcmToken: String): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
authApi.updatePushToken(fcmToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun changePassword(oldPassword: String, newPassword: String): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
authApi.changePassword(ChangePasswordRequestDto(oldPassword, newPassword))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSessionsList(): NetworkResult<SessionsListResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
authApi.getSessionsList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun revokeSession(sessionId: String): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
authApi.revokeSession(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun revokeAllExceptCurrent(): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
authApi.revokeAllExceptCurrent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun isLoggedIn(): Boolean {
|
||||||
|
return sessionManager.isLoggedIn
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logout() {
|
||||||
|
sessionManager.clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package org.yobble.messenger.data.repository
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.api.ChatPrivateApi
|
||||||
|
import org.yobble.messenger.data.remote.dto.*
|
||||||
|
import org.yobble.messenger.data.remote.safeApiCall
|
||||||
|
import org.yobble.messenger.domain.repository.ChatRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ChatRepositoryImpl @Inject constructor(
|
||||||
|
private val chatApi: ChatPrivateApi,
|
||||||
|
private val json: Json
|
||||||
|
) : ChatRepository {
|
||||||
|
|
||||||
|
override suspend fun getChatList(offset: Int, limit: Int): NetworkResult<PrivateChatListResponseDto> {
|
||||||
|
return safeApiCall(json) { chatApi.getChatList(offset, limit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getChatHistory(chatId: String, beforeMessageId: Int?, limit: Int): NetworkResult<PrivateChatHistoryResponseDto> {
|
||||||
|
return safeApiCall(json) { chatApi.getChatHistory(chatId, beforeMessageId, limit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createChat(targetUserId: String): NetworkResult<PrivateChatCreateResponseDto> {
|
||||||
|
return safeApiCall(json) { chatApi.createChat(targetUserId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendMessage(chatId: String, content: String): NetworkResult<PrivateMessageSendResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
chatApi.sendMessage(PrivateMessageSendRequestDto(chatId = chatId, content = content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteChat(chatId: String, deleteForBoth: Boolean): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
chatApi.deleteChat(PrivateChatDeleteRequestDto(chatId = chatId, deleteForBoth = deleteForBoth))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package org.yobble.messenger.data.repository
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.api.FeedApi
|
||||||
|
import org.yobble.messenger.data.remote.dto.UserSearchResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.safeApiCall
|
||||||
|
import org.yobble.messenger.domain.repository.FeedRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class FeedRepositoryImpl @Inject constructor(
|
||||||
|
private val feedApi: FeedApi,
|
||||||
|
private val json: Json
|
||||||
|
) : FeedRepository {
|
||||||
|
|
||||||
|
override suspend fun searchUsers(query: String): NetworkResult<UserSearchResponseDto> {
|
||||||
|
return safeApiCall(json) { feedApi.searchUsers(query) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package org.yobble.messenger.data.repository
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.api.ProfileApi
|
||||||
|
import org.yobble.messenger.data.remote.api.StorageApi
|
||||||
|
import org.yobble.messenger.data.remote.dto.BaseResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfileByUserIdResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfileResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.UploadAvatarResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.safeApiCall
|
||||||
|
import org.yobble.messenger.domain.repository.ProfileRepository
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ProfileRepositoryImpl @Inject constructor(
|
||||||
|
private val profileApi: ProfileApi,
|
||||||
|
private val storageApi: StorageApi,
|
||||||
|
private val json: Json
|
||||||
|
) : ProfileRepository {
|
||||||
|
|
||||||
|
override suspend fun getMyProfile(): NetworkResult<ProfileResponseDto> {
|
||||||
|
return safeApiCall(json) { profileApi.getMyProfile() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun editProfile(request: ProfileUpdateRequestDto): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) { profileApi.editProfile(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getUserProfile(userId: String): NetworkResult<ProfileByUserIdResponseDto> {
|
||||||
|
return safeApiCall(json) { profileApi.getUserProfile(userId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun uploadAvatar(file: File, mimeType: String): NetworkResult<UploadAvatarResponseDto> {
|
||||||
|
val mediaType = mimeType.toMediaType()
|
||||||
|
val requestBody = file.asRequestBody(mediaType)
|
||||||
|
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
||||||
|
return safeApiCall(json) { storageApi.uploadAvatar(part) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package org.yobble.messenger.data.repository
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.api.UserApi
|
||||||
|
import org.yobble.messenger.data.remote.dto.*
|
||||||
|
import org.yobble.messenger.data.remote.safeApiCall
|
||||||
|
import org.yobble.messenger.domain.repository.UserRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class UserRepositoryImpl @Inject constructor(
|
||||||
|
private val userApi: UserApi,
|
||||||
|
private val json: Json
|
||||||
|
) : UserRepository {
|
||||||
|
|
||||||
|
override suspend fun getContacts(offset: Int, limit: Int): NetworkResult<ContactListResponseDto> {
|
||||||
|
return safeApiCall(json) { userApi.getContacts(offset, limit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getContactCount(): NetworkResult<ContactCountResponseDto> {
|
||||||
|
return safeApiCall(json) { userApi.getContactCount() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addContact(request: ContactCreateRequestDto): NetworkResult<ContactCreateResponseDto> {
|
||||||
|
return safeApiCall(json) { userApi.addContact(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateContact(request: ContactUpdateRequestDto): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) { userApi.updateContact(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeContact(userId: String): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) { userApi.removeContact(ContactDeleteRequestDto(userId)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBlacklist(offset: Int, limit: Int): NetworkResult<BlacklistListResponseDto> {
|
||||||
|
return safeApiCall(json) { userApi.getBlacklist(offset, limit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addToBlacklist(request: BlacklistCreateRequestDto): NetworkResult<BlacklistCreateResponseDto> {
|
||||||
|
return safeApiCall(json) { userApi.addToBlacklist(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeFromBlacklist(userId: String): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) { userApi.removeFromBlacklist(BlacklistDeleteRequestDto(userId)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/src/main/java/org/yobble/messenger/di/NetworkModule.kt
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package org.yobble.messenger.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import org.yobble.messenger.BuildConfig
|
||||||
|
import org.yobble.messenger.data.remote.api.AuthApi
|
||||||
|
import org.yobble.messenger.data.remote.api.ChatPrivateApi
|
||||||
|
import org.yobble.messenger.data.remote.api.FeedApi
|
||||||
|
import org.yobble.messenger.data.remote.api.ProfileApi
|
||||||
|
import org.yobble.messenger.data.remote.api.StorageApi
|
||||||
|
import org.yobble.messenger.data.remote.api.UserApi
|
||||||
|
import org.yobble.messenger.data.remote.interceptor.AuthInterceptor
|
||||||
|
import org.yobble.messenger.data.remote.interceptor.TokenAuthenticator
|
||||||
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideJson(): Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
coerceInputValues = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
tokenAuthenticator: TokenAuthenticator
|
||||||
|
): OkHttpClient {
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(
|
||||||
|
HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.authenticator(tokenAuthenticator)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit {
|
||||||
|
val contentType = "application/json".toMediaType()
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(BuildConfig.BASE_URL)
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(json.asConverterFactory(contentType))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthApi(retrofit: Retrofit): AuthApi {
|
||||||
|
return retrofit.create(AuthApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideProfileApi(retrofit: Retrofit): ProfileApi {
|
||||||
|
return retrofit.create(ProfileApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideChatPrivateApi(retrofit: Retrofit): ChatPrivateApi {
|
||||||
|
return retrofit.create(ChatPrivateApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideStorageApi(retrofit: Retrofit): StorageApi {
|
||||||
|
return retrofit.create(StorageApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUserApi(retrofit: Retrofit): UserApi {
|
||||||
|
return retrofit.create(UserApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideFeedApi(retrofit: Retrofit): FeedApi {
|
||||||
|
return retrofit.create(FeedApi::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package org.yobble.messenger.di
|
||||||
|
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.yobble.messenger.data.repository.AuthRepositoryImpl
|
||||||
|
import org.yobble.messenger.data.repository.ChatRepositoryImpl
|
||||||
|
import org.yobble.messenger.data.repository.FeedRepositoryImpl
|
||||||
|
import org.yobble.messenger.data.repository.ProfileRepositoryImpl
|
||||||
|
import org.yobble.messenger.data.repository.UserRepositoryImpl
|
||||||
|
import org.yobble.messenger.domain.repository.AuthRepository
|
||||||
|
import org.yobble.messenger.domain.repository.ChatRepository
|
||||||
|
import org.yobble.messenger.domain.repository.FeedRepository
|
||||||
|
import org.yobble.messenger.domain.repository.ProfileRepository
|
||||||
|
import org.yobble.messenger.domain.repository.UserRepository
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class RepositoryModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindProfileRepository(impl: ProfileRepositoryImpl): ProfileRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindFeedRepository(impl: FeedRepositoryImpl): FeedRepository
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package org.yobble.messenger.domain.repository
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.BaseResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.LoginResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.RegisterResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.SessionsListResponseDto
|
||||||
|
|
||||||
|
interface AuthRepository {
|
||||||
|
suspend fun login(login: String, password: String): NetworkResult<LoginResponseDto>
|
||||||
|
suspend fun requestLoginCode(login: String): NetworkResult<BaseResponseDto>
|
||||||
|
suspend fun verifyCode(login: String, otp: String): NetworkResult<LoginResponseDto>
|
||||||
|
suspend fun register(login: String, password: String, invite: String? = null): NetworkResult<RegisterResponseDto>
|
||||||
|
suspend fun updatePushToken(fcmToken: String): NetworkResult<BaseResponseDto>
|
||||||
|
suspend fun changePassword(oldPassword: String, newPassword: String): NetworkResult<BaseResponseDto>
|
||||||
|
suspend fun getSessionsList(): NetworkResult<SessionsListResponseDto>
|
||||||
|
suspend fun revokeSession(sessionId: String): NetworkResult<BaseResponseDto>
|
||||||
|
suspend fun revokeAllExceptCurrent(): NetworkResult<BaseResponseDto>
|
||||||
|
suspend fun isLoggedIn(): Boolean
|
||||||
|
fun logout()
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package org.yobble.messenger.domain.repository
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.*
|
||||||
|
|
||||||
|
interface ChatRepository {
|
||||||
|
suspend fun getChatList(offset: Int = 0, limit: Int = 20): NetworkResult<PrivateChatListResponseDto>
|
||||||
|
suspend fun getChatHistory(chatId: String, beforeMessageId: Int? = null, limit: Int = 30): NetworkResult<PrivateChatHistoryResponseDto>
|
||||||
|
suspend fun createChat(targetUserId: String): NetworkResult<PrivateChatCreateResponseDto>
|
||||||
|
suspend fun sendMessage(chatId: String, content: String): NetworkResult<PrivateMessageSendResponseDto>
|
||||||
|
suspend fun deleteChat(chatId: String, deleteForBoth: Boolean = false): NetworkResult<BaseResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package org.yobble.messenger.domain.repository
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.UserSearchResponseDto
|
||||||
|
|
||||||
|
interface FeedRepository {
|
||||||
|
suspend fun searchUsers(query: String): NetworkResult<UserSearchResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package org.yobble.messenger.domain.repository
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.BaseResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfileByUserIdResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfileResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.UploadAvatarResponseDto
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
interface ProfileRepository {
|
||||||
|
suspend fun getMyProfile(): NetworkResult<ProfileResponseDto>
|
||||||
|
suspend fun editProfile(request: ProfileUpdateRequestDto): NetworkResult<BaseResponseDto>
|
||||||
|
suspend fun getUserProfile(userId: String): NetworkResult<ProfileByUserIdResponseDto>
|
||||||
|
suspend fun uploadAvatar(file: File, mimeType: String): NetworkResult<UploadAvatarResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package org.yobble.messenger.domain.repository
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.*
|
||||||
|
|
||||||
|
interface UserRepository {
|
||||||
|
// Contacts
|
||||||
|
suspend fun getContacts(offset: Int = 0, limit: Int = 20): NetworkResult<ContactListResponseDto>
|
||||||
|
suspend fun getContactCount(): NetworkResult<ContactCountResponseDto>
|
||||||
|
suspend fun addContact(request: ContactCreateRequestDto): NetworkResult<ContactCreateResponseDto>
|
||||||
|
suspend fun updateContact(request: ContactUpdateRequestDto): NetworkResult<BaseResponseDto>
|
||||||
|
suspend fun removeContact(userId: String): NetworkResult<BaseResponseDto>
|
||||||
|
|
||||||
|
// Blacklist
|
||||||
|
suspend fun getBlacklist(offset: Int = 0, limit: Int = 20): NetworkResult<BlacklistListResponseDto>
|
||||||
|
suspend fun addToBlacklist(request: BlacklistCreateRequestDto): NetworkResult<BlacklistCreateResponseDto>
|
||||||
|
suspend fun removeFromBlacklist(userId: String): NetworkResult<BaseResponseDto>
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package org.yobble.messenger.domain.validation
|
||||||
|
|
||||||
|
object AuthValidator {
|
||||||
|
|
||||||
|
fun validateLogin(login: String): ValidationResult {
|
||||||
|
return when {
|
||||||
|
login.isBlank() -> ValidationResult.Error("Login must not be empty")
|
||||||
|
login.contains(" ") -> ValidationResult.Error("Login must not contain whitespace characters")
|
||||||
|
login.length < 3 -> ValidationResult.Error("Login must be at least 3 characters")
|
||||||
|
login.length > 64 -> ValidationResult.Error("Login must not exceed 64 characters")
|
||||||
|
else -> ValidationResult.Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validatePassword(password: String): ValidationResult {
|
||||||
|
return when {
|
||||||
|
password.isBlank() -> ValidationResult.Error("Password must not be empty")
|
||||||
|
password.length < 8 -> ValidationResult.Error("Password must be at least 8 characters")
|
||||||
|
password.length > 128 -> ValidationResult.Error("Password must not exceed 128 characters")
|
||||||
|
else -> ValidationResult.Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validatePasswordConfirmation(password: String, confirmation: String): ValidationResult {
|
||||||
|
return when {
|
||||||
|
confirmation.isBlank() -> ValidationResult.Error("Please confirm your password")
|
||||||
|
password != confirmation -> ValidationResult.Error("Passwords do not match")
|
||||||
|
else -> ValidationResult.Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateOtp(otp: String): ValidationResult {
|
||||||
|
return when {
|
||||||
|
otp.isBlank() -> ValidationResult.Error("Code must not be empty")
|
||||||
|
otp.length < 4 -> ValidationResult.Error("Code must be at least 4 characters")
|
||||||
|
otp.length > 8 -> ValidationResult.Error("Code must not exceed 8 characters")
|
||||||
|
else -> ValidationResult.Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ValidationResult {
|
||||||
|
data object Valid : ValidationResult()
|
||||||
|
data class Error(val message: String) : ValidationResult()
|
||||||
|
}
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
package org.yobble.messenger.presentation.auth.code
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CodeVerificationScreen(
|
||||||
|
login: String,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onVerifySuccess: () -> Unit,
|
||||||
|
viewModel: CodeVerificationViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is CodeVerificationEvent.VerifySuccess -> onVerifySuccess()
|
||||||
|
is CodeVerificationEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Verification") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Enter the code",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "We sent a verification code to\n$login",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.code,
|
||||||
|
onValueChange = viewModel::onCodeChange,
|
||||||
|
label = { Text("Code") },
|
||||||
|
singleLine = true,
|
||||||
|
isError = uiState.codeError != null,
|
||||||
|
supportingText = uiState.codeError?.let { error -> { Text(error) } },
|
||||||
|
textStyle = LocalTextStyle.current.copy(
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
letterSpacing = 8.sp
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
viewModel.verifyCode()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::verifyCode,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
|
),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Verify",
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = viewModel::resendCode,
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Resend Code",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
package org.yobble.messenger.presentation.auth.code
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.domain.repository.AuthRepository
|
||||||
|
import org.yobble.messenger.domain.validation.AuthValidator
|
||||||
|
import org.yobble.messenger.domain.validation.ValidationResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class CodeVerificationUiState(
|
||||||
|
val login: String = "",
|
||||||
|
val code: String = "",
|
||||||
|
val codeError: String? = null,
|
||||||
|
val isLoading: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class CodeVerificationEvent {
|
||||||
|
data object VerifySuccess : CodeVerificationEvent()
|
||||||
|
data class ShowError(val message: String) : CodeVerificationEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class CodeVerificationViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(
|
||||||
|
CodeVerificationUiState(
|
||||||
|
login = savedStateHandle.get<String>("login") ?: ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val uiState: StateFlow<CodeVerificationUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<CodeVerificationEvent>()
|
||||||
|
val events: SharedFlow<CodeVerificationEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
fun onCodeChange(value: String) {
|
||||||
|
if (value.length <= 8) {
|
||||||
|
_uiState.update { it.copy(code = value, codeError = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyCode() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val otpValidation = AuthValidator.validateOtp(state.code)
|
||||||
|
|
||||||
|
if (otpValidation is ValidationResult.Error) {
|
||||||
|
_uiState.update { it.copy(codeError = otpValidation.message) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = authRepository.verifyCode(state.login, state.code)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_events.emit(CodeVerificationEvent.VerifySuccess)
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
val errorMessage = result.errors.firstOrNull()?.message
|
||||||
|
?: result.message
|
||||||
|
?: "Verification failed"
|
||||||
|
_events.emit(CodeVerificationEvent.ShowError(errorMessage))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(CodeVerificationEvent.ShowError("Connection error. Please try again."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resendCode() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (authRepository.requestLoginCode(_uiState.value.login)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_events.emit(CodeVerificationEvent.ShowError("Code sent successfully"))
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_events.emit(CodeVerificationEvent.ShowError("Failed to resend code"))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(CodeVerificationEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,195 @@
|
|||||||
|
package org.yobble.messenger.presentation.auth.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ChatBubble
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
onNavigateToRegister: () -> Unit,
|
||||||
|
onNavigateToCodeLogin: (String) -> Unit,
|
||||||
|
onLoginSuccess: () -> Unit,
|
||||||
|
viewModel: LoginViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is LoginEvent.LoginSuccess -> onLoginSuccess()
|
||||||
|
is LoginEvent.CodeRequested -> onNavigateToCodeLogin(event.login)
|
||||||
|
is LoginEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChatBubble,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Yobble",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Sign in to your account",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.login,
|
||||||
|
onValueChange = viewModel::onLoginChange,
|
||||||
|
label = { Text("Login") },
|
||||||
|
singleLine = true,
|
||||||
|
isError = uiState.loginError != null,
|
||||||
|
supportingText = uiState.loginError?.let { error -> { Text(error) } },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.password,
|
||||||
|
onValueChange = viewModel::onPasswordChange,
|
||||||
|
label = { Text("Password") },
|
||||||
|
singleLine = true,
|
||||||
|
isError = uiState.passwordError != null,
|
||||||
|
supportingText = uiState.passwordError?.let { error -> { Text(error) } },
|
||||||
|
visualTransformation = if (uiState.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
viewModel.login()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = viewModel::togglePasswordVisibility) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (uiState.isPasswordVisible) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::login,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
|
),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Log In",
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = viewModel::requestLoginCode) {
|
||||||
|
Text(
|
||||||
|
"Login with Code",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(bottom = 32.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Don't have an account?",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
TextButton(onClick = onNavigateToRegister) {
|
||||||
|
Text(
|
||||||
|
"Register",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
package org.yobble.messenger.presentation.auth.login
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.domain.repository.AuthRepository
|
||||||
|
import org.yobble.messenger.domain.validation.AuthValidator
|
||||||
|
import org.yobble.messenger.domain.validation.ValidationResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class LoginUiState(
|
||||||
|
val login: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val loginError: String? = null,
|
||||||
|
val passwordError: String? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isPasswordVisible: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class LoginEvent {
|
||||||
|
data object LoginSuccess : LoginEvent()
|
||||||
|
data class CodeRequested(val login: String) : LoginEvent()
|
||||||
|
data class ShowError(val message: String) : LoginEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class LoginViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(LoginUiState())
|
||||||
|
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<LoginEvent>()
|
||||||
|
val events: SharedFlow<LoginEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
fun onLoginChange(value: String) {
|
||||||
|
_uiState.update { it.copy(login = value, loginError = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPasswordChange(value: String) {
|
||||||
|
_uiState.update { it.copy(password = value, passwordError = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePasswordVisibility() {
|
||||||
|
_uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val loginValidation = AuthValidator.validateLogin(state.login)
|
||||||
|
val passwordValidation = AuthValidator.validatePassword(state.password)
|
||||||
|
|
||||||
|
if (loginValidation is ValidationResult.Error || passwordValidation is ValidationResult.Error) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
loginError = (loginValidation as? ValidationResult.Error)?.message,
|
||||||
|
passwordError = (passwordValidation as? ValidationResult.Error)?.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = authRepository.login(state.login, state.password)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_events.emit(LoginEvent.LoginSuccess)
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
val errorMessage = result.errors.firstOrNull()?.message
|
||||||
|
?: result.message
|
||||||
|
?: "Login failed"
|
||||||
|
_events.emit(LoginEvent.ShowError(errorMessage))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(LoginEvent.ShowError("Connection error. Please try again."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestLoginCode() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val loginValidation = AuthValidator.validateLogin(state.login)
|
||||||
|
|
||||||
|
if (loginValidation is ValidationResult.Error) {
|
||||||
|
_uiState.update { it.copy(loginError = loginValidation.message) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = authRepository.requestLoginCode(state.login)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_events.emit(LoginEvent.CodeRequested(state.login))
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
val errorMessage = result.errors.firstOrNull()?.message
|
||||||
|
?: result.message
|
||||||
|
?: "Failed to send code"
|
||||||
|
_events.emit(LoginEvent.ShowError(errorMessage))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(LoginEvent.ShowError("Connection error. Please try again."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,235 @@
|
|||||||
|
package org.yobble.messenger.presentation.auth.register
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onRegisterSuccess: () -> Unit,
|
||||||
|
viewModel: RegisterViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is RegisterEvent.RegisterSuccess -> {
|
||||||
|
snackbarHostState.showSnackbar("Registration successful! Please log in.")
|
||||||
|
onRegisterSuccess()
|
||||||
|
}
|
||||||
|
is RegisterEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Register") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Create your account",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Enter your details to get started",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.login,
|
||||||
|
onValueChange = viewModel::onLoginChange,
|
||||||
|
label = { Text("Login") },
|
||||||
|
singleLine = true,
|
||||||
|
isError = uiState.loginError != null,
|
||||||
|
supportingText = uiState.loginError?.let { error -> { Text(error) } },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.password,
|
||||||
|
onValueChange = viewModel::onPasswordChange,
|
||||||
|
label = { Text("Password") },
|
||||||
|
singleLine = true,
|
||||||
|
isError = uiState.passwordError != null,
|
||||||
|
supportingText = uiState.passwordError?.let { error -> { Text(error) } },
|
||||||
|
visualTransformation = if (uiState.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = viewModel::togglePasswordVisibility) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (uiState.isPasswordVisible) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.confirmPassword,
|
||||||
|
onValueChange = viewModel::onConfirmPasswordChange,
|
||||||
|
label = { Text("Confirm Password") },
|
||||||
|
singleLine = true,
|
||||||
|
isError = uiState.confirmPasswordError != null,
|
||||||
|
supportingText = uiState.confirmPasswordError?.let { error -> { Text(error) } },
|
||||||
|
visualTransformation = if (uiState.isConfirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = viewModel::toggleConfirmPasswordVisibility) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (uiState.isConfirmPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (uiState.isConfirmPasswordVisible) "Hide password" else "Show password"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.invite,
|
||||||
|
onValueChange = viewModel::onInviteChange,
|
||||||
|
label = { Text("Invite Code (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
viewModel.register()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::register,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
|
),
|
||||||
|
enabled = !uiState.isLoading
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Register",
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(bottom = 32.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Already have an account?",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
TextButton(onClick = onNavigateBack) {
|
||||||
|
Text(
|
||||||
|
"Log In",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
package org.yobble.messenger.presentation.auth.register
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.domain.repository.AuthRepository
|
||||||
|
import org.yobble.messenger.domain.validation.AuthValidator
|
||||||
|
import org.yobble.messenger.domain.validation.ValidationResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class RegisterUiState(
|
||||||
|
val login: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val confirmPassword: String = "",
|
||||||
|
val invite: String = "",
|
||||||
|
val loginError: String? = null,
|
||||||
|
val passwordError: String? = null,
|
||||||
|
val confirmPasswordError: String? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isPasswordVisible: Boolean = false,
|
||||||
|
val isConfirmPasswordVisible: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class RegisterEvent {
|
||||||
|
data object RegisterSuccess : RegisterEvent()
|
||||||
|
data class ShowError(val message: String) : RegisterEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RegisterViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(RegisterUiState())
|
||||||
|
val uiState: StateFlow<RegisterUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<RegisterEvent>()
|
||||||
|
val events: SharedFlow<RegisterEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
fun onLoginChange(value: String) {
|
||||||
|
_uiState.update { it.copy(login = value, loginError = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPasswordChange(value: String) {
|
||||||
|
_uiState.update { it.copy(password = value, passwordError = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConfirmPasswordChange(value: String) {
|
||||||
|
_uiState.update { it.copy(confirmPassword = value, confirmPasswordError = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onInviteChange(value: String) {
|
||||||
|
_uiState.update { it.copy(invite = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePasswordVisibility() {
|
||||||
|
_uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleConfirmPasswordVisibility() {
|
||||||
|
_uiState.update { it.copy(isConfirmPasswordVisible = !it.isConfirmPasswordVisible) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val loginValidation = AuthValidator.validateLogin(state.login)
|
||||||
|
val passwordValidation = AuthValidator.validatePassword(state.password)
|
||||||
|
val confirmValidation = AuthValidator.validatePasswordConfirmation(state.password, state.confirmPassword)
|
||||||
|
|
||||||
|
if (loginValidation is ValidationResult.Error ||
|
||||||
|
passwordValidation is ValidationResult.Error ||
|
||||||
|
confirmValidation is ValidationResult.Error
|
||||||
|
) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
loginError = (loginValidation as? ValidationResult.Error)?.message,
|
||||||
|
passwordError = (passwordValidation as? ValidationResult.Error)?.message,
|
||||||
|
confirmPasswordError = (confirmValidation as? ValidationResult.Error)?.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
val invite = state.invite.ifBlank { null }
|
||||||
|
when (val result = authRepository.register(state.login, state.password, invite)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_events.emit(RegisterEvent.RegisterSuccess)
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
val errorMessage = result.errors.firstOrNull()?.message
|
||||||
|
?: result.message
|
||||||
|
?: "Registration failed"
|
||||||
|
_events.emit(RegisterEvent.ShowError(errorMessage))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(RegisterEvent.ShowError("Connection error. Please try again."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,374 @@
|
|||||||
|
package org.yobble.messenger.presentation.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
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.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material.icons.filled.Verified
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||||
|
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onNavigateToProfile: (userId: String) -> Unit = {},
|
||||||
|
viewModel: ChatViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Save scroll position when leaving the chat
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
val messages = viewModel.uiState.value.messages
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
val firstVisibleIndex = listState.firstVisibleItemIndex
|
||||||
|
val reversed = messages.asReversed()
|
||||||
|
val messageId = reversed.getOrNull(firstVisibleIndex)?.messageId
|
||||||
|
if (messageId != null) {
|
||||||
|
viewModel.saveScrollPosition(messageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to saved position after initial load
|
||||||
|
LaunchedEffect(uiState.scrollToMessageId) {
|
||||||
|
val targetId = uiState.scrollToMessageId ?: return@LaunchedEffect
|
||||||
|
val targetInt = targetId.toIntOrNull() ?: return@LaunchedEffect
|
||||||
|
val reversed = uiState.messages.asReversed()
|
||||||
|
val index = reversed.indexOfFirst { it.messageId == targetInt }
|
||||||
|
if (index >= 0) {
|
||||||
|
listState.scrollToItem(index)
|
||||||
|
}
|
||||||
|
viewModel.clearScrollTarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is ChatEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show scroll-to-bottom button when scrolled up more than 15 messages
|
||||||
|
val showScrollToBottom by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.firstVisibleItemIndex > 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load more when scrolled near the top (high indices in reversed layout)
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
|
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore) {
|
||||||
|
if (shouldLoadMore) viewModel.loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.imePadding(),
|
||||||
|
contentWindowInsets = WindowInsets(0),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
uiState.otherUserId?.let { onNavigateToProfile(it) }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(uiState.chatTitle, fontWeight = FontWeight.Bold)
|
||||||
|
if (uiState.isVerified) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Verified,
|
||||||
|
contentDescription = "Verified",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (uiState.rating != null) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Star,
|
||||||
|
contentDescription = "Rating",
|
||||||
|
tint = Color(0xFFFFC107),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = String.format("%.1f", uiState.rating),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (uiState.canSendMessage) {
|
||||||
|
MessageInputBar(
|
||||||
|
text = uiState.messageText,
|
||||||
|
onTextChange = viewModel::onMessageTextChange,
|
||||||
|
onSend = viewModel::sendMessage,
|
||||||
|
isSending = uiState.isSending
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Surface(
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "You can't send messages to this user",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading && uiState.messages.isEmpty()) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
state = listState,
|
||||||
|
reverseLayout = true,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(uiState.messages.asReversed(), key = { it.messageId }) { message ->
|
||||||
|
MessageBubble(
|
||||||
|
message = message,
|
||||||
|
isOutgoing = message.senderId == uiState.currentUserId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (uiState.isLoading && uiState.messages.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showScrollToBottom,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
SmallFloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = "Scroll to bottom"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageBubble(
|
||||||
|
message: MessageItemDto,
|
||||||
|
isOutgoing: Boolean
|
||||||
|
) {
|
||||||
|
val bubbleColor = if (isOutgoing)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
|
||||||
|
val textColor = if (isOutgoing)
|
||||||
|
MaterialTheme.colorScheme.onPrimary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
|
val timeColor = if (isOutgoing)
|
||||||
|
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
|
val alignment = if (isOutgoing) Arrangement.End else Arrangement.Start
|
||||||
|
val shape = if (isOutgoing)
|
||||||
|
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
|
||||||
|
else
|
||||||
|
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
|
||||||
|
|
||||||
|
val time = formatUtcToLocalTime(message.createdAt)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = alignment
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 280.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(bubbleColor)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
if (!message.content.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = message.content,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = time,
|
||||||
|
color = timeColor,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageInputBar(
|
||||||
|
text: String,
|
||||||
|
onTextChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
isSending: Boolean
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
color = MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = onTextChange,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = { Text("Message") },
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||||
|
maxLines = 4,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
IconButton(
|
||||||
|
onClick = onSend,
|
||||||
|
enabled = text.isNotBlank() && !isSending,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (text.isNotBlank() && !isSending)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "Send",
|
||||||
|
tint = if (text.isNotBlank() && !isSending)
|
||||||
|
MaterialTheme.colorScheme.onPrimary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,222 @@
|
|||||||
|
package org.yobble.messenger.presentation.chat
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||||
|
import org.yobble.messenger.data.remote.socket.SocketEvent
|
||||||
|
import org.yobble.messenger.data.remote.socket.SocketManager
|
||||||
|
import org.yobble.messenger.domain.repository.ChatRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ChatUiState(
|
||||||
|
val chatId: String = "",
|
||||||
|
val chatTitle: String = "Chat",
|
||||||
|
val isVerified: Boolean = false,
|
||||||
|
val rating: Double? = null,
|
||||||
|
val canSendMessage: Boolean = true,
|
||||||
|
val messages: List<MessageItemDto> = emptyList(),
|
||||||
|
val messageText: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isSending: Boolean = false,
|
||||||
|
val hasMore: Boolean = false,
|
||||||
|
val currentUserId: String = "",
|
||||||
|
val otherUserId: String? = null,
|
||||||
|
val scrollToMessageId: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class ChatEvent {
|
||||||
|
data class ShowError(val message: String) : ChatEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val socketManager: SocketManager,
|
||||||
|
private val json: Json
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val chatId = savedStateHandle.get<String>("chatId") ?: ""
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(
|
||||||
|
ChatUiState(
|
||||||
|
chatId = chatId,
|
||||||
|
currentUserId = sessionManager.userId ?: "",
|
||||||
|
messageText = sessionManager.getDraft(chatId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<ChatEvent>()
|
||||||
|
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
private val savedMessageId = sessionManager.getLastReadMessageId(chatId)
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadMessages()
|
||||||
|
observeSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSocket() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
socketManager.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is SocketEvent.NewMessage -> {
|
||||||
|
val data = event.data
|
||||||
|
val payload = data.optJSONObject("payload") ?: data
|
||||||
|
val eventChatId = payload.optString("chat_id", "")
|
||||||
|
|
||||||
|
if (eventChatId == _uiState.value.chatId) {
|
||||||
|
// Parse message from socket payload and insert instantly
|
||||||
|
try {
|
||||||
|
val message = json.decodeFromString<MessageItemDto>(payload.toString())
|
||||||
|
val current = _uiState.value.messages
|
||||||
|
if (current.none { it.messageId == message.messageId }) {
|
||||||
|
_uiState.update { it.copy(messages = current + message) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("ChatViewModel", "Failed to parse socket message, reloading", e)
|
||||||
|
loadMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMessages() {
|
||||||
|
val chatId = _uiState.value.chatId
|
||||||
|
if (chatId.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = chatRepository.getChatHistory(chatId)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val items = result.data.data.items.reversed()
|
||||||
|
val otherMessage = items.firstOrNull { it.senderId != _uiState.value.currentUserId }
|
||||||
|
val otherUser = otherMessage?.senderData
|
||||||
|
val title = otherUser?.customName
|
||||||
|
?: otherUser?.fullName
|
||||||
|
?: otherUser?.login?.let { "@$it" }
|
||||||
|
?: _uiState.value.chatTitle
|
||||||
|
val scrollTarget = if (_uiState.value.messages.isEmpty()) savedMessageId else null
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
messages = items,
|
||||||
|
chatTitle = title,
|
||||||
|
otherUserId = otherMessage?.senderId,
|
||||||
|
isVerified = otherUser?.isVerified == true,
|
||||||
|
rating = otherUser?.rating?.rating,
|
||||||
|
canSendMessage = otherUser?.permissions?.youCanSendMessage != false,
|
||||||
|
hasMore = result.data.data.hasMore,
|
||||||
|
isLoading = false,
|
||||||
|
scrollToMessageId = scrollTarget
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(ChatEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to load messages"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(ChatEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMore() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.isLoading || !state.hasMore || state.messages.isEmpty()) return
|
||||||
|
|
||||||
|
val oldestMessageId = state.messages.first().messageId
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = chatRepository.getChatHistory(state.chatId, beforeMessageId = oldestMessageId)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
messages = result.data.data.items.reversed() + it.messages,
|
||||||
|
hasMore = result.data.data.hasMore,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveScrollPosition(messageId: Int) {
|
||||||
|
val chatId = _uiState.value.chatId
|
||||||
|
if (chatId.isNotBlank()) {
|
||||||
|
sessionManager.saveLastReadMessageId(chatId, messageId.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearScrollTarget() {
|
||||||
|
_uiState.update { it.copy(scrollToMessageId = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
sessionManager.saveDraft(chatId, _uiState.value.messageText)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onMessageTextChange(text: String) {
|
||||||
|
_uiState.update { it.copy(messageText = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage() {
|
||||||
|
val state = _uiState.value
|
||||||
|
val text = state.messageText.trim()
|
||||||
|
if (text.isBlank() || state.isSending) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isSending = true, messageText = "") }
|
||||||
|
when (val result = chatRepository.sendMessage(state.chatId, text)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
sessionManager.saveDraft(chatId, "")
|
||||||
|
loadMessages()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isSending = false, messageText = text) }
|
||||||
|
_events.emit(ChatEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to send message"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isSending = false, messageText = text) }
|
||||||
|
_events.emit(ChatEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isSending = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package org.yobble.messenger.presentation.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FullScreenImageViewer(
|
||||||
|
imageUrl: String,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
|
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
.clickable(onClick = onDismiss)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(imageUrl)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = "Avatar",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures { _, pan, zoom, _ ->
|
||||||
|
scale = (scale * zoom).coerceIn(1f, 5f)
|
||||||
|
if (scale > 1f) {
|
||||||
|
offsetX += pan.x
|
||||||
|
offsetY += pan.y
|
||||||
|
} else {
|
||||||
|
offsetX = 0f
|
||||||
|
offsetY = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = scale,
|
||||||
|
scaleY = scale,
|
||||||
|
translationX = offsetX,
|
||||||
|
translationY = offsetY
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
.statusBarsPadding()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package org.yobble.messenger.presentation.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.SubcomposeAsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import org.yobble.messenger.BuildConfig
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserAvatar(
|
||||||
|
userId: String?,
|
||||||
|
fileId: String?,
|
||||||
|
displayName: String,
|
||||||
|
size: Dp = 52.dp,
|
||||||
|
fontSize: TextUnit = 20.sp
|
||||||
|
) {
|
||||||
|
val avatarUrl = buildAvatarUrl(userId, fileId)
|
||||||
|
|
||||||
|
if (avatarUrl != null) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(avatarUrl)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = "Avatar",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
error = {
|
||||||
|
InitialsAvatar(displayName, size, fontSize)
|
||||||
|
},
|
||||||
|
loading = {
|
||||||
|
InitialsAvatar(displayName, size, fontSize)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
InitialsAvatar(displayName, size, fontSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InitialsAvatar(
|
||||||
|
displayName: String,
|
||||||
|
size: Dp = 52.dp,
|
||||||
|
fontSize: TextUnit = 20.sp
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = displayName.take(1).uppercase(),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = fontSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildAvatarUrl(userId: String?, fileId: String?): String? {
|
||||||
|
if (userId.isNullOrBlank() || fileId.isNullOrBlank()) return null
|
||||||
|
return "${BuildConfig.BASE_URL}v1/storage/download/avatar/$userId?file_id=$fileId"
|
||||||
|
}
|
||||||
@ -0,0 +1,430 @@
|
|||||||
|
package org.yobble.messenger.presentation.contacts
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.dto.ContactInfoDto
|
||||||
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactsScreen(
|
||||||
|
onNavigateToChat: (chatId: String) -> Unit,
|
||||||
|
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||||
|
viewModel: ContactsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is ContactsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
is ContactsEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
is ContactsEvent.NavigateToChat -> onNavigateToChat(event.chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.showAddDialog) {
|
||||||
|
AddContactDialog(
|
||||||
|
query = uiState.addContactQuery,
|
||||||
|
onQueryChange = viewModel::onAddContactQueryChange,
|
||||||
|
onAdd = viewModel::addContact,
|
||||||
|
onDismiss = viewModel::hideAddDialog,
|
||||||
|
isAdding = uiState.isAdding
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.showRenameDialog) {
|
||||||
|
RenameContactDialog(
|
||||||
|
currentName = uiState.renameQuery,
|
||||||
|
onNameChange = viewModel::onRenameQueryChange,
|
||||||
|
onSave = viewModel::saveRename,
|
||||||
|
onDismiss = viewModel::hideRenameDialog,
|
||||||
|
isSaving = uiState.isRenaming
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = viewModel::showAddDialog,
|
||||||
|
modifier = Modifier.padding(bottom = bottomPadding),
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PersonAdd, contentDescription = "Add contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
if (uiState.isLoading && uiState.contacts.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
|
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore) {
|
||||||
|
if (shouldLoadMore) viewModel.loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(bottom = bottomPadding)
|
||||||
|
) {
|
||||||
|
if (uiState.contacts.isEmpty() && !uiState.isLoading) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillParentMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"No contacts yet",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"Tap + to add someone",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(uiState.contacts, key = { it.userId }) { contact ->
|
||||||
|
SwipeableContactItem(
|
||||||
|
contact = contact,
|
||||||
|
isCreatingChat = uiState.creatingChatForUserId == contact.userId,
|
||||||
|
onClick = { viewModel.createChatWithContact(contact.userId) },
|
||||||
|
onRename = { viewModel.showRenameDialog(contact.userId, contact.customName ?: "") },
|
||||||
|
onRemove = { viewModel.removeContact(contact.userId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.isLoading && uiState.contacts.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SwipeableContactItem(
|
||||||
|
contact: ContactInfoDto,
|
||||||
|
isCreatingChat: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onRemove: () -> Unit
|
||||||
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val offsetX = remember { Animatable(0f) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val actionWidthPx = with(density) { 140.dp.toPx() }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clipToBounds()
|
||||||
|
) {
|
||||||
|
// Action buttons behind
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(70.dp)
|
||||||
|
.background(Color(0xFF5C6BC0))
|
||||||
|
.clickable {
|
||||||
|
coroutineScope.launch {
|
||||||
|
offsetX.animateTo(0f, tween(200))
|
||||||
|
}
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = "Rename",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(70.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.error)
|
||||||
|
.clickable {
|
||||||
|
coroutineScope.launch {
|
||||||
|
offsetX.animateTo(0f, tween(200))
|
||||||
|
}
|
||||||
|
onRemove()
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = "Remove",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreground content
|
||||||
|
ContactItemContent(
|
||||||
|
contact = contact,
|
||||||
|
isCreatingChat = isCreatingChat,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragEnd = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
val target = if (offsetX.value < -actionWidthPx / 2) -actionWidthPx else 0f
|
||||||
|
offsetX.animateTo(target, tween(200))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHorizontalDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
coroutineScope.launch {
|
||||||
|
val newValue = (offsetX.value + dragAmount).coerceIn(-actionWidthPx, 0f)
|
||||||
|
offsetX.snapTo(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(start = 76.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactItemContent(
|
||||||
|
contact: ContactInfoDto,
|
||||||
|
isCreatingChat: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val displayName = contact.customName
|
||||||
|
?: contact.fullName
|
||||||
|
?: contact.login?.let { "@$it" }
|
||||||
|
?: "User"
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clickable(enabled = !isCreatingChat, onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = contact.userId,
|
||||||
|
fileId = null,
|
||||||
|
displayName = displayName,
|
||||||
|
size = 48.dp,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (contact.login != null) {
|
||||||
|
Text(
|
||||||
|
text = "@${contact.login}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreatingChat) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddContactDialog(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isAdding: Boolean
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Add contact") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
label = { Text("Login") },
|
||||||
|
placeholder = { Text("username") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = onAdd,
|
||||||
|
enabled = query.isNotBlank() && !isAdding
|
||||||
|
) {
|
||||||
|
if (isAdding) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenameContactDialog(
|
||||||
|
currentName: String,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isSaving: Boolean
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Rename contact") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = currentName,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = { Text("Display name") },
|
||||||
|
placeholder = { Text("Custom name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = onSave,
|
||||||
|
enabled = !isSaving
|
||||||
|
) {
|
||||||
|
if (isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,233 @@
|
|||||||
|
package org.yobble.messenger.presentation.contacts
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.ContactCreateRequestDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ContactInfoDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ContactUpdateRequestDto
|
||||||
|
import org.yobble.messenger.domain.repository.ChatRepository
|
||||||
|
import org.yobble.messenger.domain.repository.UserRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ContactsUiState(
|
||||||
|
val contacts: List<ContactInfoDto> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val hasMore: Boolean = false,
|
||||||
|
val addContactQuery: String = "",
|
||||||
|
val isAdding: Boolean = false,
|
||||||
|
val showAddDialog: Boolean = false,
|
||||||
|
val creatingChatForUserId: String? = null,
|
||||||
|
val showRenameDialog: Boolean = false,
|
||||||
|
val renameUserId: String? = null,
|
||||||
|
val renameQuery: String = "",
|
||||||
|
val isRenaming: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class ContactsEvent {
|
||||||
|
data class ShowError(val message: String) : ContactsEvent()
|
||||||
|
data class ShowSuccess(val message: String) : ContactsEvent()
|
||||||
|
data class NavigateToChat(val chatId: String) : ContactsEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ContactsViewModel @Inject constructor(
|
||||||
|
private val userRepository: UserRepository,
|
||||||
|
private val chatRepository: ChatRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ContactsUiState())
|
||||||
|
val uiState: StateFlow<ContactsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<ContactsEvent>()
|
||||||
|
val events: SharedFlow<ContactsEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadContacts()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadContacts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = userRepository.getContacts()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
contacts = result.data.data.items,
|
||||||
|
hasMore = result.data.data.hasMore,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(ContactsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to load contacts"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(ContactsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMore() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.isLoading || !state.hasMore) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = userRepository.getContacts(offset = state.contacts.size)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
contacts = it.contacts + result.data.data.items,
|
||||||
|
hasMore = result.data.data.hasMore,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> _uiState.update { it.copy(isLoading = false) }
|
||||||
|
is NetworkResult.Exception -> _uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAddDialog() {
|
||||||
|
_uiState.update { it.copy(showAddDialog = true, addContactQuery = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideAddDialog() {
|
||||||
|
_uiState.update { it.copy(showAddDialog = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAddContactQueryChange(value: String) {
|
||||||
|
_uiState.update { it.copy(addContactQuery = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContact() {
|
||||||
|
val query = _uiState.value.addContactQuery.trim()
|
||||||
|
if (query.isBlank() || _uiState.value.isAdding) return
|
||||||
|
|
||||||
|
val request = if (query.startsWith("@")) {
|
||||||
|
ContactCreateRequestDto(login = query.removePrefix("@"))
|
||||||
|
} else {
|
||||||
|
ContactCreateRequestDto(login = query)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isAdding = true) }
|
||||||
|
when (val result = userRepository.addContact(request)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(isAdding = false, showAddDialog = false) }
|
||||||
|
_events.emit(ContactsEvent.ShowSuccess("Contact added"))
|
||||||
|
loadContacts()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isAdding = false) }
|
||||||
|
_events.emit(ContactsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to add contact"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isAdding = false) }
|
||||||
|
_events.emit(ContactsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeContact(userId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = userRepository.removeContact(userId)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_events.emit(ContactsEvent.ShowSuccess("Contact removed"))
|
||||||
|
loadContacts()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_events.emit(ContactsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to remove contact"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(ContactsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showRenameDialog(userId: String, currentName: String) {
|
||||||
|
_uiState.update { it.copy(showRenameDialog = true, renameUserId = userId, renameQuery = currentName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideRenameDialog() {
|
||||||
|
_uiState.update { it.copy(showRenameDialog = false, renameUserId = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRenameQueryChange(value: String) {
|
||||||
|
_uiState.update { it.copy(renameQuery = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveRename() {
|
||||||
|
val userId = _uiState.value.renameUserId ?: return
|
||||||
|
val name = _uiState.value.renameQuery.trim()
|
||||||
|
if (_uiState.value.isRenaming) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isRenaming = true) }
|
||||||
|
when (val result = userRepository.updateContact(
|
||||||
|
ContactUpdateRequestDto(userId, name.ifBlank { null })
|
||||||
|
)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(isRenaming = false, showRenameDialog = false, renameUserId = null) }
|
||||||
|
_events.emit(ContactsEvent.ShowSuccess("Contact updated"))
|
||||||
|
loadContacts()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isRenaming = false) }
|
||||||
|
_events.emit(ContactsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to update contact"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isRenaming = false) }
|
||||||
|
_events.emit(ContactsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChatWithContact(userId: String) {
|
||||||
|
if (_uiState.value.creatingChatForUserId != null) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(creatingChatForUserId = userId) }
|
||||||
|
when (val result = chatRepository.createChat(userId)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(creatingChatForUserId = null) }
|
||||||
|
_events.emit(ContactsEvent.NavigateToChat(result.data.data.chatId))
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(creatingChatForUserId = null) }
|
||||||
|
_events.emit(ContactsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to create chat"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(creatingChatForUserId = null) }
|
||||||
|
_events.emit(ContactsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,448 @@
|
|||||||
|
package org.yobble.messenger.presentation.main
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.Contacts
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material.icons.filled.Verified
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
|
||||||
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
|
import org.yobble.messenger.presentation.contacts.ContactsScreen
|
||||||
|
import org.yobble.messenger.presentation.profile.ProfileScreen
|
||||||
|
import org.yobble.messenger.presentation.settings.SettingsScreen
|
||||||
|
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||||
|
|
||||||
|
private const val SWIPE_NAVIGATION_ENABLED = true
|
||||||
|
|
||||||
|
private enum class HomeTab(val label: String, val icon: ImageVector) {
|
||||||
|
CHATS("Chats", Icons.AutoMirrored.Filled.Chat),
|
||||||
|
CONTACTS("Contacts", Icons.Default.Contacts),
|
||||||
|
SETTINGS("Settings", Icons.Default.Settings),
|
||||||
|
PROFILE("Profile", Icons.Default.Person)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
onNavigateToChat: (chatId: String) -> Unit,
|
||||||
|
onNavigateToPrivacy: () -> Unit,
|
||||||
|
onNavigateToSessions: () -> Unit,
|
||||||
|
onNavigateToChangePassword: () -> Unit,
|
||||||
|
onNavigateToBlacklist: () -> Unit,
|
||||||
|
onNavigateToUserProfile: (userId: String) -> Unit = {},
|
||||||
|
onNavigateToSearch: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
viewModel: HomeViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val tabs = HomeTab.entries
|
||||||
|
val pagerState = rememberPagerState(pageCount = { tabs.size })
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val selectedTab = pagerState.currentPage
|
||||||
|
val density = LocalDensity.current
|
||||||
|
var navBarHeightDp by remember { mutableStateOf(0.dp) }
|
||||||
|
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
viewModel.loadChats()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is HomeEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier.padding(bottom = navBarHeightDp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
tabs[selectedTab].label,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onNavigateToSearch) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentWindowInsets = WindowInsets(0)
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
if (SWIPE_NAVIGATION_ENABLED) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
beyondViewportPageCount = 1
|
||||||
|
) { page ->
|
||||||
|
TabContent(
|
||||||
|
tab = tabs[page],
|
||||||
|
uiState = uiState,
|
||||||
|
onNavigateToChat = onNavigateToChat,
|
||||||
|
onLoadMore = viewModel::loadMore,
|
||||||
|
onNavigateToPrivacy = onNavigateToPrivacy,
|
||||||
|
onNavigateToSessions = onNavigateToSessions,
|
||||||
|
onNavigateToChangePassword = onNavigateToChangePassword,
|
||||||
|
onNavigateToBlacklist = onNavigateToBlacklist,
|
||||||
|
onNavigateToUserProfile = onNavigateToUserProfile,
|
||||||
|
onLogout = {
|
||||||
|
viewModel.logout()
|
||||||
|
onLogout()
|
||||||
|
},
|
||||||
|
bottomPadding = navBarHeightDp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TabContent(
|
||||||
|
tab = tabs[selectedTab],
|
||||||
|
uiState = uiState,
|
||||||
|
onNavigateToChat = onNavigateToChat,
|
||||||
|
onLoadMore = viewModel::loadMore,
|
||||||
|
onNavigateToPrivacy = onNavigateToPrivacy,
|
||||||
|
onNavigateToSessions = onNavigateToSessions,
|
||||||
|
onNavigateToChangePassword = onNavigateToChangePassword,
|
||||||
|
onNavigateToBlacklist = onNavigateToBlacklist,
|
||||||
|
onNavigateToUserProfile = onNavigateToUserProfile,
|
||||||
|
onLogout = {
|
||||||
|
viewModel.logout()
|
||||||
|
onLogout()
|
||||||
|
},
|
||||||
|
bottomPadding = navBarHeightDp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating bottom navigation bar
|
||||||
|
NavigationBar(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
val heightPx = coordinates.size.height
|
||||||
|
navBarHeightDp = with(density) { heightPx.toDp() }
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||||
|
tonalElevation = 8.dp
|
||||||
|
) {
|
||||||
|
tabs.forEachIndexed { index, tab ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
if (SWIPE_NAVIGATION_ENABLED) {
|
||||||
|
pagerState.animateScrollToPage(index)
|
||||||
|
} else {
|
||||||
|
pagerState.scrollToPage(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(tab.icon, contentDescription = null) },
|
||||||
|
label = { Text(tab.label, fontSize = 11.sp) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TabContent(
|
||||||
|
tab: HomeTab,
|
||||||
|
uiState: HomeUiState,
|
||||||
|
onNavigateToChat: (String) -> Unit,
|
||||||
|
onLoadMore: () -> Unit,
|
||||||
|
onNavigateToPrivacy: () -> Unit,
|
||||||
|
onNavigateToSessions: () -> Unit,
|
||||||
|
onNavigateToChangePassword: () -> Unit,
|
||||||
|
onNavigateToBlacklist: () -> Unit,
|
||||||
|
onNavigateToUserProfile: (String) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
bottomPadding: androidx.compose.ui.unit.Dp
|
||||||
|
) {
|
||||||
|
when (tab) {
|
||||||
|
HomeTab.CHATS -> ChatsContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onNavigateToChat = onNavigateToChat,
|
||||||
|
onLoadMore = onLoadMore,
|
||||||
|
bottomPadding = bottomPadding
|
||||||
|
)
|
||||||
|
HomeTab.CONTACTS -> ContactsScreen(
|
||||||
|
onNavigateToChat = onNavigateToChat,
|
||||||
|
bottomPadding = bottomPadding
|
||||||
|
)
|
||||||
|
HomeTab.SETTINGS -> SettingsScreen(
|
||||||
|
onNavigateToPrivacy = onNavigateToPrivacy,
|
||||||
|
onNavigateToSessions = onNavigateToSessions,
|
||||||
|
onNavigateToChangePassword = onNavigateToChangePassword,
|
||||||
|
onNavigateToBlacklist = onNavigateToBlacklist,
|
||||||
|
onLogout = onLogout,
|
||||||
|
bottomPadding = bottomPadding
|
||||||
|
)
|
||||||
|
HomeTab.PROFILE -> ProfileScreen(
|
||||||
|
onNavigateBack = { /* already on home, no-op */ },
|
||||||
|
onNavigateToChat = onNavigateToChat,
|
||||||
|
showTopBar = false,
|
||||||
|
bottomPadding = bottomPadding
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChatsContent(
|
||||||
|
uiState: HomeUiState,
|
||||||
|
onNavigateToChat: (String) -> Unit,
|
||||||
|
onLoadMore: () -> Unit,
|
||||||
|
bottomPadding: androidx.compose.ui.unit.Dp
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading && uiState.chats.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
} else if (uiState.chats.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
"No chats yet",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Start a new conversation",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
|
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore) {
|
||||||
|
if (shouldLoadMore) onLoadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(bottom = bottomPadding)
|
||||||
|
) {
|
||||||
|
items(uiState.chats, key = { it.chatId }) { chat ->
|
||||||
|
ChatListItem(
|
||||||
|
chat = chat,
|
||||||
|
onClick = { onNavigateToChat(chat.chatId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (uiState.isLoading && uiState.chats.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChatListItem(
|
||||||
|
chat: PrivateChatListItemDto,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val chatData = chat.chatData
|
||||||
|
val displayName = chatData?.customName
|
||||||
|
?: chatData?.fullName
|
||||||
|
?: chatData?.login?.let { "@$it" }
|
||||||
|
?: chat.chatType.replaceFirstChar { it.uppercase() }
|
||||||
|
val isVerified = chatData?.isVerified == true
|
||||||
|
val rating = chatData?.rating?.rating
|
||||||
|
val lastMessageText = chat.lastMessage?.content ?: ""
|
||||||
|
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = chatData?.userId,
|
||||||
|
fileId = chatData?.avatars?.current?.fileId,
|
||||||
|
displayName = displayName
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
)
|
||||||
|
if (isVerified) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Verified,
|
||||||
|
contentDescription = "Verified",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (rating != null) {
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Star,
|
||||||
|
contentDescription = "Rating",
|
||||||
|
tint = Color(0xFFFFC107),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = String.format("%.1f", rating),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = time,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (chat.unreadCount > 0)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = lastMessageText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (chat.unreadCount > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (chat.unreadCount > 99) "99+" else chat.unreadCount.toString(),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(start = 80.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
package org.yobble.messenger.presentation.main
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
|
||||||
|
import org.yobble.messenger.data.remote.socket.SocketEvent
|
||||||
|
import org.yobble.messenger.data.remote.socket.SocketManager
|
||||||
|
import org.yobble.messenger.domain.repository.AuthRepository
|
||||||
|
import org.yobble.messenger.domain.repository.ChatRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class HomeUiState(
|
||||||
|
val chats: List<PrivateChatListItemDto> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val hasMore: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class HomeEvent {
|
||||||
|
data class ShowError(val message: String) : HomeEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class HomeViewModel @Inject constructor(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val socketManager: SocketManager
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(HomeUiState())
|
||||||
|
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<HomeEvent>()
|
||||||
|
val events: SharedFlow<HomeEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
private var refreshJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
socketManager.connect()
|
||||||
|
loadChats()
|
||||||
|
observeSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSocket() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
socketManager.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is SocketEvent.NewMessage -> debouncedRefresh()
|
||||||
|
is SocketEvent.Connected -> loadChats()
|
||||||
|
is SocketEvent.Disconnected -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun debouncedRefresh() {
|
||||||
|
refreshJob?.cancel()
|
||||||
|
refreshJob = viewModelScope.launch {
|
||||||
|
delay(300)
|
||||||
|
loadChats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadChats() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = chatRepository.getChatList()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
chats = result.data.data.items,
|
||||||
|
hasMore = result.data.data.hasMore,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(HomeEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to load chats"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(HomeEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMore() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.isLoading || !state.hasMore) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = chatRepository.getChatList(offset = state.chats.size)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
chats = it.chats + result.data.data.items,
|
||||||
|
hasMore = result.data.data.hasMore,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
socketManager.disconnect()
|
||||||
|
authRepository.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
package org.yobble.messenger.presentation.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import org.yobble.messenger.presentation.auth.code.CodeVerificationScreen
|
||||||
|
import org.yobble.messenger.presentation.auth.login.LoginScreen
|
||||||
|
import org.yobble.messenger.presentation.auth.register.RegisterScreen
|
||||||
|
import org.yobble.messenger.presentation.chat.ChatScreen
|
||||||
|
import org.yobble.messenger.presentation.main.HomeScreen
|
||||||
|
import org.yobble.messenger.presentation.profile.ProfileScreen
|
||||||
|
import org.yobble.messenger.presentation.search.SearchScreen
|
||||||
|
import org.yobble.messenger.presentation.settings.BlacklistScreen
|
||||||
|
import org.yobble.messenger.presentation.settings.ChangePasswordScreen
|
||||||
|
import org.yobble.messenger.presentation.settings.PrivacyScreen
|
||||||
|
import org.yobble.messenger.presentation.settings.SessionsScreen
|
||||||
|
|
||||||
|
object Routes {
|
||||||
|
const val LOGIN = "login"
|
||||||
|
const val REGISTER = "register"
|
||||||
|
const val CODE_VERIFICATION = "code_verification/{login}"
|
||||||
|
const val HOME = "home"
|
||||||
|
const val CHAT = "chat/{chatId}"
|
||||||
|
const val USER_PROFILE = "profile/{userId}"
|
||||||
|
const val PRIVACY = "settings/privacy"
|
||||||
|
const val SESSIONS = "settings/sessions"
|
||||||
|
const val CHANGE_PASSWORD = "settings/change_password"
|
||||||
|
const val BLACKLIST = "settings/blacklist"
|
||||||
|
const val SEARCH = "search"
|
||||||
|
|
||||||
|
fun codeVerification(login: String) = "code_verification/$login"
|
||||||
|
fun chat(chatId: String) = "chat/$chatId"
|
||||||
|
fun userProfile(userId: String) = "profile/$userId"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppNavGraph(
|
||||||
|
navController: NavHostController,
|
||||||
|
startDestination: String
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination
|
||||||
|
) {
|
||||||
|
composable(Routes.LOGIN) {
|
||||||
|
LoginScreen(
|
||||||
|
onNavigateToRegister = {
|
||||||
|
navController.navigate(Routes.REGISTER)
|
||||||
|
},
|
||||||
|
onNavigateToCodeLogin = { login ->
|
||||||
|
navController.navigate(Routes.codeVerification(login))
|
||||||
|
},
|
||||||
|
onLoginSuccess = {
|
||||||
|
navController.navigate(Routes.HOME) {
|
||||||
|
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.REGISTER) {
|
||||||
|
RegisterScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onRegisterSuccess = {
|
||||||
|
navController.popBackStack(Routes.LOGIN, false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.CODE_VERIFICATION) { backStackEntry ->
|
||||||
|
val login = backStackEntry.arguments?.getString("login") ?: ""
|
||||||
|
CodeVerificationScreen(
|
||||||
|
login = login,
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onVerifySuccess = {
|
||||||
|
navController.navigate(Routes.HOME) {
|
||||||
|
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.HOME) {
|
||||||
|
HomeScreen(
|
||||||
|
onNavigateToChat = { chatId ->
|
||||||
|
navController.navigate(Routes.chat(chatId))
|
||||||
|
},
|
||||||
|
onNavigateToPrivacy = {
|
||||||
|
navController.navigate(Routes.PRIVACY)
|
||||||
|
},
|
||||||
|
onNavigateToSessions = {
|
||||||
|
navController.navigate(Routes.SESSIONS)
|
||||||
|
},
|
||||||
|
onNavigateToChangePassword = {
|
||||||
|
navController.navigate(Routes.CHANGE_PASSWORD)
|
||||||
|
},
|
||||||
|
onNavigateToBlacklist = {
|
||||||
|
navController.navigate(Routes.BLACKLIST)
|
||||||
|
},
|
||||||
|
onNavigateToUserProfile = { userId ->
|
||||||
|
navController.navigate(Routes.userProfile(userId))
|
||||||
|
},
|
||||||
|
onNavigateToSearch = {
|
||||||
|
navController.navigate(Routes.SEARCH)
|
||||||
|
},
|
||||||
|
onLogout = {
|
||||||
|
navController.navigate(Routes.LOGIN) {
|
||||||
|
popUpTo(Routes.HOME) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.USER_PROFILE) {
|
||||||
|
ProfileScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onNavigateToChat = { chatId ->
|
||||||
|
navController.navigate(Routes.chat(chatId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.CHAT) {
|
||||||
|
ChatScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onNavigateToProfile = { userId ->
|
||||||
|
navController.navigate(Routes.userProfile(userId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.PRIVACY) {
|
||||||
|
PrivacyScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.SESSIONS) {
|
||||||
|
SessionsScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.CHANGE_PASSWORD) {
|
||||||
|
ChangePasswordScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.BLACKLIST) {
|
||||||
|
BlacklistScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.SEARCH) {
|
||||||
|
SearchScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
onNavigateToProfile = { userId ->
|
||||||
|
navController.navigate(Routes.userProfile(userId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,449 @@
|
|||||||
|
package org.yobble.messenger.presentation.profile
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.CameraAlt
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material.icons.filled.Verified
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.yobble.messenger.presentation.common.FullScreenImageViewer
|
||||||
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
|
import org.yobble.messenger.presentation.common.buildAvatarUrl
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ProfileScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onNavigateToChat: ((chatId: String) -> Unit)? = null,
|
||||||
|
showTopBar: Boolean = true,
|
||||||
|
bottomPadding: Dp = 0.dp,
|
||||||
|
viewModel: ProfileViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is ProfileEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
is ProfileEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
is ProfileEvent.NavigateToChat -> onNavigateToChat?.invoke(event.chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
if (showTopBar) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Profile", fontWeight = FontWeight.Bold) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (uiState.isMyProfile && !uiState.isEditing && uiState.profile != null) {
|
||||||
|
IconButton(onClick = viewModel::startEditing) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
if (uiState.isLoading && uiState.profile == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val profile = uiState.profile ?: return@Scaffold
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val allowedMimeTypes = setOf("image/jpeg", "image/png", "image/heic", "image/heif", "image/gif")
|
||||||
|
val imagePicker = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri ?: return@rememberLauncherForActivityResult
|
||||||
|
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||||
|
if (mimeType !in allowedMimeTypes) {
|
||||||
|
return@rememberLauncherForActivityResult
|
||||||
|
}
|
||||||
|
val ext = when (mimeType) {
|
||||||
|
"image/png" -> "png"
|
||||||
|
"image/gif" -> "gif"
|
||||||
|
"image/heic" -> "heic"
|
||||||
|
"image/heif" -> "heif"
|
||||||
|
else -> "jpg"
|
||||||
|
}
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri) ?: return@rememberLauncherForActivityResult
|
||||||
|
val tempFile = File(context.cacheDir, "avatar_upload.$ext")
|
||||||
|
tempFile.outputStream().use { out -> inputStream.copyTo(out) }
|
||||||
|
viewModel.uploadAvatar(tempFile, mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
val avatarUrl = buildAvatarUrl(profile.userId, profile.avatarFileId)
|
||||||
|
var showFullScreenAvatar by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showFullScreenAvatar && avatarUrl != null) {
|
||||||
|
FullScreenImageViewer(
|
||||||
|
imageUrl = avatarUrl,
|
||||||
|
onDismiss = { showFullScreenAvatar = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
Box(contentAlignment = Alignment.BottomEnd) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.clickable(enabled = avatarUrl != null) {
|
||||||
|
showFullScreenAvatar = true
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = profile.userId,
|
||||||
|
fileId = profile.avatarFileId,
|
||||||
|
displayName = profile.displayName,
|
||||||
|
size = 100.dp,
|
||||||
|
fontSize = 40.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (uiState.isMyProfile) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.clickable { imagePicker.launch("image/*") },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (uiState.isUploading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CameraAlt,
|
||||||
|
contentDescription = "Change avatar",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Name + verified
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = profile.displayName,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
if (profile.isVerified) {
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Verified,
|
||||||
|
contentDescription = "Verified",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login
|
||||||
|
if (profile.login.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "@${profile.login}",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rating
|
||||||
|
if (profile.rating != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Star,
|
||||||
|
contentDescription = "Rating",
|
||||||
|
tint = Color(0xFFFFC107),
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = String.format("%.1f", profile.rating),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showTopBar && uiState.isMyProfile && !uiState.isEditing && uiState.profile != null) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
OutlinedButton(onClick = viewModel::startEditing) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("Edit profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uiState.isMyProfile && profile.canSendMessage && onNavigateToChat != null) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::createChat,
|
||||||
|
enabled = !uiState.isCreatingChat
|
||||||
|
) {
|
||||||
|
if (uiState.isCreatingChat) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Send message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
if (uiState.isEditing) {
|
||||||
|
EditProfileSection(
|
||||||
|
fullName = uiState.editFullName,
|
||||||
|
bio = uiState.editBio,
|
||||||
|
onFullNameChange = viewModel::onFullNameChange,
|
||||||
|
onBioChange = viewModel::onBioChange,
|
||||||
|
onSave = viewModel::saveProfile,
|
||||||
|
onCancel = viewModel::cancelEditing,
|
||||||
|
isSaving = uiState.isSaving,
|
||||||
|
canSave = uiState.editBio.length <= 1024
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ProfileInfoSection(
|
||||||
|
profile = profile,
|
||||||
|
isMyProfile = uiState.isMyProfile
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp + bottomPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileInfoSection(
|
||||||
|
profile: ProfileInfo,
|
||||||
|
isMyProfile: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
if (!profile.bio.isNullOrBlank()) {
|
||||||
|
ProfileInfoCard(label = "Bio", value = profile.bio)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileInfoCard(
|
||||||
|
label = "Member since",
|
||||||
|
value = formatUtcToLocalDate(profile.createdAt)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isMyProfile && profile.balances.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
ProfileInfoCard(
|
||||||
|
label = "Balance",
|
||||||
|
value = profile.balances.joinToString("\n") {
|
||||||
|
"${it.displayBalance ?: it.balance} ${it.currency.uppercase()}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMyProfile) {
|
||||||
|
val rel = profile.relationship
|
||||||
|
if (rel != null) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
if (rel.isTargetInContacts) {
|
||||||
|
ProfileInfoCard(label = "Contact", value = "In your contacts")
|
||||||
|
}
|
||||||
|
if (rel.isTargetBlocked) {
|
||||||
|
ProfileInfoCard(label = "Blocked", value = "You blocked this user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileInfoCard(label: String, value: String) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditProfileSection(
|
||||||
|
fullName: String,
|
||||||
|
bio: String,
|
||||||
|
onFullNameChange: (String) -> Unit,
|
||||||
|
onBioChange: (String) -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
isSaving: Boolean,
|
||||||
|
canSave: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fullName,
|
||||||
|
onValueChange = onFullNameChange,
|
||||||
|
label = { Text("Full name") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
val bioMaxLength = 1024
|
||||||
|
val bioOverLimit = bio.length > bioMaxLength
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bio,
|
||||||
|
onValueChange = onBioChange,
|
||||||
|
label = { Text("Bio") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 5,
|
||||||
|
isError = bioOverLimit,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
text = "${bio.length}/$bioMaxLength",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
color = if (bioOverLimit)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onCancel,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = !isSaving
|
||||||
|
) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onSave,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = !isSaving && canSave
|
||||||
|
) {
|
||||||
|
if (isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatUtcToLocalDate(isoString: String): String {
|
||||||
|
return try {
|
||||||
|
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
|
||||||
|
val local = zonedUtc.withZoneSameInstant(java.time.ZoneId.systemDefault())
|
||||||
|
local.format(java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy"))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
isoString.substringBefore("T")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,258 @@
|
|||||||
|
package org.yobble.messenger.presentation.profile
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.RatingDataDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.RelationshipStatusDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.WalletBalanceDto
|
||||||
|
import org.yobble.messenger.domain.repository.ChatRepository
|
||||||
|
import org.yobble.messenger.domain.repository.ProfileRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ProfileInfo(
|
||||||
|
val userId: String = "",
|
||||||
|
val login: String = "",
|
||||||
|
val displayName: String = "",
|
||||||
|
val bio: String? = null,
|
||||||
|
val isVerified: Boolean = false,
|
||||||
|
val isSystem: Boolean = false,
|
||||||
|
val rating: Double? = null,
|
||||||
|
val createdAt: String = "",
|
||||||
|
val avatarFileId: String? = null,
|
||||||
|
val balances: List<WalletBalanceDto> = emptyList(),
|
||||||
|
val relationship: RelationshipStatusDto? = null,
|
||||||
|
val canSendMessage: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProfileUiState(
|
||||||
|
val profile: ProfileInfo? = null,
|
||||||
|
val isMyProfile: Boolean = true,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val isUploading: Boolean = false,
|
||||||
|
val isCreatingChat: Boolean = false,
|
||||||
|
val isEditing: Boolean = false,
|
||||||
|
val editFullName: String = "",
|
||||||
|
val editBio: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class ProfileEvent {
|
||||||
|
data class ShowError(val message: String) : ProfileEvent()
|
||||||
|
data class ShowSuccess(val message: String) : ProfileEvent()
|
||||||
|
data class NavigateToChat(val chatId: String) : ProfileEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ProfileViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val profileRepository: ProfileRepository,
|
||||||
|
private val chatRepository: ChatRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val userId: String? = savedStateHandle.get<String>("userId")
|
||||||
|
private val isMyProfile = userId == null
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ProfileUiState(isMyProfile = isMyProfile))
|
||||||
|
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<ProfileEvent>()
|
||||||
|
val events: SharedFlow<ProfileEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadProfile() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
if (isMyProfile) {
|
||||||
|
loadMyProfile()
|
||||||
|
} else {
|
||||||
|
loadUserProfile(userId!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadMyProfile() {
|
||||||
|
when (val result = profileRepository.getMyProfile()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val p = result.data.data
|
||||||
|
val info = ProfileInfo(
|
||||||
|
userId = p.userId,
|
||||||
|
login = p.login,
|
||||||
|
displayName = p.fullName ?: p.login,
|
||||||
|
bio = p.bio,
|
||||||
|
isVerified = p.isVerified == true,
|
||||||
|
rating = p.rating.rating,
|
||||||
|
createdAt = p.createdAt,
|
||||||
|
avatarFileId = p.avatars?.current?.fileId,
|
||||||
|
balances = p.balances
|
||||||
|
)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
profile = info,
|
||||||
|
isLoading = false,
|
||||||
|
editFullName = p.fullName ?: "",
|
||||||
|
editBio = p.bio ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to load profile"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadUserProfile(id: String) {
|
||||||
|
when (val result = profileRepository.getUserProfile(id)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
val p = result.data.data
|
||||||
|
val info = ProfileInfo(
|
||||||
|
userId = p.userId,
|
||||||
|
login = p.login ?: "",
|
||||||
|
displayName = p.customName ?: p.fullName ?: p.login?.let { "@$it" } ?: "User",
|
||||||
|
bio = p.bio,
|
||||||
|
isVerified = p.isVerified == true,
|
||||||
|
isSystem = p.isSystem == true,
|
||||||
|
rating = p.rating.rating,
|
||||||
|
createdAt = p.createdAt,
|
||||||
|
avatarFileId = p.avatars?.current?.fileId,
|
||||||
|
relationship = p.relationship,
|
||||||
|
canSendMessage = p.permissions.youCanSendMessage
|
||||||
|
)
|
||||||
|
_uiState.update { it.copy(profile = info, isLoading = false) }
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to load profile"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startEditing() {
|
||||||
|
if (!isMyProfile) return
|
||||||
|
val profile = _uiState.value.profile ?: return
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isEditing = true,
|
||||||
|
editFullName = if (profile.displayName != profile.login) profile.displayName else "",
|
||||||
|
editBio = profile.bio ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelEditing() {
|
||||||
|
_uiState.update { it.copy(isEditing = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onFullNameChange(value: String) {
|
||||||
|
_uiState.update { it.copy(editFullName = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBioChange(value: String) {
|
||||||
|
_uiState.update { it.copy(editBio = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveProfile() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.isSaving || !isMyProfile) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isSaving = true) }
|
||||||
|
val request = ProfileUpdateRequestDto(
|
||||||
|
fullName = state.editFullName.trim().ifBlank { null },
|
||||||
|
bio = state.editBio.trim().ifBlank { null }
|
||||||
|
)
|
||||||
|
when (val result = profileRepository.editProfile(request)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(isSaving = false, isEditing = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowSuccess("Profile updated"))
|
||||||
|
loadProfile()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isSaving = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to update profile"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isSaving = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadAvatar(file: java.io.File, mimeType: String) {
|
||||||
|
if (!isMyProfile || _uiState.value.isUploading) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isUploading = true) }
|
||||||
|
when (val result = profileRepository.uploadAvatar(file, mimeType)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(isUploading = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowSuccess("Avatar updated"))
|
||||||
|
loadProfile()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isUploading = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to upload avatar"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isUploading = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChat() {
|
||||||
|
val targetUserId = userId ?: return
|
||||||
|
if (_uiState.value.isCreatingChat) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isCreatingChat = true) }
|
||||||
|
when (val result = chatRepository.createChat(targetUserId)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(isCreatingChat = false) }
|
||||||
|
_events.emit(ProfileEvent.NavigateToChat(result.data.data.chatId))
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isCreatingChat = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to create chat"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isCreatingChat = false) }
|
||||||
|
_events.emit(ProfileEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,222 @@
|
|||||||
|
package org.yobble.messenger.presentation.search
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material.icons.filled.Verified
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.yobble.messenger.data.remote.dto.UserSearchResultDto
|
||||||
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onNavigateToProfile: (userId: String) -> Unit,
|
||||||
|
viewModel: SearchViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.query,
|
||||||
|
onValueChange = viewModel::onQueryChange,
|
||||||
|
placeholder = { Text("Search users...", color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.6f)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
unfocusedTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
focusedBorderColor = Color.Transparent,
|
||||||
|
unfocusedBorderColor = Color.Transparent
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
if (uiState.query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.onQueryChange("") }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Clear,
|
||||||
|
contentDescription = "Clear",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
uiState.isLoading -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uiState.query.isBlank() -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Search by login",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uiState.results.isEmpty() && !uiState.isLoading -> {
|
||||||
|
Text(
|
||||||
|
"No users found",
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(uiState.results, key = { it.userId }) { result ->
|
||||||
|
SearchResultItem(
|
||||||
|
result = result,
|
||||||
|
onClick = { onNavigateToProfile(result.userId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResultItem(
|
||||||
|
result: UserSearchResultDto,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val profile = result.profile
|
||||||
|
val displayName = profile?.customName
|
||||||
|
?: profile?.fullName
|
||||||
|
?: profile?.login?.let { "@$it" }
|
||||||
|
?: "User"
|
||||||
|
val isVerified = profile?.isVerified == true
|
||||||
|
val rating = profile?.rating?.rating
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = result.userId,
|
||||||
|
fileId = profile?.avatars?.current?.fileId,
|
||||||
|
displayName = displayName,
|
||||||
|
size = 48.dp,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
)
|
||||||
|
if (isVerified) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Verified,
|
||||||
|
contentDescription = "Verified",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (rating != null) {
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Star,
|
||||||
|
contentDescription = "Rating",
|
||||||
|
tint = Color(0xFFFFC107),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = String.format("%.1f", rating),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (profile?.login != null) {
|
||||||
|
Text(
|
||||||
|
text = "@${profile.login}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(start = 76.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package org.yobble.messenger.presentation.search
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.UserSearchResultDto
|
||||||
|
import org.yobble.messenger.domain.repository.FeedRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class SearchUiState(
|
||||||
|
val query: String = "",
|
||||||
|
val results: List<UserSearchResultDto> = emptyList(),
|
||||||
|
val isLoading: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SearchViewModel @Inject constructor(
|
||||||
|
private val feedRepository: FeedRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SearchUiState())
|
||||||
|
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
|
fun onQueryChange(query: String) {
|
||||||
|
_uiState.update { it.copy(query = query) }
|
||||||
|
searchJob?.cancel()
|
||||||
|
|
||||||
|
if (query.isBlank()) {
|
||||||
|
_uiState.update { it.copy(results = emptyList(), isLoading = false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchJob = viewModelScope.launch {
|
||||||
|
delay(400)
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = feedRepository.searchUsers(query.trim())) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
results = result.data.data.users,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false, results = emptyList()) }
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false, results = emptyList()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,246 @@
|
|||||||
|
package org.yobble.messenger.presentation.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Block
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.yobble.messenger.data.remote.dto.BlacklistInfoDto
|
||||||
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BlacklistScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: BlacklistViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is BlacklistEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
is BlacklistEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.showBlockDialog) {
|
||||||
|
BlockUserDialog(
|
||||||
|
query = uiState.blockQuery,
|
||||||
|
onQueryChange = viewModel::onBlockQueryChange,
|
||||||
|
onBlock = viewModel::blockUser,
|
||||||
|
onDismiss = viewModel::hideBlockDialog,
|
||||||
|
isBlocking = uiState.isBlocking
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Blacklist", fontWeight = FontWeight.Bold) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = viewModel::showBlockDialog,
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PersonAdd, contentDescription = "Block user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
if (uiState.isLoading && uiState.blacklist.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
} else if (uiState.blacklist.isEmpty() && !uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Block,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Blacklist is empty",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
items(uiState.blacklist, key = { it.userId }) { blocked ->
|
||||||
|
BlacklistItem(
|
||||||
|
item = blocked,
|
||||||
|
onUnblock = { viewModel.unblockUser(blocked.userId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.isLoading && uiState.blacklist.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BlacklistItem(
|
||||||
|
item: BlacklistInfoDto,
|
||||||
|
onUnblock: () -> Unit
|
||||||
|
) {
|
||||||
|
val displayName = item.fullName
|
||||||
|
?: item.login?.let { "@$it" }
|
||||||
|
?: "User"
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = item.userId,
|
||||||
|
fileId = null,
|
||||||
|
displayName = displayName,
|
||||||
|
size = 44.dp,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (item.login != null) {
|
||||||
|
Text(
|
||||||
|
text = "@${item.login}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onUnblock) {
|
||||||
|
Text("Unblock", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(start = 72.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BlockUserDialog(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onBlock: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isBlocking: Boolean
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Block user") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
label = { Text("Login") },
|
||||||
|
placeholder = { Text("username") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = onBlock,
|
||||||
|
enabled = query.isNotBlank() && !isBlocking,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (isBlocking) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
package org.yobble.messenger.presentation.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.BlacklistCreateRequestDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.BlacklistInfoDto
|
||||||
|
import org.yobble.messenger.domain.repository.UserRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class BlacklistUiState(
|
||||||
|
val blacklist: List<BlacklistInfoDto> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val hasMore: Boolean = false,
|
||||||
|
val blockQuery: String = "",
|
||||||
|
val isBlocking: Boolean = false,
|
||||||
|
val showBlockDialog: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class BlacklistEvent {
|
||||||
|
data class ShowError(val message: String) : BlacklistEvent()
|
||||||
|
data class ShowSuccess(val message: String) : BlacklistEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BlacklistViewModel @Inject constructor(
|
||||||
|
private val userRepository: UserRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(BlacklistUiState())
|
||||||
|
val uiState: StateFlow<BlacklistUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<BlacklistEvent>()
|
||||||
|
val events: SharedFlow<BlacklistEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadBlacklist()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBlacklist() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
|
when (val result = userRepository.getBlacklist()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
blacklist = result.data.data.items,
|
||||||
|
hasMore = result.data.data.hasMore,
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(BlacklistEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to load blacklist"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
_events.emit(BlacklistEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showBlockDialog() {
|
||||||
|
_uiState.update { it.copy(showBlockDialog = true, blockQuery = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideBlockDialog() {
|
||||||
|
_uiState.update { it.copy(showBlockDialog = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBlockQueryChange(value: String) {
|
||||||
|
_uiState.update { it.copy(blockQuery = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun blockUser() {
|
||||||
|
val query = _uiState.value.blockQuery.trim()
|
||||||
|
if (query.isBlank() || _uiState.value.isBlocking) return
|
||||||
|
|
||||||
|
val login = if (query.startsWith("@")) query.removePrefix("@") else query
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isBlocking = true) }
|
||||||
|
when (val result = userRepository.addToBlacklist(BlacklistCreateRequestDto(login = login))) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(isBlocking = false, showBlockDialog = false) }
|
||||||
|
_events.emit(BlacklistEvent.ShowSuccess("User blocked"))
|
||||||
|
loadBlacklist()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isBlocking = false) }
|
||||||
|
_events.emit(BlacklistEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to block user"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isBlocking = false) }
|
||||||
|
_events.emit(BlacklistEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unblockUser(userId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = userRepository.removeFromBlacklist(userId)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_events.emit(BlacklistEvent.ShowSuccess("User unblocked"))
|
||||||
|
loadBlacklist()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_events.emit(BlacklistEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to unblock user"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(BlacklistEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
package org.yobble.messenger.presentation.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChangePasswordScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is SettingsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
is SettingsEvent.ShowSuccess -> {
|
||||||
|
snackbarHostState.showSnackbar(event.message)
|
||||||
|
onNavigateBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Change password", fontWeight = FontWeight.Bold) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
PasswordField(
|
||||||
|
value = uiState.oldPassword,
|
||||||
|
onValueChange = viewModel::onOldPasswordChange,
|
||||||
|
label = "Current password"
|
||||||
|
)
|
||||||
|
PasswordField(
|
||||||
|
value = uiState.newPassword,
|
||||||
|
onValueChange = viewModel::onNewPasswordChange,
|
||||||
|
label = "New password",
|
||||||
|
isError = uiState.newPassword.isNotEmpty() && uiState.newPassword.length < 8,
|
||||||
|
supportingText = if (uiState.newPassword.isNotEmpty() && uiState.newPassword.length < 8)
|
||||||
|
"At least 8 characters" else null
|
||||||
|
)
|
||||||
|
PasswordField(
|
||||||
|
value = uiState.confirmPassword,
|
||||||
|
onValueChange = viewModel::onConfirmPasswordChange,
|
||||||
|
label = "Confirm new password",
|
||||||
|
isError = uiState.confirmPassword.isNotEmpty() && uiState.confirmPassword != uiState.newPassword,
|
||||||
|
supportingText = if (uiState.confirmPassword.isNotEmpty() && uiState.confirmPassword != uiState.newPassword)
|
||||||
|
"Passwords don't match" else null
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
val canSubmit = uiState.oldPassword.isNotBlank() &&
|
||||||
|
uiState.newPassword.length >= 8 &&
|
||||||
|
uiState.newPassword == uiState.confirmPassword &&
|
||||||
|
!uiState.isChangingPassword
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::changePassword,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = canSubmit,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isChangingPassword) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Change password", modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
label: String,
|
||||||
|
isError: Boolean = false,
|
||||||
|
supportingText: String? = null
|
||||||
|
) {
|
||||||
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
label = { Text(label) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
isError = isError,
|
||||||
|
supportingText = supportingText?.let { { Text(it) } },
|
||||||
|
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { visible = !visible }) {
|
||||||
|
Icon(
|
||||||
|
if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (visible) "Hide" else "Show"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,306 @@
|
|||||||
|
package org.yobble.messenger.presentation.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfilePermissionsRequestDto
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PrivacyScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadPermissions()
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is SettingsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
is SettingsEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Privacy", fontWeight = FontWeight.Bold) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
val permissions = uiState.permissions
|
||||||
|
if (uiState.isLoadingPermissions && permissions == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
} else if (permissions != null) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
SectionHeader("Visibility")
|
||||||
|
|
||||||
|
ToggleItem(
|
||||||
|
title = "Searchable",
|
||||||
|
subtitle = "Others can find you by search",
|
||||||
|
checked = permissions.isSearchable,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onToggle = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(isSearchable = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ToggleItem(
|
||||||
|
title = "Show bio to non-contacts",
|
||||||
|
subtitle = "Users not in your contacts can see your bio",
|
||||||
|
checked = permissions.showBioToNonContacts,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onToggle = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(showBioToNonContacts = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ToggleItem(
|
||||||
|
title = "Show photo to non-contacts",
|
||||||
|
subtitle = "Users not in your contacts can see your photo",
|
||||||
|
checked = permissions.showProfilePhotoToNonContacts,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onToggle = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(showProfilePhotoToNonContacts = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ToggleItem(
|
||||||
|
title = "Show stories to non-contacts",
|
||||||
|
subtitle = "Users not in your contacts can see your stories",
|
||||||
|
checked = permissions.showStoriesToNonContacts,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onToggle = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(showStoriesToNonContacts = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SelectItem(
|
||||||
|
title = "Last seen visibility",
|
||||||
|
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
|
||||||
|
selected = permissions.lastSeenVisibility,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onSelect = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(lastSeenVisibility = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader("Messages")
|
||||||
|
|
||||||
|
ToggleItem(
|
||||||
|
title = "Allow messages from non-contacts",
|
||||||
|
subtitle = "Receive messages from users not in your contacts",
|
||||||
|
checked = permissions.allowMessagesFromNonContacts,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onToggle = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(allowMessagesFromNonContacts = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ToggleItem(
|
||||||
|
title = "Allow message forwarding",
|
||||||
|
subtitle = "Others can forward your messages",
|
||||||
|
checked = permissions.allowMessageForwarding,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onToggle = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(allowMessageForwarding = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ToggleItem(
|
||||||
|
title = "Force auto-delete in private",
|
||||||
|
subtitle = "Messages in private chats auto-delete",
|
||||||
|
checked = permissions.forceAutoDeleteMessagesInPrivate,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onToggle = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(forceAutoDeleteMessagesInPrivate = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionHeader("Groups & Invites")
|
||||||
|
|
||||||
|
ToggleItem(
|
||||||
|
title = "Allow server chats",
|
||||||
|
subtitle = "Participate in server-based chats",
|
||||||
|
checked = permissions.allowServerChats,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onToggle = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(allowServerChats = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SelectItem(
|
||||||
|
title = "Public invite",
|
||||||
|
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
|
||||||
|
selected = permissions.publicInvitePermission,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onSelect = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(publicInvitePermission = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SelectItem(
|
||||||
|
title = "Group invite",
|
||||||
|
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
|
||||||
|
selected = permissions.groupInvitePermission,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onSelect = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(groupInvitePermission = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SelectItem(
|
||||||
|
title = "Calls",
|
||||||
|
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
|
||||||
|
selected = permissions.callPermission,
|
||||||
|
enabled = !uiState.isSavingPermissions,
|
||||||
|
onSelect = { value ->
|
||||||
|
viewModel.updatePermission {
|
||||||
|
ProfilePermissionsRequestDto(callPermission = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(title: String) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleItem(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
checked: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onToggle,
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectItem(
|
||||||
|
title: String,
|
||||||
|
options: List<Pair<String, Int>>,
|
||||||
|
selected: Int,
|
||||||
|
enabled: Boolean,
|
||||||
|
onSelect: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
SingleChoiceSegmentedButtonRow {
|
||||||
|
options.forEachIndexed { index, (label, value) ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = selected == value,
|
||||||
|
onClick = { onSelect(value) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, options.size),
|
||||||
|
enabled = enabled
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
package org.yobble.messenger.presentation.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.yobble.messenger.data.remote.dto.UserSessionItemDto
|
||||||
|
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SessionsScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadSessions()
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is SettingsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
is SettingsEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Sessions", fontWeight = FontWeight.Bold) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
if (uiState.isLoadingSessions && uiState.sessions.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
val otherSessions = uiState.sessions.filter { !it.isCurrent }
|
||||||
|
if (otherSessions.size > 1) {
|
||||||
|
item {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = viewModel::revokeAllExceptCurrent,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Revoke all other sessions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentSession = uiState.sessions.find { it.isCurrent }
|
||||||
|
if (currentSession != null) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Current session",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SessionCard(session = currentSession, onRevoke = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherSessions.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Other sessions",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(otherSessions, key = { it.id }) { session ->
|
||||||
|
SessionCard(
|
||||||
|
session = session,
|
||||||
|
onRevoke = { viewModel.revokeSession(session.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SessionCard(
|
||||||
|
session: UserSessionItemDto,
|
||||||
|
onRevoke: (() -> Unit)?
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (session.isCurrent)
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PhoneAndroid,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = session.clientType.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
if (session.isCurrent) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Current",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.ipAddress.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "IP: ${session.ipAddress}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Last active: ${formatSessionDate(session.lastRefreshAt)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Created: ${formatSessionDate(session.createdAt)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
if (onRevoke != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = onRevoke,
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Revoke")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSessionDate(isoString: String): String {
|
||||||
|
return try {
|
||||||
|
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
|
||||||
|
val local = zonedUtc.withZoneSameInstant(java.time.ZoneId.systemDefault())
|
||||||
|
local.format(java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm"))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
isoString.substringBefore("T")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
package org.yobble.messenger.presentation.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Block
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.Shield
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onNavigateToPrivacy: () -> Unit,
|
||||||
|
onNavigateToSessions: () -> Unit,
|
||||||
|
onNavigateToChangePassword: () -> Unit,
|
||||||
|
onNavigateToBlacklist: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
bottomPadding: Dp = 0.dp
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(top = 8.dp, bottom = bottomPadding + 8.dp)
|
||||||
|
) {
|
||||||
|
SettingsMenuItem(
|
||||||
|
icon = Icons.Default.Shield,
|
||||||
|
title = "Privacy",
|
||||||
|
subtitle = "Visibility, messages, search",
|
||||||
|
onClick = onNavigateToPrivacy
|
||||||
|
)
|
||||||
|
SettingsMenuItem(
|
||||||
|
icon = Icons.Default.PhoneAndroid,
|
||||||
|
title = "Sessions",
|
||||||
|
subtitle = "Active sessions and devices",
|
||||||
|
onClick = onNavigateToSessions
|
||||||
|
)
|
||||||
|
SettingsMenuItem(
|
||||||
|
icon = Icons.Default.Lock,
|
||||||
|
title = "Change password",
|
||||||
|
subtitle = "Update your account password",
|
||||||
|
onClick = onNavigateToChangePassword
|
||||||
|
)
|
||||||
|
SettingsMenuItem(
|
||||||
|
icon = Icons.Default.Block,
|
||||||
|
title = "Blacklist",
|
||||||
|
subtitle = "Blocked users",
|
||||||
|
onClick = onNavigateToBlacklist
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onLogout,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Log out", modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsMenuItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,221 @@
|
|||||||
|
package org.yobble.messenger.presentation.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.MyProfilePermissionsDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfilePermissionsRequestDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.UserSessionItemDto
|
||||||
|
import org.yobble.messenger.domain.repository.AuthRepository
|
||||||
|
import org.yobble.messenger.domain.repository.ProfileRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class SettingsUiState(
|
||||||
|
val sessions: List<UserSessionItemDto> = emptyList(),
|
||||||
|
val isLoadingSessions: Boolean = false,
|
||||||
|
val permissions: MyProfilePermissionsDto? = null,
|
||||||
|
val isLoadingPermissions: Boolean = false,
|
||||||
|
val isSavingPermissions: Boolean = false,
|
||||||
|
val oldPassword: String = "",
|
||||||
|
val newPassword: String = "",
|
||||||
|
val confirmPassword: String = "",
|
||||||
|
val isChangingPassword: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class SettingsEvent {
|
||||||
|
data class ShowError(val message: String) : SettingsEvent()
|
||||||
|
data class ShowSuccess(val message: String) : SettingsEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val profileRepository: ProfileRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||||
|
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<SettingsEvent>()
|
||||||
|
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
fun loadSessions() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoadingSessions = true) }
|
||||||
|
when (val result = authRepository.getSessionsList()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
sessions = result.data.data.sessions,
|
||||||
|
isLoadingSessions = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoadingSessions = false) }
|
||||||
|
_events.emit(SettingsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to load sessions"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoadingSessions = false) }
|
||||||
|
_events.emit(SettingsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun revokeSession(sessionId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = authRepository.revokeSession(sessionId)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_events.emit(SettingsEvent.ShowSuccess("Session revoked"))
|
||||||
|
loadSessions()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_events.emit(SettingsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to revoke session"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(SettingsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun revokeAllExceptCurrent() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = authRepository.revokeAllExceptCurrent()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_events.emit(SettingsEvent.ShowSuccess("All other sessions revoked"))
|
||||||
|
loadSessions()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_events.emit(SettingsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to revoke sessions"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(SettingsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPermissions() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoadingPermissions = true) }
|
||||||
|
when (val result = profileRepository.getMyProfile()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
permissions = result.data.data.profilePermissions,
|
||||||
|
isLoadingPermissions = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isLoadingPermissions = false) }
|
||||||
|
_events.emit(SettingsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to load privacy settings"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoadingPermissions = false) }
|
||||||
|
_events.emit(SettingsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePermission(update: (MyProfilePermissionsDto) -> ProfilePermissionsRequestDto) {
|
||||||
|
val current = _uiState.value.permissions ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isSavingPermissions = true) }
|
||||||
|
val request = ProfileUpdateRequestDto(profilePermissions = update(current))
|
||||||
|
when (val result = profileRepository.editProfile(request)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(isSavingPermissions = false) }
|
||||||
|
loadPermissions()
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isSavingPermissions = false) }
|
||||||
|
_events.emit(SettingsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to update setting"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isSavingPermissions = false) }
|
||||||
|
_events.emit(SettingsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOldPasswordChange(value: String) {
|
||||||
|
_uiState.update { it.copy(oldPassword = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNewPasswordChange(value: String) {
|
||||||
|
_uiState.update { it.copy(newPassword = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConfirmPasswordChange(value: String) {
|
||||||
|
_uiState.update { it.copy(confirmPassword = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changePassword() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.isChangingPassword) return
|
||||||
|
if (state.newPassword != state.confirmPassword) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_events.emit(SettingsEvent.ShowError("Passwords don't match"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.newPassword.length < 8) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_events.emit(SettingsEvent.ShowError("Password must be at least 8 characters"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isChangingPassword = true) }
|
||||||
|
when (val result = authRepository.changePassword(state.oldPassword, state.newPassword)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isChangingPassword = false,
|
||||||
|
oldPassword = "",
|
||||||
|
newPassword = "",
|
||||||
|
confirmPassword = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_events.emit(SettingsEvent.ShowSuccess("Password changed successfully"))
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_uiState.update { it.copy(isChangingPassword = false) }
|
||||||
|
_events.emit(SettingsEvent.ShowError(
|
||||||
|
result.errors.firstOrNull()?.message ?: "Failed to change password"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isChangingPassword = false) }
|
||||||
|
_events.emit(SettingsEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/src/main/java/org/yobble/messenger/ui/theme/Color.kt
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package org.yobble.messenger.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Telegram-style primary palette
|
||||||
|
val TelegramBlue = Color(0xFF2AABEE)
|
||||||
|
val TelegramBlueDark = Color(0xFF0088CC)
|
||||||
|
val TelegramBlueLight = Color(0xFF6DD0FF)
|
||||||
|
|
||||||
|
// Light theme colors
|
||||||
|
val LightBackground = Color(0xFFF7F7F8)
|
||||||
|
val LightSurface = Color(0xFFFFFFFF)
|
||||||
|
val LightSurfaceVariant = Color(0xFFEFEFF4)
|
||||||
|
val LightOnBackground = Color(0xFF1C1C1E)
|
||||||
|
val LightOnSurface = Color(0xFF1C1C1E)
|
||||||
|
val LightOnSurfaceVariant = Color(0xFF8E8E93)
|
||||||
|
val LightOutline = Color(0xFFC7C7CC)
|
||||||
|
|
||||||
|
// Dark theme colors
|
||||||
|
val DarkBackground = Color(0xFF1C1C1E)
|
||||||
|
val DarkSurface = Color(0xFF2C2C2E)
|
||||||
|
val DarkSurfaceVariant = Color(0xFF3A3A3C)
|
||||||
|
val DarkOnBackground = Color(0xFFF2F2F7)
|
||||||
|
val DarkOnSurface = Color(0xFFF2F2F7)
|
||||||
|
val DarkOnSurfaceVariant = Color(0xFF8E8E93)
|
||||||
|
val DarkOutline = Color(0xFF48484A)
|
||||||
|
|
||||||
|
// Semantic colors
|
||||||
|
val ErrorRed = Color(0xFFFF3B30)
|
||||||
|
val SuccessGreen = Color(0xFF34C759)
|
||||||
|
val OnPrimary = Color(0xFFFFFFFF)
|
||||||
69
app/src/main/java/org/yobble/messenger/ui/theme/Theme.kt
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package org.yobble.messenger.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = TelegramBlue,
|
||||||
|
onPrimary = OnPrimary,
|
||||||
|
primaryContainer = TelegramBlueLight,
|
||||||
|
onPrimaryContainer = TelegramBlueDark,
|
||||||
|
secondary = TelegramBlueDark,
|
||||||
|
onSecondary = OnPrimary,
|
||||||
|
background = LightBackground,
|
||||||
|
onBackground = LightOnBackground,
|
||||||
|
surface = LightSurface,
|
||||||
|
onSurface = LightOnSurface,
|
||||||
|
surfaceVariant = LightSurfaceVariant,
|
||||||
|
onSurfaceVariant = LightOnSurfaceVariant,
|
||||||
|
outline = LightOutline,
|
||||||
|
error = ErrorRed,
|
||||||
|
onError = OnPrimary
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = TelegramBlue,
|
||||||
|
onPrimary = OnPrimary,
|
||||||
|
primaryContainer = TelegramBlueDark,
|
||||||
|
onPrimaryContainer = TelegramBlueLight,
|
||||||
|
secondary = TelegramBlueLight,
|
||||||
|
onSecondary = DarkOnBackground,
|
||||||
|
background = DarkBackground,
|
||||||
|
onBackground = DarkOnBackground,
|
||||||
|
surface = DarkSurface,
|
||||||
|
onSurface = DarkOnSurface,
|
||||||
|
surfaceVariant = DarkSurfaceVariant,
|
||||||
|
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||||
|
outline = DarkOutline,
|
||||||
|
error = ErrorRed,
|
||||||
|
onError = OnPrimary
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun YobbleTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = false,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
74
app/src/main/java/org/yobble/messenger/ui/theme/Type.kt
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package org.yobble.messenger.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Typography = Typography(
|
||||||
|
// Screen titles — "Yobble"
|
||||||
|
headlineLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 34.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
// Section headers — "Create your account"
|
||||||
|
headlineSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
lineHeight = 26.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
// TopAppBar titles
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
// Button text
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
// Secondary text buttons, links
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
// Body text
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
),
|
||||||
|
// Subtitles / descriptions
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.25.sp
|
||||||
|
),
|
||||||
|
// Small captions
|
||||||
|
bodySmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.4.sp
|
||||||
|
)
|
||||||
|
)
|
||||||
15
app/src/main/java/org/yobble/messenger/util/TimeUtils.kt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package org.yobble.messenger.util
|
||||||
|
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
fun formatUtcToLocalTime(isoString: String?): String {
|
||||||
|
if (isoString.isNullOrBlank()) return ""
|
||||||
|
return try {
|
||||||
|
val zonedUtc = ZonedDateTime.parse(isoString)
|
||||||
|
val local = zonedUtc.withZoneSameInstant(java.time.ZoneId.systemDefault())
|
||||||
|
local.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
isoString.substringAfter("T").take(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
android:height="108dp"
|
||||||
|
android:width="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
</vector>
|
||||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |