Compare commits
No commits in common. "main" and "master" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
@ -13,3 +13,4 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/.idea/*
|
||||
6
.idea/AndroidProjectSystem.xml
generated
6
.idea/AndroidProjectSystem.xml
generated
@ -1,6 +0,0 @@
|
||||
<?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
6
.idea/compiler.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
18
.idea/deploymentTargetSelector.xml
generated
18
.idea/deploymentTargetSelector.xml
generated
@ -1,18 +0,0 @@
|
||||
<?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-07T21:14:00.266022244Z">
|
||||
<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
13
.idea/deviceManager.xml
generated
@ -1,13 +0,0 @@
|
||||
<?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
19
.idea/gradle.xml
generated
@ -1,19 +0,0 @@
|
||||
<?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
10
.idea/migrations.xml
generated
@ -1,10 +0,0 @@
|
||||
<?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>
|
||||
13
.idea/misc.xml
generated
13
.idea/misc.xml
generated
@ -1,13 +0,0 @@
|
||||
<project version="4">
|
||||
<component name="ASMSmaliIdeaPluginConfiguration">
|
||||
<asm skipDebug="true" skipFrames="true" skipCode="false" expandFrames="false" />
|
||||
<groovy codeStyle="LEGACY" />
|
||||
</component>
|
||||
<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
17
.idea/runConfigurations.xml
generated
@ -1,17 +0,0 @@
|
||||
<?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
6
.idea/vcs.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@ -2,30 +2,28 @@ 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)
|
||||
alias(libs.plugins.kapt)
|
||||
alias(libs.plugins.hilt.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.yobble.messenger"
|
||||
compileSdk {
|
||||
version = release(36)
|
||||
namespace = "com.cardinalnsk.volnahub"
|
||||
compileSdk = 35
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.yobble.messenger"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
applicationId = "com.cardinalnsk.volnahub"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
buildConfigField("String", "USER_AGENT", "\"VolnaHub Android App\"")
|
||||
|
||||
buildConfigField("String", "BASE_URL", "\"https://dev.api.yobble.org/\"")
|
||||
buildConfigField("String", "USER_AGENT", "\"yobble android/${versionName}\"")
|
||||
buildConfigField("String", "SOCKET_PATH", "\"/socket.io/\"")
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@ -46,64 +44,38 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core
|
||||
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.gson.converter)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
implementation(libs.navigation.compose)
|
||||
implementation(libs.material.icons.extended)
|
||||
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.hilt.android)
|
||||
kapt(libs.hilt.compiler)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
// Emoji picker
|
||||
implementation(libs.androidx.emoji2.emojipicker)
|
||||
|
||||
// Security
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
// Test
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
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)
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package org.yobble.messenger
|
||||
package com.cardinalnsk.volnahub
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("ru.cardinalnsk.yobble", appContext.packageName)
|
||||
assertEquals("com.example.myapplication", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,9 @@
|
||||
<?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:name=".VolnaHubApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
@ -14,27 +11,19 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:theme="@style/Theme.Yobble">
|
||||
android:theme="@style/Theme.MyApplication"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:windowSoftInputMode="adjustNothing"
|
||||
android:theme="@style/Theme.Yobble">
|
||||
android:theme="@style/Theme.MyApplication">
|
||||
<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>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB |
32
app/src/main/java/com/cardinalnsk/volnahub/MainActivity.kt
Normal file
32
app/src/main/java/com/cardinalnsk/volnahub/MainActivity.kt
Normal file
@ -0,0 +1,32 @@
|
||||
package com.cardinalnsk.volnahub
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.*
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.cardinalnsk.volnahub.navigation.AppNavHost
|
||||
import com.cardinalnsk.volnahub.ui.fragments.AppTopBar
|
||||
import com.cardinalnsk.volnahub.ui.theme.MyApplicationTheme
|
||||
import com.cardinalnsk.volnahub.viewmodel.ThemeViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val themeViewModel: ThemeViewModel = hiltViewModel()
|
||||
val isDark = themeViewModel.isDarkTheme.value
|
||||
|
||||
MyApplicationTheme(darkTheme = isDark) {
|
||||
Scaffold(
|
||||
topBar = { AppTopBar(themeViewModel = themeViewModel) }
|
||||
) { innerPadding ->
|
||||
AppNavHost(innerPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.cardinalnsk.volnahub
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class VolnaHubApplication: Application()
|
||||
44
app/src/main/java/com/cardinalnsk/volnahub/di/AppModule.kt
Normal file
44
app/src/main/java/com/cardinalnsk/volnahub/di/AppModule.kt
Normal file
@ -0,0 +1,44 @@
|
||||
package com.cardinalnsk.volnahub.di
|
||||
|
||||
import com.cardinalnsk.volnahub.network.AuthApi
|
||||
import com.cardinalnsk.volnahub.network.HeaderInterceptor
|
||||
import com.cardinalnsk.volnahub.repository.AuthRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(HeaderInterceptor())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
|
||||
Retrofit.Builder()
|
||||
.baseUrl("https://api.volnahub.ru")
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.client(okHttpClient)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi =
|
||||
retrofit.create(AuthApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRepository(authApi: AuthApi): AuthRepository =
|
||||
AuthRepository(authApi)
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.cardinalnsk.volnahub.model
|
||||
|
||||
data class LoginRequest(
|
||||
val login: String,
|
||||
val password: String
|
||||
)
|
||||
@ -0,0 +1,10 @@
|
||||
package com.cardinalnsk.volnahub.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class LoginResponse(
|
||||
val status: String,
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("refresh_token") val refreshToken: String,
|
||||
@SerializedName("token_type") val tokenType: String
|
||||
)
|
||||
@ -0,0 +1,7 @@
|
||||
package com.cardinalnsk.volnahub.model
|
||||
|
||||
data class RegisterRequest(
|
||||
val login: String,
|
||||
val password: String,
|
||||
val invite: String
|
||||
)
|
||||
@ -0,0 +1,6 @@
|
||||
package com.cardinalnsk.volnahub.model
|
||||
|
||||
data class RegisterResponse(
|
||||
val status: String,
|
||||
val message: String
|
||||
)
|
||||
@ -0,0 +1,152 @@
|
||||
package com.cardinalnsk.volnahub.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.*
|
||||
import androidx.navigation.compose.*
|
||||
import com.cardinalnsk.volnahub.ui.fragments.AppTopBar
|
||||
import com.cardinalnsk.volnahub.ui.screen.AuthScreen
|
||||
import com.cardinalnsk.volnahub.ui.screen.RegisterScreen
|
||||
import com.cardinalnsk.volnahub.ui.screen.ContactsScreen
|
||||
import com.cardinalnsk.volnahub.ui.screen.ChatsScreen
|
||||
import com.cardinalnsk.volnahub.ui.screen.SettingsScreen
|
||||
import com.cardinalnsk.volnahub.viewmodel.ThemeViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppNavHost(
|
||||
innerPadding: PaddingValues,
|
||||
themeViewModel: ThemeViewModel = hiltViewModel()
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Login.route,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(Screen.Login.route) {
|
||||
AuthScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate(Screen.Home.route) {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onRegisterClick = { navController.navigate(Screen.Register.route) }
|
||||
)
|
||||
}
|
||||
composable(Screen.Register.route) {
|
||||
RegisterScreen(
|
||||
onRegisterSuccess = {
|
||||
navController.navigate(Screen.Home.route) {
|
||||
popUpTo(Screen.Register.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onBackToLogin = {
|
||||
navController.navigate(Screen.Login.route) {
|
||||
popUpTo(Screen.Register.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Screen.Home.route) {
|
||||
HomeNavHost(themeViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeNavHost(themeViewModel: ThemeViewModel, rootNavController: NavHostController) {
|
||||
val navController = rememberNavController()
|
||||
Scaffold(
|
||||
topBar = { AppTopBar(themeViewModel) },
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
Screen.bottomNavScreens.forEach { screen ->
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Screen.icon(screen), contentDescription = null) },
|
||||
label = { Text(Screen.label(screen)) },
|
||||
selected = currentRoute == screen.route,
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
launchSingleTop = true
|
||||
popUpTo(navController.graph.startDestinationId) {
|
||||
saveState = true
|
||||
}
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Contacts.route,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(Screen.Contacts.route) { ContactsScreen() }
|
||||
composable(Screen.Chats.route) { ChatsScreen() }
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
onLogout = {
|
||||
rootNavController.navigate(Screen.Login.route) {
|
||||
popUpTo(Screen.Home.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//package com.cardinalnsk.volnahub.navigation
|
||||
//
|
||||
//import androidx.compose.runtime.Composable
|
||||
//import androidx.navigation.compose.*
|
||||
//import com.cardinalnsk.volnahub.ui.screen.*
|
||||
//
|
||||
//
|
||||
//@Composable
|
||||
//fun AppNavHost() {
|
||||
// val navController = rememberNavController()
|
||||
//
|
||||
// NavHost(navController = navController, startDestination = Screen.Login.route) {
|
||||
// composable(Screen.Login.route) {
|
||||
// AuthScreen(
|
||||
// onLoginSuccess = {
|
||||
// navController.navigate(Screen.Home.route) {
|
||||
// popUpTo(Screen.Login.route) { inclusive = true }
|
||||
// }
|
||||
// },
|
||||
// onRegisterClick = {
|
||||
// navController.navigate(Screen.Register.route)
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// composable(Screen.Register.route) {
|
||||
// RegisterScreen(
|
||||
// onRegisterSuccess = {
|
||||
// navController.navigate(Screen.Home.route) {
|
||||
// popUpTo(Screen.Register.route) { inclusive = true }
|
||||
// }
|
||||
// },
|
||||
// onBackToLogin = {
|
||||
// navController.popBackStack()
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// composable(Screen.Home.route) {
|
||||
// HomeScreen() // Pass it here
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
@ -0,0 +1,37 @@
|
||||
package com.cardinalnsk.volnahub.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
object Login : Screen("auth")
|
||||
object Register : Screen("register")
|
||||
object Home : Screen("home")
|
||||
|
||||
// Internal tabs
|
||||
object Contacts : Screen("contacts")
|
||||
object Chats : Screen("chats")
|
||||
object Settings : Screen("settings")
|
||||
|
||||
companion object {
|
||||
val bottomNavScreens = listOf(
|
||||
Contacts, Chats, Settings
|
||||
)
|
||||
|
||||
fun icon(screen: Screen): ImageVector = when (screen) {
|
||||
Contacts -> Icons.Filled.Person
|
||||
Chats -> Icons.AutoMirrored.Filled.Chat
|
||||
Settings -> Icons.Filled.Settings
|
||||
else -> Icons.Filled.Person
|
||||
}
|
||||
|
||||
fun label(screen: Screen): String = when (screen) {
|
||||
Contacts -> "Контакты"
|
||||
Chats -> "Чаты"
|
||||
Settings -> "Настройки"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.cardinalnsk.volnahub.network
|
||||
|
||||
import com.cardinalnsk.volnahub.model.LoginRequest
|
||||
import com.cardinalnsk.volnahub.model.LoginResponse
|
||||
import com.cardinalnsk.volnahub.model.RegisterRequest
|
||||
import com.cardinalnsk.volnahub.model.RegisterResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
interface AuthApi {
|
||||
@POST("/auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@POST("/auth/register")
|
||||
suspend fun register(@Body request: RegisterRequest): Response<RegisterResponse>
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package com.cardinalnsk.volnahub.network
|
||||
|
||||
import com.cardinalnsk.volnahub.BuildConfig
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class HeaderInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request().newBuilder()
|
||||
.addHeader("User-Agent", BuildConfig.USER_AGENT)
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.cardinalnsk.volnahub.repository
|
||||
|
||||
import com.cardinalnsk.volnahub.model.LoginRequest
|
||||
import com.cardinalnsk.volnahub.model.LoginResponse
|
||||
import com.cardinalnsk.volnahub.model.RegisterRequest
|
||||
import com.cardinalnsk.volnahub.model.RegisterResponse
|
||||
import com.cardinalnsk.volnahub.network.*
|
||||
import jakarta.inject.Inject
|
||||
|
||||
class AuthRepository @Inject constructor(
|
||||
private val api: AuthApi
|
||||
) {
|
||||
|
||||
|
||||
suspend fun login(login: String, password: String): Result<LoginResponse> {
|
||||
return try {
|
||||
val response = api.login(LoginRequest(login, password))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("Error: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(
|
||||
login: String,
|
||||
password: String,
|
||||
invite: String
|
||||
): Result<RegisterResponse> {
|
||||
return try {
|
||||
val response = api.register(RegisterRequest(login, password, invite))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception(response.errorBody()?.string() ?: "Register error"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package com.cardinalnsk.volnahub.ui.fragments
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.cardinalnsk.volnahub.viewmodel.ThemeViewModel
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppTopBar(
|
||||
themeViewModel: ThemeViewModel = hiltViewModel()
|
||||
) {
|
||||
val isDark = themeViewModel.isDarkTheme.value
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "VolnaHub",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { themeViewModel.toggleTheme() }) {
|
||||
Icon(
|
||||
imageVector = if (isDark) Icons.Filled.LightMode else Icons.Filled.DarkMode,
|
||||
contentDescription = if (isDark) "Светлая тема" else "Тёмная тема"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
package com.cardinalnsk.volnahub.ui.screen
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.cardinalnsk.volnahub.R
|
||||
import com.cardinalnsk.volnahub.viewmodel.AuthViewModel
|
||||
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
onRegisterClick: () -> Unit,
|
||||
) {
|
||||
val authViewModel: AuthViewModel = hiltViewModel()
|
||||
|
||||
val login = authViewModel.login
|
||||
val password = authViewModel.password
|
||||
val isLoading = authViewModel.isLoading
|
||||
val error = authViewModel.errorMessage
|
||||
|
||||
var showPasswordField by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
LaunchedEffect(login, password) {
|
||||
showPasswordField = (password.isNotBlank() || login.length >= 3)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp)
|
||||
.imePadding()
|
||||
.animateContentSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
// Spacer(modifier = Modifier.height(64.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.login_form_title),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = login,
|
||||
onValueChange = { authViewModel.login = it },
|
||||
label = { Text(stringResource(R.string.login_form_login_placeholder)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showPasswordField,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { authViewModel.password = it },
|
||||
label = { Text(stringResource(R.string.login_form_password_placeholder)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
authViewModel.login { onLoginSuccess() }
|
||||
},
|
||||
enabled = login.length >= 3 && password.length >= 3 && !isLoading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.login_button))
|
||||
}
|
||||
|
||||
if (error.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextButton(onClick = onRegisterClick) {
|
||||
Text(stringResource(R.string.login_form_register_link))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.cardinalnsk.volnahub.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun ChatsScreen() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Чаты", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.cardinalnsk.volnahub.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun ContactsScreen() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Контакты", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.cardinalnsk.volnahub.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.cardinalnsk.volnahub.viewmodel.ThemeViewModel
|
||||
|
||||
|
||||
@Composable
|
||||
fun HomeScreen() {
|
||||
val themeViewModel: ThemeViewModel = hiltViewModel()
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Добро пожаловать!", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,222 @@
|
||||
package com.cardinalnsk.volnahub.ui.screen
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.cardinalnsk.volnahub.R
|
||||
import com.cardinalnsk.volnahub.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
onRegisterSuccess: () -> Unit,
|
||||
onBackToLogin: () -> Unit,
|
||||
) {
|
||||
val authViewModel: AuthViewModel = hiltViewModel()
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center)
|
||||
.verticalScroll(scrollState)
|
||||
.animateContentSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.registration_form_title),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = authViewModel.login,
|
||||
onValueChange = { authViewModel.login = it },
|
||||
label = { Text(stringResource(R.string.registration_form_login_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = authViewModel.password,
|
||||
onValueChange = { authViewModel.password = it },
|
||||
label = { Text(stringResource(R.string.registration_form_password_placeholder)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = authViewModel.confirmPassword,
|
||||
onValueChange = { authViewModel.confirmPassword = it },
|
||||
label = { Text(stringResource(R.string.registration_form_confirm_password_placeholder)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = authViewModel.invite,
|
||||
onValueChange = { authViewModel.invite = it },
|
||||
label = { Text(stringResource(R.string.registration_form_inviteCode_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { authViewModel.register { onRegisterSuccess() } },
|
||||
enabled = authViewModel.login.length >= 3 &&
|
||||
authViewModel.password.length >= 3 &&
|
||||
authViewModel.invite.isNotBlank() &&
|
||||
!authViewModel.isLoading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.registration_form_submit_button))
|
||||
}
|
||||
|
||||
if (authViewModel.errorMessage.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(authViewModel.errorMessage, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextButton(onClick = onBackToLogin) {
|
||||
Text(stringResource(R.string.registration_form_link_to_login))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//@Composable
|
||||
//fun RegisterScreen(
|
||||
// onRegisterSuccess: () -> Unit,
|
||||
// onBackToLogin: () -> Unit,
|
||||
//) {
|
||||
// val authViewModel: AuthViewModel = hiltViewModel()
|
||||
//
|
||||
// val login = authViewModel.login
|
||||
// val password = authViewModel.password
|
||||
// val confirmPassword = authViewModel.confirmPassword
|
||||
// val invite = authViewModel.invite
|
||||
// val isLoading = authViewModel.isLoading
|
||||
// val error = authViewModel.errorMessage
|
||||
//
|
||||
// val scrollState = rememberScrollState()
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxSize()
|
||||
// .verticalScroll(scrollState)
|
||||
// .padding(24.dp)
|
||||
// .imePadding()
|
||||
// .animateContentSize(),
|
||||
// verticalArrangement = Arrangement.Center,
|
||||
// horizontalAlignment = Alignment.CenterHorizontally
|
||||
// ) {
|
||||
// Text(
|
||||
// stringResource(R.string.registration_form_title),
|
||||
// style = MaterialTheme.typography.headlineSmall
|
||||
// )
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
//
|
||||
// OutlinedTextField(
|
||||
// value = login,
|
||||
// onValueChange = { authViewModel.login = it },
|
||||
// label = { Text(stringResource(R.string.registration_form_login_placeholder)) },
|
||||
// singleLine = true,
|
||||
// modifier = Modifier.fillMaxWidth()
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
//
|
||||
// OutlinedTextField(
|
||||
// value = password,
|
||||
// onValueChange = { authViewModel.password = it },
|
||||
// label = { Text(stringResource(R.string.registration_form_password_placeholder)) },
|
||||
// singleLine = true,
|
||||
// visualTransformation = PasswordVisualTransformation(),
|
||||
// keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
// modifier = Modifier.fillMaxWidth()
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
//
|
||||
// OutlinedTextField(
|
||||
// value = confirmPassword,
|
||||
// onValueChange = { authViewModel.confirmPassword = it },
|
||||
// label = { Text(stringResource(R.string.registration_form_confirm_password_placeholder)) },
|
||||
// singleLine = true,
|
||||
// visualTransformation = PasswordVisualTransformation(),
|
||||
// keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
// modifier = Modifier.fillMaxWidth()
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
//
|
||||
// OutlinedTextField(
|
||||
// value = invite,
|
||||
// onValueChange = { authViewModel.invite = it },
|
||||
// label = { Text(stringResource(R.string.registration_form_inviteCode_placeholder)) },
|
||||
// singleLine = true,
|
||||
// modifier = Modifier.fillMaxWidth()
|
||||
// )
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
//
|
||||
// Button(
|
||||
// onClick = {
|
||||
// authViewModel.register {
|
||||
// onRegisterSuccess()
|
||||
// }
|
||||
// },
|
||||
// enabled = login.length >= 3 && password.length >= 3 && invite.isNotBlank() && !isLoading,
|
||||
// modifier = Modifier.fillMaxWidth()
|
||||
// ) {
|
||||
// Text(stringResource(R.string.registration_form_submit_button))
|
||||
// }
|
||||
//
|
||||
// if (authViewModel.passwordError != null) {
|
||||
// Text(text = authViewModel.passwordError!!, color = Color.Red)
|
||||
// }
|
||||
//
|
||||
// if (error.isNotEmpty()) {
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
// Text(error, color = MaterialTheme.colorScheme.error)
|
||||
// }
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
//
|
||||
// TextButton(onClick = onBackToLogin) {
|
||||
// Text(stringResource(R.string.registration_form_link_to_login))
|
||||
// }
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
//
|
||||
// }
|
||||
//}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.cardinalnsk.volnahub.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.*
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(onLogout: () -> Unit) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Button(onClick = onLogout) {
|
||||
Text("Выйти из аккаунта")
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/com/cardinalnsk/volnahub/ui/theme/Color.kt
Normal file
13
app/src/main/java/com/cardinalnsk/volnahub/ui/theme/Color.kt
Normal file
@ -0,0 +1,13 @@
|
||||
package com.cardinalnsk.volnahub.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Light Theme
|
||||
val BlueLightPrimary = Color(0xFF1976D2) // синий
|
||||
val BlueLightSecondary = Color(0xFF90CAF9) // светло-синий
|
||||
val BlueLightTertiary = Color(0xFF64B5F6)
|
||||
|
||||
// Dark Theme
|
||||
val BlueDarkPrimary = Color(0xFF0D47A1)
|
||||
val BlueDarkSecondary = Color(0xFF42A5F5)
|
||||
val BlueDarkTertiary = Color(0xFF2196F3)
|
||||
46
app/src/main/java/com/cardinalnsk/volnahub/ui/theme/Theme.kt
Normal file
46
app/src/main/java/com/cardinalnsk/volnahub/ui/theme/Theme.kt
Normal file
@ -0,0 +1,46 @@
|
||||
package com.cardinalnsk.volnahub.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 DarkColorScheme = darkColorScheme(
|
||||
primary = BlueDarkPrimary,
|
||||
secondary = BlueDarkSecondary,
|
||||
tertiary = BlueDarkTertiary
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = BlueLightPrimary,
|
||||
secondary = BlueLightSecondary,
|
||||
tertiary = BlueLightTertiary
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MyApplicationTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
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
|
||||
)
|
||||
}
|
||||
17
app/src/main/java/com/cardinalnsk/volnahub/ui/theme/Type.kt
Normal file
17
app/src/main/java/com/cardinalnsk/volnahub/ui/theme/Type.kt
Normal file
@ -0,0 +1,17 @@
|
||||
package com.cardinalnsk.volnahub.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(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@ -0,0 +1,83 @@
|
||||
package com.cardinalnsk.volnahub.viewmodel
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.*
|
||||
import com.cardinalnsk.volnahub.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import jakarta.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(private val repository: AuthRepository) : ViewModel() {
|
||||
var login by mutableStateOf("")
|
||||
var password by mutableStateOf("")
|
||||
var confirmPassword by mutableStateOf("")
|
||||
var invite by mutableStateOf("")
|
||||
var isLoading by mutableStateOf(false)
|
||||
var errorMessage by mutableStateOf("")
|
||||
var token by mutableStateOf("")
|
||||
var passwordError by mutableStateOf<String?>(null)
|
||||
|
||||
|
||||
fun login(onSuccess: (String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
isLoading = true
|
||||
errorMessage = ""
|
||||
val result = repository.login(login, password)
|
||||
isLoading = false
|
||||
|
||||
result.onSuccess {
|
||||
token = it.accessToken
|
||||
onSuccess(token)
|
||||
}.onFailure {
|
||||
errorMessage = it.message ?: "Login error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(onSuccess: (String) -> Unit) {
|
||||
if (!validatePassword()) {
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
isLoading = true
|
||||
errorMessage = ""
|
||||
val result = repository.register(login, password, invite)
|
||||
isLoading = false
|
||||
|
||||
result.onSuccess {
|
||||
onSuccess("")
|
||||
}.onFailure {
|
||||
errorMessage = it.message ?: "Registration error"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun validatePassword(): Boolean {
|
||||
return when {
|
||||
password.length < 6 -> {
|
||||
passwordError = "Пароль должен быть не менее 6 символов"
|
||||
false
|
||||
}
|
||||
|
||||
!password.any { it.isDigit() } -> {
|
||||
passwordError = "Пароль должен содержать хотя бы одну цифру"
|
||||
false
|
||||
}
|
||||
|
||||
password != confirmPassword -> {
|
||||
passwordError = "Пароли не совпадают"
|
||||
false
|
||||
}
|
||||
|
||||
else -> {
|
||||
passwordError = null
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.cardinalnsk.volnahub.viewmodel
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import jakarta.inject.Inject
|
||||
import androidx.compose.runtime.State
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class ThemeViewModel @Inject constructor(
|
||||
// Можно сюда репозиторий с DataStore, если хочешь сохранять тему навсегда
|
||||
) : ViewModel() {
|
||||
|
||||
// Простое хранение (или через State/DataStore)
|
||||
private val _isDarkTheme = mutableStateOf(false)
|
||||
val isDarkTheme: State<Boolean> = _isDarkTheme
|
||||
|
||||
fun toggleTheme() {
|
||||
_isDarkTheme.value = !_isDarkTheme.value
|
||||
}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
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.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
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)
|
||||
// Required for smooth keyboard animations (official docs)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
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
|
||||
)
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
package org.yobble.messenger
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import okhttp3.OkHttpClient
|
||||
import org.yobble.messenger.data.local.CacheManager
|
||||
import org.yobble.messenger.data.local.SessionManager
|
||||
import org.yobble.messenger.di.CoilClient
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class YobbleApp : Application(), ImageLoaderFactory {
|
||||
|
||||
@Inject
|
||||
@CoilClient
|
||||
lateinit var okHttpClient: OkHttpClient
|
||||
|
||||
@Inject
|
||||
lateinit var sessionManager: SessionManager
|
||||
|
||||
@Inject
|
||||
lateinit var cacheManager: CacheManager
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return buildImageLoader()
|
||||
}
|
||||
|
||||
private var currentImageLoaderUserId: String? = null
|
||||
|
||||
fun resetImageLoaderIfNeeded() {
|
||||
val userId = sessionManager.userId
|
||||
if (userId != currentImageLoaderUserId) {
|
||||
currentImageLoaderUserId = userId
|
||||
Coil.setImageLoader(buildImageLoader())
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildImageLoader(): ImageLoader {
|
||||
val builder = ImageLoader.Builder(this)
|
||||
.okHttpClient(okHttpClient)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
|
||||
val userId = sessionManager.userId
|
||||
if (userId != null) {
|
||||
val coilCacheDir = File(cacheManager.getUserCacheDir(userId), "coil")
|
||||
builder.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(coilCacheDir)
|
||||
.maxSizePercent(0.05)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
return builder.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"
|
||||
}
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
package org.yobble.messenger.data.local
|
||||
|
||||
import android.content.Context
|
||||
import coil.imageLoader
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class CacheStats(
|
||||
val imagesBytes: Long = 0L,
|
||||
val networkBytes: Long = 0L,
|
||||
val otherBytes: Long = 0L
|
||||
) {
|
||||
val totalBytes: Long get() = imagesBytes + networkBytes + otherBytes
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class CacheManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val sessionManager: SessionManager
|
||||
) {
|
||||
|
||||
fun getCacheStats(): CacheStats {
|
||||
val userId = sessionManager.userId ?: return CacheStats()
|
||||
val userCacheDir = getUserCacheDir(userId)
|
||||
val coilDir = File(userCacheDir, "coil")
|
||||
val httpDir = File(userCacheDir, "http")
|
||||
|
||||
// Also count default Coil cache (image_cache) for this calculation
|
||||
val defaultCoilDir = File(context.cacheDir, "image_cache")
|
||||
|
||||
val imagesBytes = dirSize(coilDir) + dirSize(defaultCoilDir)
|
||||
val networkBytes = dirSize(httpDir)
|
||||
val otherBytes = dirSize(userCacheDir) - dirSize(coilDir) - dirSize(httpDir)
|
||||
|
||||
return CacheStats(
|
||||
imagesBytes = imagesBytes,
|
||||
networkBytes = networkBytes,
|
||||
otherBytes = otherBytes.coerceAtLeast(0L)
|
||||
)
|
||||
}
|
||||
|
||||
fun clearImageCache() {
|
||||
val userId = sessionManager.userId ?: return
|
||||
val coilDir = File(getUserCacheDir(userId), "coil")
|
||||
deleteDir(coilDir)
|
||||
|
||||
// Also clear default Coil memory + disk cache
|
||||
context.imageLoader.memoryCache?.clear()
|
||||
context.imageLoader.diskCache?.clear()
|
||||
|
||||
val defaultCoilDir = File(context.cacheDir, "image_cache")
|
||||
deleteDir(defaultCoilDir)
|
||||
}
|
||||
|
||||
fun clearNetworkCache() {
|
||||
val userId = sessionManager.userId ?: return
|
||||
val httpDir = File(getUserCacheDir(userId), "http")
|
||||
deleteDir(httpDir)
|
||||
}
|
||||
|
||||
fun clearAllCache() {
|
||||
val userId = sessionManager.userId ?: return
|
||||
deleteDir(getUserCacheDir(userId))
|
||||
|
||||
context.imageLoader.memoryCache?.clear()
|
||||
context.imageLoader.diskCache?.clear()
|
||||
|
||||
val defaultCoilDir = File(context.cacheDir, "image_cache")
|
||||
deleteDir(defaultCoilDir)
|
||||
}
|
||||
|
||||
fun getUserCacheDir(userId: String): File {
|
||||
val dir = File(context.cacheDir, "user_$userId")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun dirSize(dir: File): Long {
|
||||
if (!dir.exists()) return 0L
|
||||
return dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
}
|
||||
|
||||
private fun deleteDir(dir: File) {
|
||||
if (dir.exists()) dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
package org.yobble.messenger.data.local
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ChatCacheManager @Inject constructor(
|
||||
private val cacheManager: CacheManager,
|
||||
private val sessionManager: SessionManager,
|
||||
private val json: Json
|
||||
) {
|
||||
|
||||
private fun chatsDir(): File? {
|
||||
val userId = sessionManager.userId ?: return null
|
||||
val dir = File(cacheManager.getUserCacheDir(userId), "chats")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
return dir
|
||||
}
|
||||
|
||||
suspend fun saveChatList(chats: List<PrivateChatListItemDto>) = withContext(Dispatchers.IO) {
|
||||
val dir = chatsDir() ?: return@withContext
|
||||
val file = File(dir, "chat_list.json")
|
||||
file.writeText(json.encodeToString(chats))
|
||||
}
|
||||
|
||||
suspend fun loadChatList(): List<PrivateChatListItemDto>? = withContext(Dispatchers.IO) {
|
||||
val dir = chatsDir() ?: return@withContext null
|
||||
val file = File(dir, "chat_list.json")
|
||||
if (!file.exists()) return@withContext null
|
||||
try {
|
||||
json.decodeFromString<List<PrivateChatListItemDto>>(file.readText())
|
||||
} catch (e: Exception) {
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveChatMessages(chatId: String, messages: List<MessageItemDto>) = withContext(Dispatchers.IO) {
|
||||
val dir = chatsDir() ?: return@withContext
|
||||
val messagesDir = File(dir, "messages")
|
||||
if (!messagesDir.exists()) messagesDir.mkdirs()
|
||||
val file = File(messagesDir, "${chatId}.json")
|
||||
file.writeText(json.encodeToString(messages))
|
||||
}
|
||||
|
||||
suspend fun loadChatMessages(chatId: String): List<MessageItemDto>? = withContext(Dispatchers.IO) {
|
||||
val dir = chatsDir() ?: return@withContext null
|
||||
val file = File(dir, "messages/${chatId}.json")
|
||||
if (!file.exists()) return@withContext null
|
||||
try {
|
||||
json.decodeFromString<List<MessageItemDto>>(file.readText())
|
||||
} catch (e: Exception) {
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,216 +0,0 @@
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import androidx.core.content.edit
|
||||
|
||||
data class AccountInfo(
|
||||
val accountId: String,
|
||||
val userId: String,
|
||||
val login: String? = null,
|
||||
val displayName: String? = null,
|
||||
val avatarFileId: String? = null,
|
||||
val bio: String? = null
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class SessionManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val accountsPrefs: SharedPreferences = context.getSharedPreferences(
|
||||
"yobble_accounts", Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
private val _activeAccountId = MutableStateFlow(accountsPrefs.getString(KEY_ACTIVE_ACCOUNT, null))
|
||||
val activeAccountId: StateFlow<String?> = _activeAccountId.asStateFlow()
|
||||
|
||||
private var pendingNewAccount = false
|
||||
|
||||
private val sessionCaches = mutableMapOf<String, SharedPreferences>()
|
||||
|
||||
private val localPrefs: SharedPreferences = context.getSharedPreferences(
|
||||
"yobble_local_prefs", Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
private fun getSessionPrefs(accountId: String): SharedPreferences {
|
||||
return sessionCaches.getOrPut(accountId) {
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"yobble_session_$accountId",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val activePrefs: SharedPreferences?
|
||||
get() {
|
||||
val id = _activeAccountId.value ?: return null
|
||||
return getSessionPrefs(id)
|
||||
}
|
||||
|
||||
var accessToken: String?
|
||||
get() = activePrefs?.getString(KEY_ACCESS_TOKEN, null)
|
||||
set(value) = activePrefs?.edit { putString(KEY_ACCESS_TOKEN, value) } ?: Unit
|
||||
|
||||
var refreshToken: String?
|
||||
get() = activePrefs?.getString(KEY_REFRESH_TOKEN, null)
|
||||
set(value) = activePrefs?.edit { putString(KEY_REFRESH_TOKEN, value) } ?: Unit
|
||||
|
||||
var userId: String?
|
||||
get() = activePrefs?.getString(KEY_USER_ID, null)
|
||||
set(value) = activePrefs?.edit { putString(KEY_USER_ID, value) } ?: Unit
|
||||
|
||||
val isLoggedIn: Boolean
|
||||
get() = accessToken != null
|
||||
|
||||
fun saveSession(accessToken: String, refreshToken: String, userId: String) {
|
||||
val accountId = if (pendingNewAccount) userId else (_activeAccountId.value ?: userId)
|
||||
pendingNewAccount = false
|
||||
setActiveAccount(accountId)
|
||||
val prefs = getSessionPrefs(accountId)
|
||||
prefs.edit {
|
||||
putString(KEY_ACCESS_TOKEN, accessToken)
|
||||
putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
putString(KEY_USER_ID, userId)
|
||||
}
|
||||
addAccountToList(accountId, userId)
|
||||
}
|
||||
|
||||
fun clearSession() {
|
||||
val accountId = _activeAccountId.value ?: return
|
||||
removeAccount(accountId)
|
||||
}
|
||||
|
||||
fun getAccounts(): List<AccountInfo> {
|
||||
val ids = accountsPrefs.getStringSet(KEY_ACCOUNT_IDS, emptySet()) ?: emptySet()
|
||||
return ids.mapNotNull { id ->
|
||||
try {
|
||||
val prefs = getSessionPrefs(id)
|
||||
val uid = prefs.getString(KEY_USER_ID, null) ?: return@mapNotNull null
|
||||
AccountInfo(
|
||||
accountId = id,
|
||||
userId = uid,
|
||||
login = prefs.getString(KEY_LOGIN, null),
|
||||
displayName = prefs.getString(KEY_DISPLAY_NAME, null),
|
||||
avatarFileId = prefs.getString(KEY_AVATAR_FILE_ID, null),
|
||||
bio = prefs.getString(KEY_BIO, null)
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchAccount(accountId: String) {
|
||||
val ids = accountsPrefs.getStringSet(KEY_ACCOUNT_IDS, emptySet()) ?: emptySet()
|
||||
if (accountId !in ids) return
|
||||
setActiveAccount(accountId)
|
||||
}
|
||||
|
||||
fun prepareNewAccountLogin() {
|
||||
pendingNewAccount = true
|
||||
}
|
||||
|
||||
fun removeAccount(accountId: String) {
|
||||
val ids = (accountsPrefs.getStringSet(KEY_ACCOUNT_IDS, emptySet()) ?: emptySet()).toMutableSet()
|
||||
ids.remove(accountId)
|
||||
accountsPrefs.edit {
|
||||
putStringSet(KEY_ACCOUNT_IDS, ids)
|
||||
}
|
||||
|
||||
try {
|
||||
getSessionPrefs(accountId).edit { clear() }
|
||||
} catch (_: Exception) {}
|
||||
sessionCaches.remove(accountId)
|
||||
|
||||
if (_activeAccountId.value == accountId) {
|
||||
val nextId = ids.firstOrNull()
|
||||
if (nextId != null) {
|
||||
setActiveAccount(nextId)
|
||||
} else {
|
||||
_activeAccountId.value = null
|
||||
accountsPrefs.edit { remove(KEY_ACTIVE_ACCOUNT) }
|
||||
localPrefs.edit { clear() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAccountMeta(
|
||||
login: String? = null,
|
||||
displayName: String? = null,
|
||||
avatarFileId: String? = null,
|
||||
bio: String? = null
|
||||
) {
|
||||
val prefs = activePrefs ?: return
|
||||
prefs.edit {
|
||||
if (login != null) putString(KEY_LOGIN, login)
|
||||
if (displayName != null) putString(KEY_DISPLAY_NAME, displayName)
|
||||
if (avatarFileId != null) putString(KEY_AVATAR_FILE_ID, avatarFileId)
|
||||
// bio can be empty string to clear it
|
||||
if (bio != null) putString(KEY_BIO, bio)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setActiveAccount(accountId: String) {
|
||||
_activeAccountId.value = accountId
|
||||
accountsPrefs.edit { putString(KEY_ACTIVE_ACCOUNT, accountId) }
|
||||
}
|
||||
|
||||
private fun addAccountToList(accountId: String, userId: String) {
|
||||
val ids = (accountsPrefs.getStringSet(KEY_ACCOUNT_IDS, emptySet()) ?: emptySet()).toMutableSet()
|
||||
ids.add(accountId)
|
||||
accountsPrefs.edit {
|
||||
putStringSet(KEY_ACCOUNT_IDS, ids)
|
||||
}
|
||||
}
|
||||
|
||||
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) ?: ""
|
||||
}
|
||||
|
||||
var navStyle: String
|
||||
get() = localPrefs.getString("nav_style", "bottom_bar") ?: "bottom_bar"
|
||||
set(value) = localPrefs.edit { putString("nav_style", value) }
|
||||
|
||||
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"
|
||||
private const val KEY_LOGIN = "login"
|
||||
private const val KEY_DISPLAY_NAME = "display_name"
|
||||
private const val KEY_AVATAR_FILE_ID = "avatar_file_id"
|
||||
private const val KEY_BIO = "bio"
|
||||
private const val KEY_ACTIVE_ACCOUNT = "active_account_id"
|
||||
private const val KEY_ACCOUNT_IDS = "account_ids"
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package org.yobble.messenger.data.remote.api
|
||||
|
||||
import org.yobble.messenger.data.remote.dto.AchievementListResponseDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface AchievementApi {
|
||||
|
||||
@GET("v1/achievement/my")
|
||||
suspend fun getMyAchievements(): Response<AchievementListResponseDto>
|
||||
|
||||
@GET("v1/achievement/user/{user_id}")
|
||||
suspend fun getUserAchievements(
|
||||
@Path("user_id") userId: String
|
||||
): Response<AchievementListResponseDto>
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
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,
|
||||
@Query("is_forward") isForward: Boolean = false
|
||||
): Response<PrivateChatHistoryResponseDto>
|
||||
|
||||
@POST("v1/chat/private/create")
|
||||
suspend fun createChat(
|
||||
@Query("target_user_id") targetUserId: String
|
||||
): Response<PrivateChatCreateResponseDto>
|
||||
|
||||
@POST("v1/chat/private/message/send")
|
||||
suspend fun sendMessage(
|
||||
@Body request: PrivateMessageSendRequestDto
|
||||
): Response<PrivateMessageSendResponseDto>
|
||||
|
||||
@POST("v1/chat/private/message/delete")
|
||||
suspend fun deleteMessage(
|
||||
@Body request: PrivateMessageDeleteRequestDto
|
||||
): Response<PrivateMessageDeleteResponseDto>
|
||||
|
||||
@PUT("v1/chat/private/message/edit")
|
||||
suspend fun editMessage(
|
||||
@Body request: PrivateMessageEditRequestDto
|
||||
): Response<PrivateMessageEditResponseDto>
|
||||
|
||||
@POST("v1/chat/private/message/mark-read")
|
||||
suspend fun markRead(
|
||||
@Body request: PrivateChatMarkReadRequestDto
|
||||
): Response<PrivateChatMarkReadResponseDto>
|
||||
|
||||
@HTTP(method = "DELETE", path = "v1/chat/private/delete", hasBody = true)
|
||||
suspend fun deleteChat(
|
||||
@Body request: PrivateChatDeleteRequestDto
|
||||
): Response<BaseResponseDto>
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
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/avatar/upload")
|
||||
suspend fun uploadAvatar(
|
||||
@Part file: MultipartBody.Part
|
||||
): Response<UploadAvatarResponseDto>
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
package org.yobble.messenger.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AchievementListResponseDto(
|
||||
@SerialName("status") val status: String,
|
||||
@SerialName("data") val data: AchievementListDataDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AchievementListDataDto(
|
||||
@SerialName("items") val items: Map<String, List<AchievementItemDto>> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AchievementItemDto(
|
||||
@SerialName("achievement_id") val achievementId: Int,
|
||||
@SerialName("code") val code: String,
|
||||
@SerialName("name") val name: String,
|
||||
@SerialName("description") val description: String? = null,
|
||||
@SerialName("icon") val icon: String? = null,
|
||||
@SerialName("category") val category: String? = null,
|
||||
@SerialName("badge_type") val badgeType: String,
|
||||
@SerialName("is_completed") val isCompleted: Boolean,
|
||||
@SerialName("unlocked_at") val unlockedAt: String? = null,
|
||||
@SerialName("progress") val progress: Int? = null,
|
||||
@SerialName("required_progress") val requiredProgress: Int? = null
|
||||
)
|
||||
@ -1,40 +0,0 @@
|
||||
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
|
||||
)
|
||||
@ -1,64 +0,0 @@
|
||||
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
|
||||
)
|
||||
@ -1,173 +0,0 @@
|
||||
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
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PrivateMessageDeleteRequestDto(
|
||||
@SerialName("chat_id") val chatId: String,
|
||||
@SerialName("message_id") val messageId: Int,
|
||||
@SerialName("delete_for_all") val deleteForAll: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PrivateMessageEditRequestDto(
|
||||
@SerialName("chat_id") val chatId: String,
|
||||
@SerialName("message_id") val messageId: Int,
|
||||
@SerialName("content") val content: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PrivateChatMarkReadRequestDto(
|
||||
@SerialName("chat_id") val chatId: String,
|
||||
@SerialName("message_id") val messageId: Int? = null,
|
||||
@SerialName("mark_all") val markAll: 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_companion_ids") val chatCompanionIds: List<String>? = null,
|
||||
@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("is_edited") val isEdited: Boolean = false,
|
||||
@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,
|
||||
@SerialName("forward_chat_data") val forwardChatData: JsonObject? = 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
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PrivateMessageDeleteResponseDto(
|
||||
@SerialName("status") val status: String,
|
||||
@SerialName("data") val data: PrivateMessageDeleteDataDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PrivateMessageDeleteDataDto(
|
||||
@SerialName("message_id") val messageId: Int,
|
||||
@SerialName("chat_id") val chatId: String,
|
||||
@SerialName("deleted_at") val deletedAt: String,
|
||||
@SerialName("delete_for_all") val deleteForAll: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PrivateMessageEditResponseDto(
|
||||
@SerialName("status") val status: String,
|
||||
@SerialName("data") val data: PrivateMessageEditDataDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PrivateMessageEditDataDto(
|
||||
@SerialName("message_id") val messageId: Int,
|
||||
@SerialName("chat_id") val chatId: String,
|
||||
@SerialName("content") val content: String,
|
||||
@SerialName("updated_at") val updatedAt: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PrivateChatMarkReadResponseDto(
|
||||
@SerialName("status") val status: String,
|
||||
@SerialName("data") val data: PrivateChatMarkReadDataDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PrivateChatMarkReadDataDto(
|
||||
@SerialName("chat_id") val chatId: String,
|
||||
@SerialName("marked_count") val markedCount: Int
|
||||
)
|
||||
|
||||
// endregion
|
||||
@ -1,118 +0,0 @@
|
||||
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,
|
||||
@SerialName("avatars") val avatars: AvatarsBlockDto? = 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,
|
||||
@SerialName("avatars") val avatars: AvatarsBlockDto? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BlacklistCreateResponseDto(
|
||||
@SerialName("status") val status: String,
|
||||
@SerialName("data") val data: BlacklistInfoDto
|
||||
)
|
||||
|
||||
// endregion
|
||||
@ -1,22 +0,0 @@
|
||||
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
|
||||
)
|
||||
@ -1,161 +0,0 @@
|
||||
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("public_invite_permission") val publicInvitePermission: Int? = null,
|
||||
@SerialName("group_invite_permission") val groupInvitePermission: Int? = null,
|
||||
@SerialName("call_permission") val callPermission: 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("verification") val verification: VerificationItemDto? = null,
|
||||
@SerialName("partner_verifications") val partnerVerifications: List<VerificationItemDto> = emptyList(),
|
||||
@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("verification") val verification: VerificationItemDto? = null,
|
||||
@SerialName("partner_verifications") val partnerVerifications: List<VerificationItemDto> = emptyList(),
|
||||
@SerialName("is_system") val isSystem: Boolean? = false,
|
||||
@SerialName("rating") val rating: RatingDataDto,
|
||||
@SerialName("last_seen_at") val lastSeenAt: String? = 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 VerificationItemDto(
|
||||
@SerialName("type") val type: String,
|
||||
@SerialName("reason") val reason: String? = null,
|
||||
@SerialName("issuer_id") val issuerId: String,
|
||||
@SerialName("issuer_name") val issuerName: String? = null,
|
||||
@SerialName("issued_at") val issuedAt: String? = null,
|
||||
@SerialName("expires_at") val expiresAt: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyProfilePermissionsDto(
|
||||
@SerialName("is_searchable") val isSearchable: Boolean = true,
|
||||
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean = true,
|
||||
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean = true,
|
||||
@SerialName("show_profile_photo_to_non_contacts") val showProfilePhotoToNonContacts: Boolean = true,
|
||||
@SerialName("last_seen_visibility") val lastSeenVisibility: Int = 0,
|
||||
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean = true,
|
||||
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean = true,
|
||||
@SerialName("public_invite_permission") val publicInvitePermission: Int = 0,
|
||||
@SerialName("group_invite_permission") val groupInvitePermission: Int = 0,
|
||||
@SerialName("call_permission") val callPermission: Int = 0,
|
||||
@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 = true,
|
||||
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean = true
|
||||
)
|
||||
|
||||
// endregion
|
||||
@ -1,27 +0,0 @@
|
||||
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
|
||||
)
|
||||
@ -1,15 +0,0 @@
|
||||
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
|
||||
)
|
||||
@ -1,83 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
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 isRefreshRequest = request.url.encodedPath.contains("token/refresh")
|
||||
|
||||
val newRequest = request.newBuilder()
|
||||
.header("User-Agent", BuildConfig.USER_AGENT)
|
||||
.apply {
|
||||
if (token != null && !isRefreshRequest) {
|
||||
header("Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
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? {
|
||||
// Don't retry if we already tried refreshing
|
||||
if (responseCount(response) >= 2) {
|
||||
sessionManager.clearSession()
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var count = 1
|
||||
var prior = response.priorResponse
|
||||
while (prior != null) {
|
||||
count++
|
||||
prior = prior.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
@ -1,235 +0,0 @@
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.yobble.messenger.BuildConfig
|
||||
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
|
||||
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 val authApiProvider: Provider<AuthApi>
|
||||
) {
|
||||
private var socket: Socket? = null
|
||||
private var isRefreshing = false
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
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)
|
||||
}
|
||||
val errorStr = error.toString()
|
||||
if (errorStr.contains("IP address mismatch") || errorStr.contains("authentication_failed")) {
|
||||
refreshTokenAndReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshTokenAndReconnect() {
|
||||
synchronized(this) {
|
||||
if (isRefreshing) return
|
||||
isRefreshing = true
|
||||
// Stop auto-reconnect to prevent repeated errors
|
||||
socket?.disconnect()
|
||||
socket?.off()
|
||||
socket = null
|
||||
}
|
||||
Log.i(TAG, "Refreshing token due to IP mismatch...")
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val accessToken = sessionManager.accessToken
|
||||
val refreshToken = sessionManager.refreshToken
|
||||
if (accessToken == null || refreshToken == null) {
|
||||
Log.e(TAG, "No tokens for refresh")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val response = authApiProvider.get().refreshToken(
|
||||
TokenRefreshRequestDto(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken
|
||||
)
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val newTokens = response.body()?.data
|
||||
if (newTokens != null) {
|
||||
sessionManager.saveSession(
|
||||
accessToken = newTokens.accessToken,
|
||||
refreshToken = newTokens.refreshToken,
|
||||
userId = sessionManager.userId ?: ""
|
||||
)
|
||||
Log.i(TAG, "Token refreshed, reconnecting socket...")
|
||||
connect()
|
||||
} else {
|
||||
Log.e(TAG, "Token refresh: empty body")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Token refresh failed: ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Token refresh exception", e)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
Log.d(TAG, "Disconnecting socket")
|
||||
socket?.disconnect()
|
||||
socket?.off()
|
||||
socket = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SocketManager"
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
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.AchievementApi
|
||||
import org.yobble.messenger.data.remote.dto.AchievementListResponseDto
|
||||
import org.yobble.messenger.data.remote.safeApiCall
|
||||
import org.yobble.messenger.domain.repository.AchievementRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class AchievementRepositoryImpl @Inject constructor(
|
||||
private val achievementApi: AchievementApi,
|
||||
private val json: Json
|
||||
) : AchievementRepository {
|
||||
|
||||
override suspend fun getMyAchievements(): NetworkResult<AchievementListResponseDto> {
|
||||
return safeApiCall(json) { achievementApi.getMyAchievements() }
|
||||
}
|
||||
|
||||
override suspend fun getUserAchievements(userId: String): NetworkResult<AchievementListResponseDto> {
|
||||
return safeApiCall(json) { achievementApi.getUserAchievements(userId) }
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
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) {
|
||||
val data = result.data.data
|
||||
sessionManager.saveSession(
|
||||
accessToken = data.accessToken,
|
||||
refreshToken = data.refreshToken,
|
||||
userId = data.userId
|
||||
)
|
||||
sessionManager.updateAccountMeta(login = login, displayName = null)
|
||||
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) {
|
||||
val data = result.data.data
|
||||
sessionManager.saveSession(
|
||||
accessToken = data.accessToken,
|
||||
refreshToken = data.refreshToken,
|
||||
userId = data.userId
|
||||
)
|
||||
sessionManager.updateAccountMeta(login = login, displayName = null)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
package org.yobble.messenger.data.repository
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.yobble.messenger.data.local.ChatCacheManager
|
||||
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,
|
||||
private val chatCacheManager: ChatCacheManager
|
||||
) : ChatRepository {
|
||||
|
||||
private val chatDataMap = mutableMapOf<String, ProfileByUserIdDataDto>()
|
||||
|
||||
fun getChatData(chatId: String): ProfileByUserIdDataDto? = chatDataMap[chatId]
|
||||
|
||||
override suspend fun getChatList(offset: Int, limit: Int): NetworkResult<PrivateChatListResponseDto> {
|
||||
val result = safeApiCall(json) { chatApi.getChatList(offset, limit) }
|
||||
if (result is NetworkResult.Success) {
|
||||
result.data.data.items.forEach { chat ->
|
||||
chat.chatData?.let { chatDataMap[chat.chatId] = it }
|
||||
}
|
||||
if (offset == 0) {
|
||||
chatCacheManager.saveChatList(result.data.data.items)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun getChatHistory(chatId: String, beforeMessageId: Int?, limit: Int): NetworkResult<PrivateChatHistoryResponseDto> {
|
||||
val result = safeApiCall(json) { chatApi.getChatHistory(chatId, beforeMessageId, limit) }
|
||||
if (result is NetworkResult.Success && beforeMessageId == null) {
|
||||
chatCacheManager.saveChatMessages(chatId, result.data.data.items)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getCachedChatList(): List<PrivateChatListItemDto>? {
|
||||
return chatCacheManager.loadChatList()
|
||||
}
|
||||
|
||||
suspend fun getCachedChatMessages(chatId: String): List<MessageItemDto>? {
|
||||
return chatCacheManager.loadChatMessages(chatId)
|
||||
}
|
||||
|
||||
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 deleteMessage(chatId: String, messageId: Int, deleteForAll: Boolean): NetworkResult<PrivateMessageDeleteResponseDto> {
|
||||
return safeApiCall(json) {
|
||||
chatApi.deleteMessage(PrivateMessageDeleteRequestDto(chatId = chatId, messageId = messageId, deleteForAll = deleteForAll))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun editMessage(chatId: String, messageId: Int, content: String): NetworkResult<PrivateMessageEditResponseDto> {
|
||||
return safeApiCall(json) {
|
||||
chatApi.editMessage(PrivateMessageEditRequestDto(chatId = chatId, messageId = messageId, content = content))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markRead(chatId: String, messageId: Int?, markAll: Boolean): NetworkResult<PrivateChatMarkReadResponseDto> {
|
||||
return safeApiCall(json) {
|
||||
chatApi.markRead(PrivateChatMarkReadRequestDto(chatId = chatId, messageId = messageId, markAll = markAll))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteChat(chatId: String): NetworkResult<BaseResponseDto> {
|
||||
return safeApiCall(json) {
|
||||
chatApi.deleteChat(PrivateChatDeleteRequestDto(chatId = chatId))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
@ -1,144 +0,0 @@
|
||||
package org.yobble.messenger.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Qualifier
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Cache
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.yobble.messenger.BuildConfig
|
||||
import org.yobble.messenger.data.local.CacheManager
|
||||
import org.yobble.messenger.data.local.SessionManager
|
||||
import org.yobble.messenger.data.remote.api.AchievementApi
|
||||
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 java.io.File
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class CoilClient
|
||||
|
||||
@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,
|
||||
cacheManager: CacheManager,
|
||||
sessionManager: SessionManager
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
)
|
||||
.authenticator(tokenAuthenticator)
|
||||
|
||||
val userId = sessionManager.userId
|
||||
if (userId != null) {
|
||||
val httpCacheDir = File(cacheManager.getUserCacheDir(userId), "http")
|
||||
builder.cache(Cache(httpCacheDir, 20L * 1024 * 1024)) // 20 MB
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@CoilClient
|
||||
fun provideCoilOkHttpClient(
|
||||
authInterceptor: AuthInterceptor,
|
||||
tokenAuthenticator: TokenAuthenticator
|
||||
): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.HEADERS
|
||||
}
|
||||
)
|
||||
.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)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAchievementApi(retrofit: Retrofit): AchievementApi {
|
||||
return retrofit.create(AchievementApi::class.java)
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
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.AchievementRepositoryImpl
|
||||
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.AchievementRepository
|
||||
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
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAchievementRepository(impl: AchievementRepositoryImpl): AchievementRepository
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
package org.yobble.messenger.domain.repository
|
||||
|
||||
import org.yobble.messenger.data.remote.NetworkResult
|
||||
import org.yobble.messenger.data.remote.dto.AchievementListResponseDto
|
||||
|
||||
interface AchievementRepository {
|
||||
suspend fun getMyAchievements(): NetworkResult<AchievementListResponseDto>
|
||||
suspend fun getUserAchievements(userId: String): NetworkResult<AchievementListResponseDto>
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
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 deleteMessage(chatId: String, messageId: Int, deleteForAll: Boolean = false): NetworkResult<PrivateMessageDeleteResponseDto>
|
||||
suspend fun editMessage(chatId: String, messageId: Int, content: String): NetworkResult<PrivateMessageEditResponseDto>
|
||||
suspend fun markRead(chatId: String, messageId: Int? = null, markAll: Boolean = false): NetworkResult<PrivateChatMarkReadResponseDto>
|
||||
suspend fun deleteChat(chatId: String): NetworkResult<BaseResponseDto>
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@ -1,183 +0,0 @@
|
||||
package org.yobble.messenger.presentation.accounts
|
||||
|
||||
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.shape.CircleShape
|
||||
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.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
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.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.local.AccountInfo
|
||||
import org.yobble.messenger.presentation.common.InitialsAvatar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountSwitcherScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onAccountSwitched: () -> Unit,
|
||||
onAddAccount: () -> Unit,
|
||||
viewModel: AccountSwitcherViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(uiState.switchedAccount) {
|
||||
if (uiState.switchedAccount) {
|
||||
onAccountSwitched()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Accounts", fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
items(uiState.accounts, key = { it.accountId }) { account ->
|
||||
AccountItem(
|
||||
account = account,
|
||||
isActive = account.accountId == uiState.activeAccountId,
|
||||
onClick = { viewModel.switchTo(account.accountId) },
|
||||
onRemove = { viewModel.removeAccount(account.accountId) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = {
|
||||
viewModel.prepareNewAccountLogin()
|
||||
onAddAccount()
|
||||
})
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
"Add account",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountItem(
|
||||
account: AccountInfo,
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val displayName = account.displayName ?: account.login ?: "Account"
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.background(
|
||||
if (isActive) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
InitialsAvatar(
|
||||
displayName = displayName,
|
||||
size = 44.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 (account.login != null) {
|
||||
Text(
|
||||
text = "@${account.login}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Active",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 72.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
package org.yobble.messenger.presentation.accounts
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.yobble.messenger.data.local.AccountInfo
|
||||
import org.yobble.messenger.data.local.SessionManager
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AccountSwitcherUiState(
|
||||
val accounts: List<AccountInfo> = emptyList(),
|
||||
val activeAccountId: String? = null,
|
||||
val switchedAccount: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AccountSwitcherViewModel @Inject constructor(
|
||||
private val sessionManager: SessionManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AccountSwitcherUiState())
|
||||
val uiState: StateFlow<AccountSwitcherUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
private fun loadAccounts() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
accounts = sessionManager.getAccounts(),
|
||||
activeAccountId = sessionManager.activeAccountId.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun switchTo(accountId: String) {
|
||||
if (accountId == _uiState.value.activeAccountId) return
|
||||
sessionManager.switchAccount(accountId)
|
||||
_uiState.update { it.copy(switchedAccount = true) }
|
||||
}
|
||||
|
||||
fun removeAccount(accountId: String) {
|
||||
sessionManager.removeAccount(accountId)
|
||||
loadAccounts()
|
||||
if (sessionManager.activeAccountId.value == null) {
|
||||
_uiState.update { it.copy(switchedAccount = true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareNewAccountLogin() {
|
||||
sessionManager.prepareNewAccountLogin()
|
||||
}
|
||||
}
|
||||
@ -1,150 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,226 +0,0 @@
|
||||
package org.yobble.messenger.presentation.auth.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.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.draw.clip
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.compose.ui.unit.sp
|
||||
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))
|
||||
|
||||
Text(
|
||||
text = "Yobble",
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
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(48.dp))
|
||||
|
||||
TextField(
|
||||
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(14.dp),
|
||||
colors = authFieldColors()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
TextField(
|
||||
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 = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = authFieldColors()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||
Button(
|
||||
onClick = viewModel::login,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (!uiState.isLoading)
|
||||
Brush.linearGradient(listOf(primary, primaryContainer))
|
||||
else
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
primary.copy(alpha = 0.5f),
|
||||
primaryContainer.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Log In",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.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,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun authFieldColors() = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
@ -1,121 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,273 +0,0 @@
|
||||
package org.yobble.messenger.presentation.auth.register
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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("Create Account", fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter your details to get started",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
TextField(
|
||||
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(14.dp),
|
||||
colors = authFieldColors()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
TextField(
|
||||
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 = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = authFieldColors()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
TextField(
|
||||
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 = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = authFieldColors()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
TextField(
|
||||
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(14.dp),
|
||||
colors = authFieldColors()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||
Button(
|
||||
onClick = viewModel::register,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (!uiState.isLoading)
|
||||
Brush.linearGradient(listOf(primary, primaryContainer))
|
||||
else
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
primary.copy(alpha = 0.5f),
|
||||
primaryContainer.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Create Account",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun authFieldColors() = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
@ -1,113 +0,0 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,290 +0,0 @@
|
||||
package org.yobble.messenger.presentation.chat
|
||||
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.emoji2.emojipicker.EmojiPickerView
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.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.presentation.chat.components.DeleteMessageDialog
|
||||
import org.yobble.messenger.presentation.chat.components.EditMessageDialog
|
||||
import org.yobble.messenger.presentation.chat.components.MessageActionsSheet
|
||||
import org.yobble.messenger.presentation.chat.components.MessageBubble
|
||||
import org.yobble.messenger.presentation.chat.components.MessageInputBar
|
||||
import org.yobble.messenger.presentation.common.UserAvatar
|
||||
import org.yobble.messenger.util.formatLastSeen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToProfile: (userId: String) -> Unit = {},
|
||||
viewModel: ChatViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var selectedMessage by remember { mutableStateOf<MessageItemDto?>(null) }
|
||||
var editingMessage by remember { mutableStateOf<MessageItemDto?>(null) }
|
||||
var deletingMessage by remember { mutableStateOf<MessageItemDto?>(null) }
|
||||
var deleteForAll by remember { mutableStateOf(false) }
|
||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.scrollToMessageId) {
|
||||
val targetId = uiState.scrollToMessageId ?: return@LaunchedEffect
|
||||
val targetInt = targetId.toIntOrNull() ?: return@LaunchedEffect
|
||||
val index = uiState.messages.asReversed().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)
|
||||
is ChatEvent.ScrollToBottom -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val messageCount = uiState.messages.size
|
||||
LaunchedEffect(messageCount) {
|
||||
if (messageCount > 0 && listState.firstVisibleItemIndex < 3) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
val showScrollToBottom by remember {
|
||||
derivedStateOf { listState.firstVisibleItemIndex > 15 }
|
||||
}
|
||||
|
||||
val hasMore = uiState.hasMore
|
||||
val isLoading = uiState.isLoading
|
||||
val shouldLoadMore by remember(hasMore, isLoading) {
|
||||
derivedStateOf {
|
||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
val totalItems = listState.layoutInfo.totalItemsCount
|
||||
lastVisible >= totalItems - 3 && hasMore && !isLoading
|
||||
}
|
||||
}
|
||||
LaunchedEffect(shouldLoadMore) {
|
||||
if (shouldLoadMore) viewModel.loadMore()
|
||||
}
|
||||
|
||||
// Layout: Column with statusBarsPadding on top, imePadding on input at bottom
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
) {
|
||||
// Top bar — fixed, not affected by keyboard
|
||||
ChatTopBar(uiState, onNavigateBack, onNavigateToProfile)
|
||||
|
||||
// Messages — takes remaining space, shrinks when keyboard opens
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
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,
|
||||
onLongClick = {
|
||||
if (keyboardController != null) {
|
||||
keyboardController.hide()
|
||||
coroutineScope.launch {
|
||||
kotlinx.coroutines.delay(250)
|
||||
selectedMessage = message
|
||||
}
|
||||
} else {
|
||||
selectedMessage = message
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (uiState.isLoading && uiState.messages.isNotEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showScrollToBottom) {
|
||||
SmallFloatingActionButton(
|
||||
onClick = { coroutineScope.launch { listState.animateScrollToItem(0) } },
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
|
||||
) {
|
||||
Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Scroll to bottom")
|
||||
}
|
||||
}
|
||||
|
||||
SnackbarHost(snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
|
||||
// Input bar
|
||||
if (uiState.canSendMessage) {
|
||||
MessageInputBar(
|
||||
text = uiState.messageText,
|
||||
onTextChange = viewModel::onMessageTextChange,
|
||||
onSend = viewModel::sendMessage,
|
||||
isSending = uiState.isSending,
|
||||
showEmojiPicker = showEmojiPicker,
|
||||
focusRequester = inputFocusRequester,
|
||||
onToggleEmoji = {
|
||||
if (showEmojiPicker) {
|
||||
showEmojiPicker = false
|
||||
inputFocusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
} else {
|
||||
keyboardController?.hide()
|
||||
showEmojiPicker = true
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "You can't send messages to this user",
|
||||
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface)
|
||||
.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
// Emoji picker panel
|
||||
if (showEmojiPicker) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
EmojiPickerView(context).apply {
|
||||
emojiGridColumns = 8
|
||||
setOnEmojiPickedListener { emoji ->
|
||||
val current = viewModel.uiState.value.messageText
|
||||
viewModel.onMessageTextChange(current + emoji.emoji)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(280.dp)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
MessageActionsSheet(selectedMessage, uiState.currentUserId, clipboardManager,
|
||||
onDismiss = { selectedMessage = null },
|
||||
onEdit = { editingMessage = it; viewModel.onMessageTextChange(it.content ?: ""); selectedMessage = null },
|
||||
onDelete = { deletingMessage = it; deleteForAll = false; selectedMessage = null }
|
||||
)
|
||||
DeleteMessageDialog(deletingMessage, uiState.currentUserId, deleteForAll,
|
||||
onDeleteForAllChange = { deleteForAll = it },
|
||||
onConfirm = { viewModel.deleteMessage(it.messageId, deleteForAll); deletingMessage = null },
|
||||
onDismiss = { deletingMessage = null }
|
||||
)
|
||||
EditMessageDialog(editingMessage, uiState.messageText, viewModel::onMessageTextChange,
|
||||
onConfirm = { viewModel.editMessage(it.messageId, uiState.messageText.trim()); editingMessage = null; viewModel.onMessageTextChange("") },
|
||||
onDismiss = { editingMessage = null; viewModel.onMessageTextChange("") }
|
||||
)
|
||||
}
|
||||
|
||||
// region TopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChatTopBar(uiState: ChatUiState, onNavigateBack: () -> Unit, onNavigateToProfile: (String) -> Unit) {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable(enabled = uiState.otherUserId != null) {
|
||||
uiState.otherUserId?.let { onNavigateToProfile(it) }
|
||||
}
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(uiState.chatTitle, fontWeight = FontWeight.Bold, fontSize = 17.sp, maxLines = 1)
|
||||
if (uiState.isVerified) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(Icons.Default.Verified, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
val lastSeenText = formatLastSeen(uiState.otherLastSeen)
|
||||
if (lastSeenText.isNotEmpty()) {
|
||||
Text(lastSeenText, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
if (uiState.otherUserId != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
UserAvatar(uiState.otherUserId, uiState.otherAvatarFileId, uiState.chatTitle, 32.dp, 13.sp)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
@ -1,325 +0,0 @@
|
||||
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.dto.ProfileByUserIdDataDto
|
||||
import org.yobble.messenger.data.remote.socket.SocketEvent
|
||||
import org.yobble.messenger.data.remote.socket.SocketManager
|
||||
import org.yobble.messenger.data.repository.ChatRepositoryImpl
|
||||
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 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 otherAvatarFileId: String? = null,
|
||||
val otherLastSeen: String? = null,
|
||||
val scrollToMessageId: String? = null
|
||||
)
|
||||
|
||||
sealed class ChatEvent {
|
||||
data class ShowError(val message: String) : ChatEvent()
|
||||
data object ScrollToBottom : 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)
|
||||
private val otherUserFromChatList: ProfileByUserIdDataDto? =
|
||||
(chatRepository as? ChatRepositoryImpl)?.getChatData(chatId)
|
||||
|
||||
init {
|
||||
loadCachedThenNetwork()
|
||||
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) {
|
||||
try {
|
||||
val message = json.decodeFromString<MessageItemDto>(payload.toString())
|
||||
val current = _uiState.value.messages
|
||||
if (current.none { it.messageId == message.messageId }) {
|
||||
val updated = current + message
|
||||
_uiState.update { it.copy(messages = updated) }
|
||||
_events.emit(ChatEvent.ScrollToBottom)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("ChatViewModel", "Failed to parse socket message, reloading", e)
|
||||
loadMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
is SocketEvent.Connected -> loadMessages()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCachedThenNetwork() {
|
||||
val chatId = _uiState.value.chatId
|
||||
if (chatId.isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
// Show cached messages immediately
|
||||
val repo = chatRepository as? ChatRepositoryImpl
|
||||
val cached = repo?.getCachedChatMessages(chatId)
|
||||
if (!cached.isNullOrEmpty()) {
|
||||
val items = cached.reversed()
|
||||
applyMessages(items, hasMore = true, fromCache = true)
|
||||
}
|
||||
|
||||
// Then fetch from network
|
||||
fetchMessagesFromNetwork(chatId)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMessages() {
|
||||
val chatId = _uiState.value.chatId
|
||||
if (chatId.isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
fetchMessagesFromNetwork(chatId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMessagesFromNetwork(chatId: String) {
|
||||
when (val result = chatRepository.getChatHistory(chatId)) {
|
||||
is NetworkResult.Success -> {
|
||||
val items = result.data.data.items.reversed()
|
||||
applyMessages(items, hasMore = result.data.data.hasMore, fromCache = false)
|
||||
// Auto mark-read
|
||||
markAllRead()
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(ChatEvent.ShowError(
|
||||
result.errors.firstOrNull()?.message ?: "Failed to load messages"
|
||||
))
|
||||
}
|
||||
is NetworkResult.Exception -> {
|
||||
// Offline — keep cached data visible
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
if (_uiState.value.messages.isEmpty()) {
|
||||
_events.emit(ChatEvent.ShowError("Connection error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyMessages(items: List<MessageItemDto>, hasMore: Boolean, fromCache: Boolean) {
|
||||
val otherMessage = items.firstOrNull { it.senderId != _uiState.value.currentUserId }
|
||||
val otherUser = otherMessage?.senderData
|
||||
|
||||
// Fall back to chat list data if no other user message found
|
||||
val cachedChatData = if (otherUser == null) otherUserFromChatList else null
|
||||
|
||||
val title = otherUser?.customName
|
||||
?: otherUser?.fullName
|
||||
?: otherUser?.login
|
||||
?: cachedChatData?.customName
|
||||
?: cachedChatData?.fullName
|
||||
?: cachedChatData?.login
|
||||
?: _uiState.value.chatTitle
|
||||
|
||||
val otherUserId = otherMessage?.senderId ?: cachedChatData?.userId ?: _uiState.value.otherUserId
|
||||
val scrollTarget = if (_uiState.value.messages.isEmpty() && !fromCache) savedMessageId else null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
messages = items,
|
||||
chatTitle = title,
|
||||
otherUserId = otherUserId,
|
||||
otherAvatarFileId = otherUser?.avatars?.current?.fileId ?: cachedChatData?.avatars?.current?.fileId ?: it.otherAvatarFileId,
|
||||
otherLastSeen = otherUser?.lastSeenAt ?: cachedChatData?.lastSeenAt ?: it.otherLastSeen,
|
||||
isVerified = otherUser?.verification != null || cachedChatData?.verification != null,
|
||||
canSendMessage = otherUser?.permissions?.youCanSendMessage ?: cachedChatData?.permissions?.youCanSendMessage ?: true,
|
||||
hasMore = hasMore,
|
||||
isLoading = if (fromCache) true else false,
|
||||
scrollToMessageId = scrollTarget
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun markAllRead() {
|
||||
val chatId = _uiState.value.chatId
|
||||
if (chatId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
chatRepository.markRead(chatId, markAll = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessage(messageId: Int, deleteForAll: Boolean = false) {
|
||||
val chatId = _uiState.value.chatId
|
||||
viewModelScope.launch {
|
||||
when (chatRepository.deleteMessage(chatId, messageId, deleteForAll)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(messages = state.messages.filter { it.messageId != messageId })
|
||||
}
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_events.emit(ChatEvent.ShowError("Failed to delete message"))
|
||||
}
|
||||
is NetworkResult.Exception -> {
|
||||
_events.emit(ChatEvent.ShowError("Connection error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun editMessage(messageId: Int, newContent: String) {
|
||||
val chatId = _uiState.value.chatId
|
||||
if (newContent.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.editMessage(chatId, messageId, newContent)) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(messages = state.messages.map {
|
||||
if (it.messageId == messageId)
|
||||
it.copy(content = result.data.data.content, isEdited = true, updatedAt = result.data.data.updatedAt)
|
||||
else it
|
||||
})
|
||||
}
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
_events.emit(ChatEvent.ShowError("Failed to edit message"))
|
||||
}
|
||||
is NetworkResult.Exception -> {
|
||||
_events.emit(ChatEvent.ShowError("Connection error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
_events.emit(ChatEvent.ScrollToBottom)
|
||||
}
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
package org.yobble.messenger.presentation.chat.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun MessageActionsSheet(msg: MessageItemDto?, currentUserId: String, clipboard: ClipboardManager, onDismiss: () -> Unit, onEdit: (MessageItemDto) -> Unit, onDelete: (MessageItemDto) -> Unit) {
|
||||
if (msg == null) return
|
||||
val isOwn = msg.senderId == currentUserId
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Column(Modifier.padding(bottom = 32.dp)) {
|
||||
if (!msg.content.isNullOrBlank()) {
|
||||
ActionRow(Icons.Default.ContentCopy, "Copy") { clipboard.setText(AnnotatedString(msg.content)); onDismiss() }
|
||||
}
|
||||
if (isOwn && !msg.content.isNullOrBlank()) {
|
||||
ActionRow(Icons.Default.Edit, "Edit") { onEdit(msg) }
|
||||
}
|
||||
ActionRow(Icons.Default.Delete, "Delete", MaterialTheme.colorScheme.error) { onDelete(msg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeleteMessageDialog(msg: MessageItemDto?, currentUserId: String, deleteForAll: Boolean, onDeleteForAllChange: (Boolean) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
||||
if (msg == null) return
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Delete message?") },
|
||||
text = {
|
||||
if (msg.senderId == currentUserId) {
|
||||
Row(Modifier.fillMaxWidth().clickable { onDeleteForAllChange(!deleteForAll) }, verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(deleteForAll, onDeleteForAllChange)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Delete for everyone")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(msg) }) { Text("Delete", color = MaterialTheme.colorScheme.error) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EditMessageDialog(msg: MessageItemDto?, text: String, onTextChange: (String) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
||||
if (msg == null) return
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit message") },
|
||||
text = {
|
||||
TextField(text, onTextChange, Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
|
||||
colors = TextFieldDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(msg) }, enabled = text.isNotBlank()) { Text("Save") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ActionRow(icon: ImageVector, label: String, color: Color = MaterialTheme.colorScheme.onSurface, onClick: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, tint = color, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, color = color)
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
package org.yobble.messenger.presentation.chat.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.graphics.Brush
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun MessageBubble(message: MessageItemDto, isOutgoing: Boolean, onLongClick: () -> Unit = {}) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||
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 shape = if (isOutgoing) RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp) else RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp)
|
||||
val time = formatUtcToLocalTime(message.createdAt)
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start) {
|
||||
Box(
|
||||
Modifier.widthIn(max = 280.dp).clip(shape)
|
||||
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
||||
.then(if (isOutgoing) Modifier.background(Brush.linearGradient(listOf(primary, primaryContainer))) else Modifier.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
if (!message.content.isNullOrBlank()) Text(message.content, color = textColor, fontSize = 15.sp)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text("${if (message.isEdited) "edited " else ""}$time", color = timeColor, fontSize = 11.sp, modifier = Modifier.align(Alignment.End))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,126 +0,0 @@
|
||||
package org.yobble.messenger.presentation.chat.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material3.*
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun MessageInputBar(
|
||||
text: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
isSending: Boolean,
|
||||
showEmojiPicker: Boolean = false,
|
||||
onToggleEmoji: () -> Unit = {},
|
||||
focusRequester: FocusRequester? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val canSend = text.isNotBlank() && !isSending
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Emoji toggle
|
||||
IconButton(onClick = onToggleEmoji, modifier = Modifier.size(44.dp)) {
|
||||
Icon(
|
||||
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions,
|
||||
contentDescription = "Emoji",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Input field with attach inside
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier.weight(1f)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
|
||||
placeholder = {
|
||||
Text("Message...", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||
maxLines = 4,
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Attach",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable { /* TODO: attach */ }
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
// Send
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (canSend)
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
else
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint = if (canSend)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
package org.yobble.messenger.presentation.common
|
||||
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@Composable
|
||||
fun SwipeBackContainer(
|
||||
onBack: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
val density = LocalDensity.current
|
||||
var accumulated by remember { mutableFloatStateOf(0f) }
|
||||
var started by remember { mutableStateOf(false) }
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val screenWidthPx = with(density) { maxWidth.toPx() }
|
||||
|
||||
val draggableState = rememberDraggableState { delta ->
|
||||
if (started) {
|
||||
accumulated = (accumulated + delta).coerceAtLeast(0f)
|
||||
// Damped progress — screen moves slower than the finger
|
||||
val linear = (accumulated / screenWidthPx).coerceIn(0f, 1f)
|
||||
val progress = sqrt(linear) * 0.6f
|
||||
try {
|
||||
backDispatcher?.dispatchOnBackProgressed(
|
||||
BackEventCompat(accumulated, 0f, progress.coerceIn(0f, 1f), BackEventCompat.EDGE_LEFT)
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.draggable(
|
||||
state = draggableState,
|
||||
orientation = Orientation.Horizontal,
|
||||
onDragStarted = { offset ->
|
||||
accumulated = 0f
|
||||
started = true
|
||||
try {
|
||||
backDispatcher?.dispatchOnBackStarted(
|
||||
BackEventCompat(offset.x, offset.y, 0f, BackEventCompat.EDGE_LEFT)
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
},
|
||||
onDragStopped = { velocity ->
|
||||
if (started) {
|
||||
val linear = accumulated / screenWidthPx
|
||||
val progress = sqrt(linear) * 0.6f
|
||||
if (progress > 0.25f || velocity > 1500f) {
|
||||
backDispatcher?.onBackPressed()
|
||||
} else {
|
||||
try {
|
||||
backDispatcher?.dispatchOnBackCancelled()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
started = false
|
||||
accumulated = 0f
|
||||
}
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
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.compose.SubcomposeAsyncImageContent
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.request.CachePolicy
|
||||
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)
|
||||
.memoryCacheKey(avatarUrl)
|
||||
.diskCacheKey(avatarUrl)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.crossfade(false)
|
||||
.build(),
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
) {
|
||||
when (painter.state) {
|
||||
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
|
||||
is AsyncImagePainter.State.Error -> InitialsAvatar(displayName, size, fontSize)
|
||||
else -> 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/avatar/download/$userId?file_id=$fileId"
|
||||
}
|
||||
@ -1,280 +0,0 @@
|
||||
package org.yobble.messenger.presentation.contacts
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.material.icons.Icons
|
||||
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.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.ContactInfoDto
|
||||
import org.yobble.messenger.presentation.common.UserAvatar
|
||||
import org.yobble.messenger.presentation.contacts.components.AddContactDialog
|
||||
import org.yobble.messenger.presentation.contacts.components.RenameContactDialog
|
||||
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
onNavigateToChat: (chatId: String) -> Unit,
|
||||
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||
selectedContactIds: Set<String> = emptySet(),
|
||||
onToggleContactSelection: (String) -> Unit = {},
|
||||
deleteContactIds: Set<String> = emptySet(),
|
||||
onDeleteContactsHandled: () -> Unit = {},
|
||||
renameContactId: String? = null,
|
||||
onRenameContactHandled: () -> Unit = {},
|
||||
viewModel: ContactsViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(deleteContactIds) {
|
||||
if (deleteContactIds.isNotEmpty()) {
|
||||
deleteContactIds.forEach { viewModel.removeContact(it) }
|
||||
onDeleteContactsHandled()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(renameContactId) {
|
||||
if (renameContactId != null) {
|
||||
val contact = uiState.contacts.find { it.userId == renameContactId }
|
||||
viewModel.showRenameDialog(renameContactId, contact?.customName ?: "")
|
||||
onRenameContactHandled()
|
||||
}
|
||||
}
|
||||
|
||||
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) },
|
||||
containerColor = Color.Transparent,
|
||||
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 hasMore = uiState.hasMore
|
||||
val isLoading = uiState.isLoading
|
||||
val shouldLoadMore by remember(hasMore, isLoading) {
|
||||
derivedStateOf {
|
||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
val totalItems = listState.layoutInfo.totalItemsCount
|
||||
lastVisible >= totalItems - 3 && hasMore && !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 ->
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
isSelected = contact.userId in selectedContactIds,
|
||||
inSelectionMode = selectedContactIds.isNotEmpty(),
|
||||
isCreatingChat = uiState.creatingChatForUserId == contact.userId,
|
||||
onClick = {
|
||||
if (selectedContactIds.isNotEmpty()) {
|
||||
onToggleContactSelection(contact.userId)
|
||||
} else {
|
||||
viewModel.createChatWithContact(contact.userId)
|
||||
}
|
||||
},
|
||||
onLongClick = { onToggleContactSelection(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ContactItem(
|
||||
contact: ContactInfoDto,
|
||||
isSelected: Boolean,
|
||||
inSelectionMode: Boolean,
|
||||
isCreatingChat: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val displayName = contact.customName
|
||||
?: contact.fullName
|
||||
?: contact.login
|
||||
?: "User"
|
||||
|
||||
val bgColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
else
|
||||
Color.Transparent
|
||||
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.combinedClickable(
|
||||
enabled = !isCreatingChat,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserAvatar(
|
||||
userId = contact.userId,
|
||||
fileId = contact.avatars?.current?.fileId,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,233 +0,0 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
package org.yobble.messenger.presentation.contacts.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal 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
|
||||
internal 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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user