Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

156 changed files with 1471 additions and 11212 deletions

1
.gitignore vendored
View File

@ -13,3 +13,4 @@
.externalNativeBuild
.cxx
local.properties
/.idea/*

View File

@ -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
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View File

@ -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>

View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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>

View File

@ -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
View File

@ -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>

View File

@ -2,30 +2,28 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.google.services)
alias(libs.plugins.kapt)
alias(libs.plugins.hilt.android)
}
android {
namespace = "org.yobble.messenger"
compileSdk {
version = release(36)
namespace = "com.cardinalnsk.volnahub"
compileSdk = 35
buildFeatures {
buildConfig = true
}
defaultConfig {
applicationId = "org.yobble.messenger"
minSdk = 28
targetSdk = 36
applicationId = "com.cardinalnsk.volnahub"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "USER_AGENT", "\"VolnaHub Android App\"")
buildConfigField("String", "BASE_URL", "\"https://dev.api.yobble.org/\"")
buildConfigField("String", "USER_AGENT", "\"yobble android/${versionName}\"")
buildConfigField("String", "SOCKET_PATH", "\"/socket.io/\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@ -46,64 +44,38 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
// Core
implementation(libs.retrofit)
implementation(libs.gson.converter)
implementation(libs.okhttp.logging)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.navigation.compose)
implementation(libs.material.icons.extended)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.kotlinx.coroutines.android)
// Compose
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
// Navigation
implementation(libs.androidx.navigation.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.hilt.navigation.compose)
// Network
implementation(libs.retrofit)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
// Firebase
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
// Socket.IO
implementation(libs.socket.io.client)
// Image loading
implementation(libs.coil.compose)
// Emoji picker
implementation(libs.androidx.emoji2.emojipicker)
// Security
implementation(libs.androidx.security.crypto)
// Test
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

View File

@ -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"
}

View File

@ -1,4 +1,4 @@
package org.yobble.messenger
package com.cardinalnsk.volnahub
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("ru.cardinalnsk.yobble", appContext.packageName)
assertEquals("com.example.myapplication", appContext.packageName)
}
}

View File

@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".YobbleApp"
android:name=".VolnaHubApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@ -14,27 +11,19 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.Yobble">
android:theme="@style/Theme.MyApplication"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.Yobble">
android:theme="@style/Theme.MyApplication">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".data.remote.fcm.YobbleFcmService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

View 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)
}
}
}
}
}

View File

@ -0,0 +1,7 @@
package com.cardinalnsk.volnahub
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class VolnaHubApplication: Application()

View 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)
}

View File

@ -0,0 +1,6 @@
package com.cardinalnsk.volnahub.model
data class LoginRequest(
val login: String,
val password: String
)

View File

@ -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
)

View File

@ -0,0 +1,7 @@
package com.cardinalnsk.volnahub.model
data class RegisterRequest(
val login: String,
val password: String,
val invite: String
)

View File

@ -0,0 +1,6 @@
package com.cardinalnsk.volnahub.model
data class RegisterResponse(
val status: String,
val message: String
)

View File

@ -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
// }
// }
//}
//

View File

@ -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 -> ""
}
}
}

View File

@ -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>
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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 "Тёмная тема"
)
}
}
)
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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))
//
// }
//}

View File

@ -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("Выйти из аккаунта")
}
}
}

View 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)

View 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
)
}

View 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
)
)

View File

@ -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
}
}
}
}

View File

@ -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
}
}

View File

@ -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
)
}

View File

@ -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"
}
}

View File

@ -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()
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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"
}
}

View File

@ -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)
}
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -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
)

View File

@ -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
)

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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"
}
}

View File

@ -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) }
}
}

View File

@ -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()
}
}

View File

@ -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))
}
}
}

View File

@ -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) }
}
}

View File

@ -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) }
}
}

View File

@ -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)) }
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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>
}

View File

@ -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()
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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()
}

View File

@ -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
)
}

View File

@ -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()
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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) }
}
}
}

View File

@ -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
)

View File

@ -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) }
}
}
}

View File

@ -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
)

View File

@ -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) }
}
}
}

View File

@ -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

View File

@ -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) }
}
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}
}
}

View File

@ -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)
)
}
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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()
}
}
}

View File

@ -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"
}

View File

@ -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
)
}
}
}
}

View File

@ -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"))
}
}
}
}
}

View File

@ -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