Compare commits
No commits in common. "105b0d5bad45c3b684309c84da50595df3dd9124" and "master" have entirely different histories.
105b0d5bad
...
master
1
.gitignore
vendored
1
.gitignore
vendored
@ -13,3 +13,4 @@
|
|||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
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.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kapt)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt.android)
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
alias(libs.plugins.google.services)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "org.yobble.messenger"
|
namespace = "com.cardinalnsk.volnahub"
|
||||||
compileSdk {
|
compileSdk = 35
|
||||||
version = release(36)
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "org.yobble.messenger"
|
applicationId = "com.cardinalnsk.volnahub"
|
||||||
minSdk = 28
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
buildConfigField("String", "USER_AGENT", "\"VolnaHub Android App\"")
|
||||||
|
|
||||||
buildConfigField("String", "BASE_URL", "\"https://dev.api.yobble.org/\"")
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
buildConfigField("String", "USER_AGENT", "\"yobble android/${versionName}\"")
|
|
||||||
buildConfigField("String", "SOCKET_PATH", "\"/socket.io/\"")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@ -46,64 +44,38 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.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(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.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
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
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.platform.app.InstrumentationRegistry
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
|
|||||||
fun useAppContext() {
|
fun useAppContext() {
|
||||||
// Context of the app under test.
|
// Context of the app under test.
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".YobbleApp"
|
android:name=".VolnaHubApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
@ -14,27 +11,19 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:theme="@style/Theme.MyApplication"
|
||||||
android:theme="@style/Theme.Yobble">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:windowSoftInputMode="adjustNothing"
|
android:theme="@style/Theme.MyApplication">
|
||||||
android:theme="@style/Theme.Yobble">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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