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 |