This commit is contained in:
YaAndreyIgorevich 2026-03-07 03:42:50 +07:00
commit 7e8fcfc24a
117 changed files with 8554 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml generated Normal file
View File

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

18
.idea/deploymentTargetSelector.xml generated Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-03-06T22:11:28.794766722Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/cardinalnsk/.config/.android/avd/Pixel_6.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

13
.idea/deviceManager.xml generated Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

19
.idea/gradle.xml generated Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

106
app/build.gradle.kts Normal file
View File

@ -0,0 +1,106 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.google.services)
}
android {
namespace = "org.yobble.messenger"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "org.yobble.messenger"
minSdk = 28
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "BASE_URL", "\"https://dev.api.yobble.org/\"")
buildConfigField("String", "USER_AGENT", "\"yobble android/${versionName}\"")
buildConfigField("String", "SOCKET_PATH", "\"/socket.io/\"")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.kotlinx.coroutines.android)
// Compose
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
// Navigation
implementation(libs.androidx.navigation.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.hilt.navigation.compose)
// Network
implementation(libs.retrofit)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
// Firebase
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
// Socket.IO
implementation(libs.socket.io.client)
// Image loading
implementation(libs.coil.compose)
// Security
implementation(libs.androidx.security.crypto)
// Test
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

29
app/google-services.json Normal file
View File

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "1058456897662",
"project_id": "yobble",
"storage_bucket": "yobble.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1058456897662:android:0c9ec958c52ba6df09f02f",
"android_client_info": {
"package_name": "org.yobble.messenger"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBhP8fd4Vbj-2lheoQpwf4KubGHerwR6eU"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package org.yobble.messenger
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("ru.cardinalnsk.yobble", appContext.packageName)
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

View File

@ -0,0 +1,65 @@
package org.yobble.messenger
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import org.yobble.messenger.data.local.SessionManager
import org.yobble.messenger.presentation.navigation.AppNavGraph
import org.yobble.messenger.presentation.navigation.Routes
import org.yobble.messenger.ui.theme.YobbleTheme
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var sessionManager: SessionManager
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ -> }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermission()
val startDestination = if (sessionManager.isLoggedIn) Routes.HOME else Routes.LOGIN
setContent {
YobbleTheme {
YobbleAppContent(startDestination = startDestination)
}
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
@Composable
private fun YobbleAppContent(startDestination: String) {
val navController = rememberNavController()
AppNavGraph(
navController = navController,
startDestination = startDestination
)
}

View File

@ -0,0 +1,56 @@
package org.yobble.messenger
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import coil.ImageLoader
import coil.ImageLoaderFactory
import dagger.hilt.android.HiltAndroidApp
import okhttp3.OkHttpClient
import javax.inject.Inject
@HiltAndroidApp
class YobbleApp : Application(), ImageLoaderFactory {
@Inject
lateinit var okHttpClient: OkHttpClient
override fun onCreate() {
super.onCreate()
createNotificationChannels()
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.okHttpClient(okHttpClient)
.crossfade(true)
.build()
}
private fun createNotificationChannels() {
val manager = getSystemService(NotificationManager::class.java)
val messagesChannel = NotificationChannel(
CHANNEL_MESSAGES,
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "New message notifications"
}
val generalChannel = NotificationChannel(
CHANNEL_GENERAL,
"General",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "General notifications"
}
manager.createNotificationChannels(listOf(messagesChannel, generalChannel))
}
companion object {
const val CHANNEL_MESSAGES = "messages"
const val CHANNEL_GENERAL = "general"
}
}

View File

@ -0,0 +1,28 @@
package org.yobble.messenger.data.local
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.api.AuthApi
import org.yobble.messenger.data.remote.safeApiCall
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PushTokenManager @Inject constructor(
private val authApi: AuthApi,
private val sessionManager: SessionManager,
private val json: Json
) {
suspend fun registerPushToken() {
if (!sessionManager.isLoggedIn) return
try {
val token = FirebaseMessaging.getInstance().token.await()
safeApiCall(json) { authApi.updatePushToken(token) }
} catch (_: Exception) {
// Firebase not available or network error — silently ignore
}
}
}

View File

@ -0,0 +1,85 @@
package org.yobble.messenger.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import androidx.core.content.edit
@Singleton
class SessionManager @Inject constructor(
@ApplicationContext context: Context
) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"yobble_session_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private val localPrefs: SharedPreferences = context.getSharedPreferences(
"yobble_local_prefs", Context.MODE_PRIVATE
)
var accessToken: String?
get() = prefs.getString(KEY_ACCESS_TOKEN, null)
set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) }
var refreshToken: String?
get() = prefs.getString(KEY_REFRESH_TOKEN, null)
set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) }
var userId: String?
get() = prefs.getString(KEY_USER_ID, null)
set(value) = prefs.edit { putString(KEY_USER_ID, value) }
val isLoggedIn: Boolean
get() = accessToken != null
fun saveSession(accessToken: String, refreshToken: String, userId: String) {
prefs.edit {
putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken)
.putString(KEY_USER_ID, userId)
}
}
fun clearSession() {
prefs.edit { clear() }
localPrefs.edit { clear() }
}
fun saveLastReadMessageId(chatId: String, messageId: String) {
localPrefs.edit { putString("last_read_$chatId", messageId) }
}
fun getLastReadMessageId(chatId: String): String? {
return localPrefs.getString("last_read_$chatId", null)
}
fun saveDraft(chatId: String, text: String) {
if (text.isBlank()) {
localPrefs.edit { remove("draft_$chatId") }
} else {
localPrefs.edit { putString("draft_$chatId", text) }
}
}
fun getDraft(chatId: String): String {
return localPrefs.getString("draft_$chatId", null) ?: ""
}
companion object {
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_USER_ID = "user_id"
}
}

View File

@ -0,0 +1,46 @@
package org.yobble.messenger.data.remote
import kotlinx.serialization.json.Json
import org.yobble.messenger.data.remote.dto.ErrorResponseDto
import retrofit2.Response
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(
val code: Int,
val errors: List<FieldError> = emptyList(),
val message: String? = null
) : NetworkResult<Nothing>()
data class Exception(val throwable: Throwable) : NetworkResult<Nothing>()
}
data class FieldError(val field: String, val message: String)
suspend fun <T> safeApiCall(json: Json, apiCall: suspend () -> Response<T>): NetworkResult<T> {
return try {
val response = apiCall()
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
NetworkResult.Success(body)
} else {
NetworkResult.Error(response.code(), message = "Empty response body")
}
} else {
val errorBody = response.errorBody()?.string()
val errors = if (errorBody != null) {
try {
val errorResponse = json.decodeFromString<ErrorResponseDto>(errorBody)
errorResponse.errors.map { FieldError(it.field, it.message) }
} catch (e: kotlin.Exception) {
emptyList()
}
} else {
emptyList()
}
NetworkResult.Error(response.code(), errors, response.message())
}
} catch (e: kotlin.Exception) {
NetworkResult.Exception(e)
}
}

View File

@ -0,0 +1,57 @@
package org.yobble.messenger.data.remote.api
import org.yobble.messenger.data.remote.dto.*
import retrofit2.Response
import retrofit2.http.*
interface AuthApi {
@POST("v1/auth/login/password")
suspend fun login(
@Body request: LoginRequestDto,
@Header("X-Client-Type") clientType: String = "android"
): Response<LoginResponseDto>
@POST("v1/auth/login/code")
suspend fun requestLoginCode(
@Body request: LoginCodeRequestDto,
@Header("X-Client-Type") clientType: String = "android"
): Response<BaseResponseDto>
@POST("v1/auth/login/verify_code")
suspend fun verifyCode(
@Body request: VerifyCodeRequestDto,
@Header("X-Client-Type") clientType: String = "android"
): Response<LoginResponseDto>
@POST("v1/auth/register")
suspend fun register(
@Body request: RegisterRequestDto
): Response<RegisterResponseDto>
@POST("v1/auth/token/refresh")
suspend fun refreshToken(
@Body request: TokenRefreshRequestDto
): Response<TokenRefreshResponseDto>
@POST("v1/auth/password/change")
suspend fun changePassword(
@Body request: ChangePasswordRequestDto
): Response<BaseResponseDto>
@POST("v1/auth/sessions/update_push_token")
suspend fun updatePushToken(
@Query("fcm_token") fcmToken: String
): Response<BaseResponseDto>
@GET("v1/auth/sessions/list")
suspend fun getSessionsList(): Response<SessionsListResponseDto>
@POST("v1/auth/sessions/revoke/{session_id}")
suspend fun revokeSession(
@Path("session_id") sessionId: String
): Response<BaseResponseDto>
@POST("v1/auth/sessions/revoke_all_except_current")
suspend fun revokeAllExceptCurrent(): Response<BaseResponseDto>
}

View File

@ -0,0 +1,36 @@
package org.yobble.messenger.data.remote.api
import org.yobble.messenger.data.remote.dto.*
import retrofit2.Response
import retrofit2.http.*
interface ChatPrivateApi {
@GET("v1/chat/private/list")
suspend fun getChatList(
@Query("offset") offset: Int = 0,
@Query("limit") limit: Int = 20
): Response<PrivateChatListResponseDto>
@GET("v1/chat/private/history")
suspend fun getChatHistory(
@Query("chat_id") chatId: String,
@Query("before_message_id") beforeMessageId: Int? = null,
@Query("limit") limit: Int = 30
): Response<PrivateChatHistoryResponseDto>
@POST("v1/chat/private/create")
suspend fun createChat(
@Query("target_user_id") targetUserId: String
): Response<PrivateChatCreateResponseDto>
@POST("v1/chat/private/send")
suspend fun sendMessage(
@Body request: PrivateMessageSendRequestDto
): Response<PrivateMessageSendResponseDto>
@HTTP(method = "DELETE", path = "v1/chat/private/delete", hasBody = true)
suspend fun deleteChat(
@Body request: PrivateChatDeleteRequestDto
): Response<BaseResponseDto>
}

View File

@ -0,0 +1,14 @@
package org.yobble.messenger.data.remote.api
import org.yobble.messenger.data.remote.dto.UserSearchResponseDto
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface FeedApi {
@GET("v1/feed/user/search")
suspend fun searchUsers(
@Query("query") query: String
): Response<UserSearchResponseDto>
}

View File

@ -0,0 +1,21 @@
package org.yobble.messenger.data.remote.api
import org.yobble.messenger.data.remote.dto.*
import retrofit2.Response
import retrofit2.http.*
interface ProfileApi {
@GET("v1/profile/me")
suspend fun getMyProfile(): Response<ProfileResponseDto>
@PUT("v1/profile/edit")
suspend fun editProfile(
@Body request: ProfileUpdateRequestDto
): Response<BaseResponseDto>
@GET("v1/profile/{user_id}")
suspend fun getUserProfile(
@Path("user_id") userId: String
): Response<ProfileByUserIdResponseDto>
}

View File

@ -0,0 +1,17 @@
package org.yobble.messenger.data.remote.api
import okhttp3.MultipartBody
import org.yobble.messenger.data.remote.dto.UploadAvatarResponseDto
import retrofit2.Response
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
interface StorageApi {
@Multipart
@POST("v1/storage/upload/avatar")
suspend fun uploadAvatar(
@Part file: MultipartBody.Part
): Response<UploadAvatarResponseDto>
}

View File

@ -0,0 +1,52 @@
package org.yobble.messenger.data.remote.api
import org.yobble.messenger.data.remote.dto.*
import retrofit2.Response
import retrofit2.http.*
interface UserApi {
// Contacts
@GET("v1/user/contact/list")
suspend fun getContacts(
@Query("offset") offset: Int = 0,
@Query("limit") limit: Int = 20
): Response<ContactListResponseDto>
@GET("v1/user/contact/count")
suspend fun getContactCount(): Response<ContactCountResponseDto>
@POST("v1/user/contact/add")
suspend fun addContact(
@Body request: ContactCreateRequestDto
): Response<ContactCreateResponseDto>
@PATCH("v1/user/contact/update")
suspend fun updateContact(
@Body request: ContactUpdateRequestDto
): Response<BaseResponseDto>
@HTTP(method = "DELETE", path = "v1/user/contact/remove", hasBody = true)
suspend fun removeContact(
@Body request: ContactDeleteRequestDto
): Response<BaseResponseDto>
// Blacklist
@GET("v1/user/blacklist/list")
suspend fun getBlacklist(
@Query("offset") offset: Int = 0,
@Query("limit") limit: Int = 20
): Response<BlacklistListResponseDto>
@POST("v1/user/blacklist/add")
suspend fun addToBlacklist(
@Body request: BlacklistCreateRequestDto
): Response<BlacklistCreateResponseDto>
@HTTP(method = "DELETE", path = "v1/user/blacklist/remove", hasBody = true)
suspend fun removeFromBlacklist(
@Body request: BlacklistDeleteRequestDto
): Response<BaseResponseDto>
}

View File

@ -0,0 +1,40 @@
package org.yobble.messenger.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequestDto(
@SerialName("login") val login: String,
@SerialName("password") val password: String
)
@Serializable
data class LoginCodeRequestDto(
@SerialName("login") val login: String
)
@Serializable
data class VerifyCodeRequestDto(
@SerialName("login") val login: String,
@SerialName("otp") val otp: String
)
@Serializable
data class RegisterRequestDto(
@SerialName("login") val login: String,
@SerialName("password") val password: String,
@SerialName("invite") val invite: String? = null
)
@Serializable
data class TokenRefreshRequestDto(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String? = null
)
@Serializable
data class ChangePasswordRequestDto(
@SerialName("old_password") val oldPassword: String,
@SerialName("new_password") val newPassword: String
)

View File

@ -0,0 +1,64 @@
package org.yobble.messenger.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BaseResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: MessageDataDto
)
@Serializable
data class MessageDataDto(
@SerialName("message") val message: String
)
@Serializable
data class LoginResponseDto(
@SerialName("status") val status: String = "fine",
@SerialName("data") val data: LoginDataDto
)
@Serializable
data class LoginDataDto(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("user_id") val userId: String
)
@Serializable
data class RegisterResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: RegisterDataDto
)
@Serializable
data class RegisterDataDto(
@SerialName("message") val message: String,
@SerialName("user_id") val userId: String
)
@Serializable
data class TokenRefreshResponseDto(
@SerialName("status") val status: String = "fine",
@SerialName("data") val data: TokenDataDto
)
@Serializable
data class TokenDataDto(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String
)
@Serializable
data class ErrorResponseDto(
@SerialName("status") val status: String,
@SerialName("errors") val errors: List<ErrorItemDto>
)
@Serializable
data class ErrorItemDto(
@SerialName("field") val field: String,
@SerialName("message") val message: String
)

View File

@ -0,0 +1,110 @@
package org.yobble.messenger.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
// region Requests
@Serializable
data class PrivateMessageSendRequestDto(
@SerialName("chat_id") val chatId: String,
@SerialName("content") val content: String? = null,
@SerialName("message_type") val messageType: List<String> = listOf("text")
)
@Serializable
data class PrivateChatDeleteRequestDto(
@SerialName("chat_id") val chatId: String,
@SerialName("delete_for_both") val deleteForBoth: Boolean = false
)
// endregion
// region Responses
@Serializable
data class PrivateChatListResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: PrivateChatListDataDto
)
@Serializable
data class PrivateChatListDataDto(
@SerialName("items") val items: List<PrivateChatListItemDto>,
@SerialName("has_more") val hasMore: Boolean
)
@Serializable
data class PrivateChatListItemDto(
@SerialName("chat_id") val chatId: String,
@SerialName("chat_type") val chatType: String,
@SerialName("chat_data") val chatData: ProfileByUserIdDataDto? = null,
@SerialName("last_message") val lastMessage: MessageItemDto? = null,
@SerialName("created_at") val createdAt: String,
@SerialName("unread_count") val unreadCount: Int
)
@Serializable
data class MessageItemDto(
@SerialName("message_id") val messageId: Int,
@SerialName("message_type") val messageType: List<String>,
@SerialName("forward_metadata") val forwardMetadata: MessageForwardDto? = null,
@SerialName("chat_id") val chatId: String,
@SerialName("sender_id") val senderId: String,
@SerialName("sender_data") val senderData: ProfileByUserIdDataDto? = null,
@SerialName("content") val content: String? = null,
@SerialName("media_link") val mediaLink: JsonObject? = null,
@SerialName("is_viewed") val isViewed: Boolean,
@SerialName("viewed_at") val viewedAt: String? = null,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String? = null
)
@Serializable
data class MessageForwardDto(
@SerialName("forward_type") val forwardType: String? = null,
@SerialName("forward_sender_id") val forwardSenderId: String? = null,
@SerialName("forward_message_id") val forwardMessageId: Int? = null
)
@Serializable
data class PrivateChatHistoryResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: PrivateChatHistoryDataDto
)
@Serializable
data class PrivateChatHistoryDataDto(
@SerialName("items") val items: List<MessageItemDto>,
@SerialName("has_more") val hasMore: Boolean
)
@Serializable
data class PrivateChatCreateResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: PrivateChatCreateDataDto
)
@Serializable
data class PrivateChatCreateDataDto(
@SerialName("chat_id") val chatId: String,
@SerialName("chat_type") val chatType: String,
@SerialName("status") val status: String,
@SerialName("message") val message: String
)
@Serializable
data class PrivateMessageSendResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: PrivateMessageSendDataDto
)
@Serializable
data class PrivateMessageSendDataDto(
@SerialName("message_id") val messageId: Int,
@SerialName("chat_id") val chatId: String,
@SerialName("created_at") val createdAt: String
)
// endregion

View File

@ -0,0 +1,116 @@
package org.yobble.messenger.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// region Contact Requests
@Serializable
data class ContactCreateRequestDto(
@SerialName("user_id") val userId: String? = null,
@SerialName("login") val login: String? = null,
@SerialName("friend_code") val friendCode: String? = null,
@SerialName("custom_name") val customName: String? = null
)
@Serializable
data class ContactUpdateRequestDto(
@SerialName("user_id") val userId: String,
@SerialName("custom_name") val customName: String? = null
)
@Serializable
data class ContactDeleteRequestDto(
@SerialName("user_id") val userId: String
)
// endregion
// region Contact Responses
@Serializable
data class ContactListResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: ContactListDataDto
)
@Serializable
data class ContactListDataDto(
@SerialName("items") val items: List<ContactInfoDto>,
@SerialName("has_more") val hasMore: Boolean
)
@Serializable
data class ContactInfoDto(
@SerialName("user_id") val userId: String,
@SerialName("login") val login: String? = null,
@SerialName("full_name") val fullName: String? = null,
@SerialName("custom_name") val customName: String? = null,
@SerialName("friend_code") val friendCode: Boolean = false,
@SerialName("created_at") val createdAt: String,
@SerialName("last_seen_at") val lastSeenAt: String? = null
)
@Serializable
data class ContactCreateResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: ContactInfoDto
)
@Serializable
data class ContactCountResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: ContactCountDataDto
)
@Serializable
data class ContactCountDataDto(
@SerialName("count") val count: Int
)
// endregion
// region Blacklist Requests
@Serializable
data class BlacklistCreateRequestDto(
@SerialName("user_id") val userId: String? = null,
@SerialName("login") val login: String? = null
)
@Serializable
data class BlacklistDeleteRequestDto(
@SerialName("user_id") val userId: String
)
// endregion
// region Blacklist Responses
@Serializable
data class BlacklistListResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: BlacklistListDataDto
)
@Serializable
data class BlacklistListDataDto(
@SerialName("items") val items: List<BlacklistInfoDto>,
@SerialName("has_more") val hasMore: Boolean
)
@Serializable
data class BlacklistInfoDto(
@SerialName("user_id") val userId: String,
@SerialName("login") val login: String? = null,
@SerialName("full_name") val fullName: String? = null,
@SerialName("created_at") val createdAt: String
)
@Serializable
data class BlacklistCreateResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: BlacklistInfoDto
)
// endregion

View File

@ -0,0 +1,22 @@
package org.yobble.messenger.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class UserSearchResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: UserSearchDataDto
)
@Serializable
data class UserSearchDataDto(
@SerialName("users") val users: List<UserSearchResultDto>
)
@Serializable
data class UserSearchResultDto(
@SerialName("user_id") val userId: String,
@SerialName("profile") val profile: ProfileByUserIdDataDto? = null
)

View File

@ -0,0 +1,158 @@
package org.yobble.messenger.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// region Requests
@Serializable
data class ProfileUpdateRequestDto(
@SerialName("full_name") val fullName: String? = null,
@SerialName("bio") val bio: String? = null,
@SerialName("profile_permissions") val profilePermissions: ProfilePermissionsRequestDto? = null
)
@Serializable
data class ProfilePermissionsRequestDto(
@SerialName("is_searchable") val isSearchable: Boolean? = null,
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean? = null,
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean? = null,
@SerialName("show_profile_photo_to_non_contacts") val showProfilePhotoToNonContacts: Boolean? = null,
@SerialName("last_seen_visibility") val lastSeenVisibility: Int? = null,
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean? = null,
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean? = null,
@SerialName("allow_server_chats") val allowServerChats: Boolean? = null,
@SerialName("public_invite_permission") val publicInvitePermission: Int? = null,
@SerialName("group_invite_permission") val groupInvitePermission: Int? = null,
@SerialName("call_permission") val callPermission: Int? = null,
@SerialName("force_auto_delete_messages_in_private") val forceAutoDeleteMessagesInPrivate: Boolean? = null,
@SerialName("max_message_auto_delete_seconds") val maxMessageAutoDeleteSeconds: Int? = null,
@SerialName("auto_delete_after_days") val autoDeleteAfterDays: Int? = null
)
// endregion
// region Responses
@Serializable
data class ProfileResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: ProfileDataDto
)
@Serializable
data class ProfileDataDto(
@SerialName("user_id") val userId: String,
@SerialName("login") val login: String,
@SerialName("full_name") val fullName: String? = null,
@SerialName("bio") val bio: String? = null,
@SerialName("is_verified") val isVerified: Boolean? = false,
@SerialName("rating") val rating: RatingDataDto,
@SerialName("balances") val balances: List<WalletBalanceDto>,
@SerialName("created_at") val createdAt: String,
@SerialName("avatars") val avatars: AvatarsBlockDto? = null,
@SerialName("profile_permissions") val profilePermissions: MyProfilePermissionsDto
)
@Serializable
data class ProfileByUserIdResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: ProfileByUserIdDataDto
)
@Serializable
data class ProfileByUserIdDataDto(
@SerialName("user_id") val userId: String,
@SerialName("login") val login: String? = null,
@SerialName("full_name") val fullName: String? = null,
@SerialName("custom_name") val customName: String? = null,
@SerialName("bio") val bio: String? = null,
@SerialName("is_verified") val isVerified: Boolean? = false,
@SerialName("is_system") val isSystem: Boolean? = false,
@SerialName("rating") val rating: RatingDataDto,
@SerialName("last_seen") val lastSeen: Int? = null,
@SerialName("created_at") val createdAt: String,
@SerialName("avatars") val avatars: AvatarsBlockDto? = null,
@SerialName("permissions") val permissions: PermissionsResponseDto,
@SerialName("profile_permissions") val profilePermissions: UserProfilePermissionsDto,
@SerialName("relationship") val relationship: RelationshipStatusDto
)
// endregion
// region Shared models
@Serializable
data class RatingDataDto(
@SerialName("rating") val rating: Double? = null,
@SerialName("status") val status: String = "unavailable"
)
@Serializable
data class WalletBalanceDto(
@SerialName("currency") val currency: String,
@SerialName("balance") val balance: String,
@SerialName("display_balance") val displayBalance: Double? = null
)
@Serializable
data class AvatarsBlockDto(
@SerialName("current") val current: AvatarItemDto? = null,
@SerialName("history") val history: List<AvatarItemDto> = emptyList()
)
@Serializable
data class AvatarItemDto(
@SerialName("file_id") val fileId: String? = null,
@SerialName("mime") val mime: String? = null,
@SerialName("size") val size: Int? = null,
@SerialName("width") val width: Int? = null,
@SerialName("height") val height: Int? = null,
@SerialName("created_at") val createdAt: String? = null
)
@Serializable
data class PermissionsResponseDto(
@SerialName("you_can_send_message") val youCanSendMessage: Boolean,
@SerialName("you_can_public_invite_permission") val youCanPublicInvite: Boolean,
@SerialName("you_can_group_invite_permission") val youCanGroupInvite: Boolean,
@SerialName("you_can_call_permission") val youCanCall: Boolean
)
@Serializable
data class RelationshipStatusDto(
@SerialName("is_target_in_contacts_of_current_user") val isTargetInContacts: Boolean,
@SerialName("is_current_user_in_contacts_of_target") val isCurrentInTargetContacts: Boolean,
@SerialName("is_target_user_blocked_by_current_user") val isTargetBlocked: Boolean,
@SerialName("is_current_user_in_blacklist_of_target") val isCurrentInBlacklist: Boolean
)
@Serializable
data class MyProfilePermissionsDto(
@SerialName("is_searchable") val isSearchable: Boolean,
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean,
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean,
@SerialName("show_profile_photo_to_non_contacts") val showProfilePhotoToNonContacts: Boolean,
@SerialName("last_seen_visibility") val lastSeenVisibility: Int,
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean,
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean,
@SerialName("allow_server_chats") val allowServerChats: Boolean,
@SerialName("public_invite_permission") val publicInvitePermission: Int,
@SerialName("group_invite_permission") val groupInvitePermission: Int,
@SerialName("call_permission") val callPermission: Int,
@SerialName("force_auto_delete_messages_in_private") val forceAutoDeleteMessagesInPrivate: Boolean,
@SerialName("max_message_auto_delete_seconds") val maxMessageAutoDeleteSeconds: Int? = null,
@SerialName("auto_delete_after_days") val autoDeleteAfterDays: Int? = null
)
@Serializable
data class UserProfilePermissionsDto(
@SerialName("is_searchable") val isSearchable: Boolean? = null,
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean,
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean,
@SerialName("allow_server_chats") val allowServerChats: Boolean,
@SerialName("force_auto_delete_messages_in_private") val forceAutoDeleteMessagesInPrivate: Boolean,
@SerialName("max_message_auto_delete_seconds") val maxMessageAutoDeleteSeconds: Int? = null
)
// endregion

View File

@ -0,0 +1,27 @@
package org.yobble.messenger.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SessionsListResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: SessionsListDataDto
)
@Serializable
data class SessionsListDataDto(
@SerialName("sessions") val sessions: List<UserSessionItemDto>
)
@Serializable
data class UserSessionItemDto(
@SerialName("id") val id: String,
@SerialName("ip_address") val ipAddress: String? = null,
@SerialName("user_agent") val userAgent: String? = null,
@SerialName("client_type") val clientType: String,
@SerialName("is_active") val isActive: Boolean,
@SerialName("created_at") val createdAt: String,
@SerialName("last_refresh_at") val lastRefreshAt: String,
@SerialName("is_current") val isCurrent: Boolean
)

View File

@ -0,0 +1,15 @@
package org.yobble.messenger.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UploadAvatarResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: UploadAvatarDataDto
)
@Serializable
data class UploadAvatarDataDto(
@SerialName("file_id") val fileId: String
)

View File

@ -0,0 +1,83 @@
package org.yobble.messenger.data.remote.fcm
import android.app.PendingIntent
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.yobble.messenger.MainActivity
import org.yobble.messenger.R
import org.yobble.messenger.YobbleApp
import org.yobble.messenger.data.local.PushTokenManager
import javax.inject.Inject
@AndroidEntryPoint
class YobbleFcmService : FirebaseMessagingService() {
@Inject
lateinit var pushTokenManager: PushTokenManager
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewToken(token: String) {
super.onNewToken(token)
scope.launch {
pushTokenManager.registerPushToken()
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
val title = message.notification?.title
?: message.data["title"]
?: "Yobble"
val body = message.notification?.body
?: message.data["body"]
?: return
showNotification(title, body, message.data)
}
private fun showNotification(
title: String,
body: String,
data: Map<String, String>
) {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
data.forEach { (key, value) -> putExtra(key, value) }
}
val pendingIntent = PendingIntent.getActivity(
this,
System.currentTimeMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val channelId = data["channel"] ?: YobbleApp.CHANNEL_MESSAGES
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
try {
NotificationManagerCompat.from(this)
.notify(System.currentTimeMillis().toInt(), notification)
} catch (_: SecurityException) {
// POST_NOTIFICATIONS permission not granted
}
}
}

View File

@ -0,0 +1,27 @@
package org.yobble.messenger.data.remote.interceptor
import okhttp3.Interceptor
import okhttp3.Response
import org.yobble.messenger.BuildConfig
import org.yobble.messenger.data.local.SessionManager
import javax.inject.Inject
class AuthInterceptor @Inject constructor(
private val sessionManager: SessionManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val token = sessionManager.accessToken
val newRequest = request.newBuilder()
.header("User-Agent", BuildConfig.USER_AGENT)
.apply {
if (token != null) {
header("Authorization", "Bearer $token")
}
}
.build()
return chain.proceed(newRequest)
}
}

View File

@ -0,0 +1,64 @@
package org.yobble.messenger.data.remote.interceptor
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.yobble.messenger.data.local.SessionManager
import org.yobble.messenger.data.remote.api.AuthApi
import org.yobble.messenger.data.remote.dto.TokenRefreshRequestDto
import javax.inject.Inject
import javax.inject.Provider
class TokenAuthenticator @Inject constructor(
private val sessionManager: SessionManager,
private val authApiProvider: Provider<AuthApi>
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val accessToken = sessionManager.accessToken ?: return null
val refreshToken = sessionManager.refreshToken ?: return null
synchronized(this) {
val currentToken = sessionManager.accessToken
if (currentToken != null && currentToken != accessToken) {
return response.request.newBuilder()
.header("Authorization", "Bearer $currentToken")
.build()
}
val newTokens = runBlocking {
try {
val refreshResponse = authApiProvider.get().refreshToken(
TokenRefreshRequestDto(
accessToken = accessToken,
refreshToken = refreshToken
)
)
if (refreshResponse.isSuccessful) {
refreshResponse.body()?.data
} else {
null
}
} catch (e: Exception) {
null
}
}
return if (newTokens != null) {
sessionManager.saveSession(
accessToken = newTokens.accessToken,
refreshToken = newTokens.refreshToken,
userId = sessionManager.userId ?: ""
)
response.request.newBuilder()
.header("Authorization", "Bearer ${newTokens.accessToken}")
.build()
} else {
sessionManager.clearSession()
null
}
}
}
}

View File

@ -0,0 +1,171 @@
package org.yobble.messenger.data.remote.socket
import android.util.Log
import io.socket.client.IO
import io.socket.client.Socket
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import org.json.JSONObject
import org.yobble.messenger.BuildConfig
import org.yobble.messenger.data.local.SessionManager
import javax.inject.Inject
import javax.inject.Singleton
sealed class SocketEvent {
data class NewMessage(val data: JSONObject) : SocketEvent()
data class Connected(val data: JSONObject?) : SocketEvent()
data object Disconnected : SocketEvent()
}
@Singleton
class SocketManager @Inject constructor(
private val sessionManager: SessionManager
) {
private var socket: Socket? = null
private val _events = MutableSharedFlow<SocketEvent>(extraBufferCapacity = 64)
val events: SharedFlow<SocketEvent> = _events.asSharedFlow()
val isConnected: Boolean
get() = socket?.connected() == true
fun connect() {
if (socket?.connected() == true) {
Log.d(TAG, "Already connected, skipping")
return
}
val token = sessionManager.accessToken
if (token == null) {
Log.w(TAG, "No access token, cannot connect socket")
return
}
// Disconnect old socket if exists
socket?.let {
Log.d(TAG, "Cleaning up old socket")
it.disconnect()
it.off()
socket = null
}
try {
val baseUrl = BuildConfig.BASE_URL.trimEnd('/')
Log.i(TAG, "Connecting to: $baseUrl")
// OkHttpClient for socket.io (no auth interceptor — we pass token manually)
val okHttpClient = OkHttpClient.Builder().build()
IO.setDefaultOkHttpCallFactory(okHttpClient)
IO.setDefaultOkHttpWebSocketFactory(okHttpClient)
val options = IO.Options().apply {
// Auth map (socket.io v4 handshake)
auth = mapOf("token" to token)
// Also pass token as query param (like iOS connectParams)
query = "token=$token"
// Extra headers (like iOS)
extraHeaders = mapOf(
"Authorization" to listOf("Bearer $token"),
"User-Agent" to listOf(BuildConfig.USER_AGENT)
)
path = "/socket.io/"
transports = arrayOf("websocket", "polling")
reconnection = true
reconnectionAttempts = Int.MAX_VALUE
reconnectionDelay = 2000
reconnectionDelayMax = 5000
timeout = 20000
}
socket = IO.socket(baseUrl, options).apply {
// Debug: log ALL incoming events
onAnyIncoming { args ->
Log.d(TAG, "<<< event: ${args.joinToString()}")
}
on(Socket.EVENT_CONNECT) {
Log.i(TAG, "===== CONNECTED ===== SID: ${id()}")
val data = if (it.isNotEmpty()) it[0] as? JSONObject else null
_events.tryEmit(SocketEvent.Connected(data))
}
on(Socket.EVENT_DISCONNECT) { args ->
val reason = if (args.isNotEmpty()) args[0] else "unknown"
Log.w(TAG, "===== DISCONNECTED ===== reason: $reason")
_events.tryEmit(SocketEvent.Disconnected)
}
on(Socket.EVENT_CONNECT_ERROR) { args ->
val error = if (args.isNotEmpty()) args[0] else "Unknown"
Log.e(TAG, "===== CONNECT ERROR ===== $error")
if (error is Exception) {
Log.e(TAG, "Error details:", error)
}
}
// Server confirms connection
on("connected") { args ->
Log.i(TAG, "Server 'connected' event: ${args.firstOrNull()}")
}
// Heartbeat: server responds with "pong" via "message" event
on("message") { args ->
Log.d(TAG, "message event: ${args.firstOrNull()}")
}
// Private chat new message (matches iOS: chat_private:new_message)
on("chat_private:new_message") { args ->
Log.i(TAG, "chat_private:new_message, args: ${args.size}")
handleNewMessage(args)
}
// Also listen to chat:new_message (from web example)
on("chat:new_message") { args ->
Log.i(TAG, "chat:new_message, args: ${args.size}")
handleNewMessage(args)
}
on("achievement:received") { args ->
Log.i(TAG, "achievement:received: ${args.firstOrNull()}")
}
on("achievement:progress") { args ->
Log.i(TAG, "achievement:progress: ${args.firstOrNull()}")
}
Log.d(TAG, "Calling socket.connect()...")
connect()
Log.d(TAG, "socket.connect() called, connected=${connected()}")
}
} catch (e: Exception) {
Log.e(TAG, "Socket connection failed", e)
}
}
private fun handleNewMessage(args: Array<Any>) {
if (args.isEmpty()) return
val raw = args[0]
val data = when (raw) {
is JSONObject -> raw
is String -> try { JSONObject(raw) } catch (_: Exception) { null }
else -> null
}
if (data != null) {
Log.i(TAG, "New message data: $data")
_events.tryEmit(SocketEvent.NewMessage(data))
}
}
fun disconnect() {
Log.d(TAG, "Disconnecting socket")
socket?.disconnect()
socket?.off()
socket = null
}
companion object {
private const val TAG = "SocketManager"
}
}

View File

@ -0,0 +1,99 @@
package org.yobble.messenger.data.repository
import kotlinx.serialization.json.Json
import org.yobble.messenger.data.local.PushTokenManager
import org.yobble.messenger.data.local.SessionManager
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.api.AuthApi
import org.yobble.messenger.data.remote.dto.*
import org.yobble.messenger.data.remote.safeApiCall
import org.yobble.messenger.domain.repository.AuthRepository
import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor(
private val authApi: AuthApi,
private val sessionManager: SessionManager,
private val pushTokenManager: PushTokenManager,
private val json: Json
) : AuthRepository {
override suspend fun login(login: String, password: String): NetworkResult<LoginResponseDto> {
val result = safeApiCall(json) {
authApi.login(LoginRequestDto(login, password))
}
if (result is NetworkResult.Success) {
sessionManager.saveSession(
accessToken = result.data.data.accessToken,
refreshToken = result.data.data.refreshToken,
userId = result.data.data.userId
)
pushTokenManager.registerPushToken()
}
return result
}
override suspend fun requestLoginCode(login: String): NetworkResult<BaseResponseDto> {
return safeApiCall(json) {
authApi.requestLoginCode(LoginCodeRequestDto(login))
}
}
override suspend fun verifyCode(login: String, otp: String): NetworkResult<LoginResponseDto> {
val result = safeApiCall(json) {
authApi.verifyCode(VerifyCodeRequestDto(login, otp))
}
if (result is NetworkResult.Success) {
sessionManager.saveSession(
accessToken = result.data.data.accessToken,
refreshToken = result.data.data.refreshToken,
userId = result.data.data.userId
)
pushTokenManager.registerPushToken()
}
return result
}
override suspend fun register(login: String, password: String, invite: String?): NetworkResult<RegisterResponseDto> {
return safeApiCall(json) {
authApi.register(RegisterRequestDto(login, password, invite))
}
}
override suspend fun updatePushToken(fcmToken: String): NetworkResult<BaseResponseDto> {
return safeApiCall(json) {
authApi.updatePushToken(fcmToken)
}
}
override suspend fun changePassword(oldPassword: String, newPassword: String): NetworkResult<BaseResponseDto> {
return safeApiCall(json) {
authApi.changePassword(ChangePasswordRequestDto(oldPassword, newPassword))
}
}
override suspend fun getSessionsList(): NetworkResult<SessionsListResponseDto> {
return safeApiCall(json) {
authApi.getSessionsList()
}
}
override suspend fun revokeSession(sessionId: String): NetworkResult<BaseResponseDto> {
return safeApiCall(json) {
authApi.revokeSession(sessionId)
}
}
override suspend fun revokeAllExceptCurrent(): NetworkResult<BaseResponseDto> {
return safeApiCall(json) {
authApi.revokeAllExceptCurrent()
}
}
override suspend fun isLoggedIn(): Boolean {
return sessionManager.isLoggedIn
}
override fun logout() {
sessionManager.clearSession()
}
}

View File

@ -0,0 +1,39 @@
package org.yobble.messenger.data.repository
import kotlinx.serialization.json.Json
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.api.ChatPrivateApi
import org.yobble.messenger.data.remote.dto.*
import org.yobble.messenger.data.remote.safeApiCall
import org.yobble.messenger.domain.repository.ChatRepository
import javax.inject.Inject
class ChatRepositoryImpl @Inject constructor(
private val chatApi: ChatPrivateApi,
private val json: Json
) : ChatRepository {
override suspend fun getChatList(offset: Int, limit: Int): NetworkResult<PrivateChatListResponseDto> {
return safeApiCall(json) { chatApi.getChatList(offset, limit) }
}
override suspend fun getChatHistory(chatId: String, beforeMessageId: Int?, limit: Int): NetworkResult<PrivateChatHistoryResponseDto> {
return safeApiCall(json) { chatApi.getChatHistory(chatId, beforeMessageId, limit) }
}
override suspend fun createChat(targetUserId: String): NetworkResult<PrivateChatCreateResponseDto> {
return safeApiCall(json) { chatApi.createChat(targetUserId) }
}
override suspend fun sendMessage(chatId: String, content: String): NetworkResult<PrivateMessageSendResponseDto> {
return safeApiCall(json) {
chatApi.sendMessage(PrivateMessageSendRequestDto(chatId = chatId, content = content))
}
}
override suspend fun deleteChat(chatId: String, deleteForBoth: Boolean): NetworkResult<BaseResponseDto> {
return safeApiCall(json) {
chatApi.deleteChat(PrivateChatDeleteRequestDto(chatId = chatId, deleteForBoth = deleteForBoth))
}
}
}

View File

@ -0,0 +1,19 @@
package org.yobble.messenger.data.repository
import kotlinx.serialization.json.Json
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.api.FeedApi
import org.yobble.messenger.data.remote.dto.UserSearchResponseDto
import org.yobble.messenger.data.remote.safeApiCall
import org.yobble.messenger.domain.repository.FeedRepository
import javax.inject.Inject
class FeedRepositoryImpl @Inject constructor(
private val feedApi: FeedApi,
private val json: Json
) : FeedRepository {
override suspend fun searchUsers(query: String): NetworkResult<UserSearchResponseDto> {
return safeApiCall(json) { feedApi.searchUsers(query) }
}
}

View File

@ -0,0 +1,44 @@
package org.yobble.messenger.data.repository
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.api.ProfileApi
import org.yobble.messenger.data.remote.api.StorageApi
import org.yobble.messenger.data.remote.dto.BaseResponseDto
import org.yobble.messenger.data.remote.dto.ProfileByUserIdResponseDto
import org.yobble.messenger.data.remote.dto.ProfileResponseDto
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
import org.yobble.messenger.data.remote.dto.UploadAvatarResponseDto
import org.yobble.messenger.data.remote.safeApiCall
import org.yobble.messenger.domain.repository.ProfileRepository
import java.io.File
import javax.inject.Inject
class ProfileRepositoryImpl @Inject constructor(
private val profileApi: ProfileApi,
private val storageApi: StorageApi,
private val json: Json
) : ProfileRepository {
override suspend fun getMyProfile(): NetworkResult<ProfileResponseDto> {
return safeApiCall(json) { profileApi.getMyProfile() }
}
override suspend fun editProfile(request: ProfileUpdateRequestDto): NetworkResult<BaseResponseDto> {
return safeApiCall(json) { profileApi.editProfile(request) }
}
override suspend fun getUserProfile(userId: String): NetworkResult<ProfileByUserIdResponseDto> {
return safeApiCall(json) { profileApi.getUserProfile(userId) }
}
override suspend fun uploadAvatar(file: File, mimeType: String): NetworkResult<UploadAvatarResponseDto> {
val mediaType = mimeType.toMediaType()
val requestBody = file.asRequestBody(mediaType)
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
return safeApiCall(json) { storageApi.uploadAvatar(part) }
}
}

View File

@ -0,0 +1,47 @@
package org.yobble.messenger.data.repository
import kotlinx.serialization.json.Json
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.api.UserApi
import org.yobble.messenger.data.remote.dto.*
import org.yobble.messenger.data.remote.safeApiCall
import org.yobble.messenger.domain.repository.UserRepository
import javax.inject.Inject
class UserRepositoryImpl @Inject constructor(
private val userApi: UserApi,
private val json: Json
) : UserRepository {
override suspend fun getContacts(offset: Int, limit: Int): NetworkResult<ContactListResponseDto> {
return safeApiCall(json) { userApi.getContacts(offset, limit) }
}
override suspend fun getContactCount(): NetworkResult<ContactCountResponseDto> {
return safeApiCall(json) { userApi.getContactCount() }
}
override suspend fun addContact(request: ContactCreateRequestDto): NetworkResult<ContactCreateResponseDto> {
return safeApiCall(json) { userApi.addContact(request) }
}
override suspend fun updateContact(request: ContactUpdateRequestDto): NetworkResult<BaseResponseDto> {
return safeApiCall(json) { userApi.updateContact(request) }
}
override suspend fun removeContact(userId: String): NetworkResult<BaseResponseDto> {
return safeApiCall(json) { userApi.removeContact(ContactDeleteRequestDto(userId)) }
}
override suspend fun getBlacklist(offset: Int, limit: Int): NetworkResult<BlacklistListResponseDto> {
return safeApiCall(json) { userApi.getBlacklist(offset, limit) }
}
override suspend fun addToBlacklist(request: BlacklistCreateRequestDto): NetworkResult<BlacklistCreateResponseDto> {
return safeApiCall(json) { userApi.addToBlacklist(request) }
}
override suspend fun removeFromBlacklist(userId: String): NetworkResult<BaseResponseDto> {
return safeApiCall(json) { userApi.removeFromBlacklist(BlacklistDeleteRequestDto(userId)) }
}
}

View File

@ -0,0 +1,101 @@
package org.yobble.messenger.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.yobble.messenger.BuildConfig
import org.yobble.messenger.data.remote.api.AuthApi
import org.yobble.messenger.data.remote.api.ChatPrivateApi
import org.yobble.messenger.data.remote.api.FeedApi
import org.yobble.messenger.data.remote.api.ProfileApi
import org.yobble.messenger.data.remote.api.StorageApi
import org.yobble.messenger.data.remote.api.UserApi
import org.yobble.messenger.data.remote.interceptor.AuthInterceptor
import org.yobble.messenger.data.remote.interceptor.TokenAuthenticator
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
encodeDefaults = true
explicitNulls = false
}
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
.authenticator(tokenAuthenticator)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
}
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi {
return retrofit.create(AuthApi::class.java)
}
@Provides
@Singleton
fun provideProfileApi(retrofit: Retrofit): ProfileApi {
return retrofit.create(ProfileApi::class.java)
}
@Provides
@Singleton
fun provideChatPrivateApi(retrofit: Retrofit): ChatPrivateApi {
return retrofit.create(ChatPrivateApi::class.java)
}
@Provides
@Singleton
fun provideStorageApi(retrofit: Retrofit): StorageApi {
return retrofit.create(StorageApi::class.java)
}
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi {
return retrofit.create(UserApi::class.java)
}
@Provides
@Singleton
fun provideFeedApi(retrofit: Retrofit): FeedApi {
return retrofit.create(FeedApi::class.java)
}
}

View File

@ -0,0 +1,42 @@
package org.yobble.messenger.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.yobble.messenger.data.repository.AuthRepositoryImpl
import org.yobble.messenger.data.repository.ChatRepositoryImpl
import org.yobble.messenger.data.repository.FeedRepositoryImpl
import org.yobble.messenger.data.repository.ProfileRepositoryImpl
import org.yobble.messenger.data.repository.UserRepositoryImpl
import org.yobble.messenger.domain.repository.AuthRepository
import org.yobble.messenger.domain.repository.ChatRepository
import org.yobble.messenger.domain.repository.FeedRepository
import org.yobble.messenger.domain.repository.ProfileRepository
import org.yobble.messenger.domain.repository.UserRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
@Binds
@Singleton
abstract fun bindProfileRepository(impl: ProfileRepositoryImpl): ProfileRepository
@Binds
@Singleton
abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
@Binds
@Singleton
abstract fun bindFeedRepository(impl: FeedRepositoryImpl): FeedRepository
}

View File

@ -0,0 +1,21 @@
package org.yobble.messenger.domain.repository
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.BaseResponseDto
import org.yobble.messenger.data.remote.dto.LoginResponseDto
import org.yobble.messenger.data.remote.dto.RegisterResponseDto
import org.yobble.messenger.data.remote.dto.SessionsListResponseDto
interface AuthRepository {
suspend fun login(login: String, password: String): NetworkResult<LoginResponseDto>
suspend fun requestLoginCode(login: String): NetworkResult<BaseResponseDto>
suspend fun verifyCode(login: String, otp: String): NetworkResult<LoginResponseDto>
suspend fun register(login: String, password: String, invite: String? = null): NetworkResult<RegisterResponseDto>
suspend fun updatePushToken(fcmToken: String): NetworkResult<BaseResponseDto>
suspend fun changePassword(oldPassword: String, newPassword: String): NetworkResult<BaseResponseDto>
suspend fun getSessionsList(): NetworkResult<SessionsListResponseDto>
suspend fun revokeSession(sessionId: String): NetworkResult<BaseResponseDto>
suspend fun revokeAllExceptCurrent(): NetworkResult<BaseResponseDto>
suspend fun isLoggedIn(): Boolean
fun logout()
}

View File

@ -0,0 +1,12 @@
package org.yobble.messenger.domain.repository
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.*
interface ChatRepository {
suspend fun getChatList(offset: Int = 0, limit: Int = 20): NetworkResult<PrivateChatListResponseDto>
suspend fun getChatHistory(chatId: String, beforeMessageId: Int? = null, limit: Int = 30): NetworkResult<PrivateChatHistoryResponseDto>
suspend fun createChat(targetUserId: String): NetworkResult<PrivateChatCreateResponseDto>
suspend fun sendMessage(chatId: String, content: String): NetworkResult<PrivateMessageSendResponseDto>
suspend fun deleteChat(chatId: String, deleteForBoth: Boolean = false): NetworkResult<BaseResponseDto>
}

View File

@ -0,0 +1,8 @@
package org.yobble.messenger.domain.repository
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.UserSearchResponseDto
interface FeedRepository {
suspend fun searchUsers(query: String): NetworkResult<UserSearchResponseDto>
}

View File

@ -0,0 +1,16 @@
package org.yobble.messenger.domain.repository
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.BaseResponseDto
import org.yobble.messenger.data.remote.dto.ProfileByUserIdResponseDto
import org.yobble.messenger.data.remote.dto.ProfileResponseDto
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
import org.yobble.messenger.data.remote.dto.UploadAvatarResponseDto
import java.io.File
interface ProfileRepository {
suspend fun getMyProfile(): NetworkResult<ProfileResponseDto>
suspend fun editProfile(request: ProfileUpdateRequestDto): NetworkResult<BaseResponseDto>
suspend fun getUserProfile(userId: String): NetworkResult<ProfileByUserIdResponseDto>
suspend fun uploadAvatar(file: File, mimeType: String): NetworkResult<UploadAvatarResponseDto>
}

View File

@ -0,0 +1,18 @@
package org.yobble.messenger.domain.repository
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.*
interface UserRepository {
// Contacts
suspend fun getContacts(offset: Int = 0, limit: Int = 20): NetworkResult<ContactListResponseDto>
suspend fun getContactCount(): NetworkResult<ContactCountResponseDto>
suspend fun addContact(request: ContactCreateRequestDto): NetworkResult<ContactCreateResponseDto>
suspend fun updateContact(request: ContactUpdateRequestDto): NetworkResult<BaseResponseDto>
suspend fun removeContact(userId: String): NetworkResult<BaseResponseDto>
// Blacklist
suspend fun getBlacklist(offset: Int = 0, limit: Int = 20): NetworkResult<BlacklistListResponseDto>
suspend fun addToBlacklist(request: BlacklistCreateRequestDto): NetworkResult<BlacklistCreateResponseDto>
suspend fun removeFromBlacklist(userId: String): NetworkResult<BaseResponseDto>
}

View File

@ -0,0 +1,45 @@
package org.yobble.messenger.domain.validation
object AuthValidator {
fun validateLogin(login: String): ValidationResult {
return when {
login.isBlank() -> ValidationResult.Error("Login must not be empty")
login.contains(" ") -> ValidationResult.Error("Login must not contain whitespace characters")
login.length < 3 -> ValidationResult.Error("Login must be at least 3 characters")
login.length > 64 -> ValidationResult.Error("Login must not exceed 64 characters")
else -> ValidationResult.Valid
}
}
fun validatePassword(password: String): ValidationResult {
return when {
password.isBlank() -> ValidationResult.Error("Password must not be empty")
password.length < 8 -> ValidationResult.Error("Password must be at least 8 characters")
password.length > 128 -> ValidationResult.Error("Password must not exceed 128 characters")
else -> ValidationResult.Valid
}
}
fun validatePasswordConfirmation(password: String, confirmation: String): ValidationResult {
return when {
confirmation.isBlank() -> ValidationResult.Error("Please confirm your password")
password != confirmation -> ValidationResult.Error("Passwords do not match")
else -> ValidationResult.Valid
}
}
fun validateOtp(otp: String): ValidationResult {
return when {
otp.isBlank() -> ValidationResult.Error("Code must not be empty")
otp.length < 4 -> ValidationResult.Error("Code must be at least 4 characters")
otp.length > 8 -> ValidationResult.Error("Code must not exceed 8 characters")
else -> ValidationResult.Valid
}
}
}
sealed class ValidationResult {
data object Valid : ValidationResult()
data class Error(val message: String) : ValidationResult()
}

View File

@ -0,0 +1,150 @@
package org.yobble.messenger.presentation.auth.code
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CodeVerificationScreen(
login: String,
onNavigateBack: () -> Unit,
onVerifySuccess: () -> Unit,
viewModel: CodeVerificationViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is CodeVerificationEvent.VerifySuccess -> onVerifySuccess()
is CodeVerificationEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Verification") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
Text(
text = "Enter the code",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "We sent a verification code to\n$login",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(40.dp))
OutlinedTextField(
value = uiState.code,
onValueChange = viewModel::onCodeChange,
label = { Text("Code") },
singleLine = true,
isError = uiState.codeError != null,
supportingText = uiState.codeError?.let { error -> { Text(error) } },
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
fontSize = 24.sp,
letterSpacing = 8.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.verifyCode()
}
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = viewModel::verifyCode,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
"Verify",
style = MaterialTheme.typography.labelLarge
)
}
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = viewModel::resendCode,
enabled = !uiState.isLoading
) {
Text(
"Resend Code",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}

View File

@ -0,0 +1,101 @@
package org.yobble.messenger.presentation.auth.code
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.domain.repository.AuthRepository
import org.yobble.messenger.domain.validation.AuthValidator
import org.yobble.messenger.domain.validation.ValidationResult
import javax.inject.Inject
data class CodeVerificationUiState(
val login: String = "",
val code: String = "",
val codeError: String? = null,
val isLoading: Boolean = false
)
sealed class CodeVerificationEvent {
data object VerifySuccess : CodeVerificationEvent()
data class ShowError(val message: String) : CodeVerificationEvent()
}
@HiltViewModel
class CodeVerificationViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(
CodeVerificationUiState(
login = savedStateHandle.get<String>("login") ?: ""
)
)
val uiState: StateFlow<CodeVerificationUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<CodeVerificationEvent>()
val events: SharedFlow<CodeVerificationEvent> = _events.asSharedFlow()
fun onCodeChange(value: String) {
if (value.length <= 8) {
_uiState.update { it.copy(code = value, codeError = null) }
}
}
fun verifyCode() {
val state = _uiState.value
val otpValidation = AuthValidator.validateOtp(state.code)
if (otpValidation is ValidationResult.Error) {
_uiState.update { it.copy(codeError = otpValidation.message) }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = authRepository.verifyCode(state.login, state.code)) {
is NetworkResult.Success -> {
_events.emit(CodeVerificationEvent.VerifySuccess)
}
is NetworkResult.Error -> {
val errorMessage = result.errors.firstOrNull()?.message
?: result.message
?: "Verification failed"
_events.emit(CodeVerificationEvent.ShowError(errorMessage))
}
is NetworkResult.Exception -> {
_events.emit(CodeVerificationEvent.ShowError("Connection error. Please try again."))
}
}
_uiState.update { it.copy(isLoading = false) }
}
}
fun resendCode() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (authRepository.requestLoginCode(_uiState.value.login)) {
is NetworkResult.Success -> {
_events.emit(CodeVerificationEvent.ShowError("Code sent successfully"))
}
is NetworkResult.Error -> {
_events.emit(CodeVerificationEvent.ShowError("Failed to resend code"))
}
is NetworkResult.Exception -> {
_events.emit(CodeVerificationEvent.ShowError("Connection error"))
}
}
_uiState.update { it.copy(isLoading = false) }
}
}
}

View File

@ -0,0 +1,195 @@
package org.yobble.messenger.presentation.auth.login
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
@Composable
fun LoginScreen(
onNavigateToRegister: () -> Unit,
onNavigateToCodeLogin: (String) -> Unit,
onLoginSuccess: () -> Unit,
viewModel: LoginViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is LoginEvent.LoginSuccess -> onLoginSuccess()
is LoginEvent.CodeRequested -> onNavigateToCodeLogin(event.login)
is LoginEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(80.dp))
Icon(
imageVector = Icons.Default.ChatBubble,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Yobble",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Sign in to your account",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(40.dp))
OutlinedTextField(
value = uiState.login,
onValueChange = viewModel::onLoginChange,
label = { Text("Login") },
singleLine = true,
isError = uiState.loginError != null,
supportingText = uiState.loginError?.let { error -> { Text(error) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = uiState.password,
onValueChange = viewModel::onPasswordChange,
label = { Text("Password") },
singleLine = true,
isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { error -> { Text(error) } },
visualTransformation = if (uiState.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.login()
}
),
trailingIcon = {
IconButton(onClick = viewModel::togglePasswordVisibility) {
Icon(
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (uiState.isPasswordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = viewModel::login,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
"Log In",
style = MaterialTheme.typography.labelLarge
)
}
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = viewModel::requestLoginCode) {
Text(
"Login with Code",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.padding(bottom = 32.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Don't have an account?",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = onNavigateToRegister) {
Text(
"Register",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}

View File

@ -0,0 +1,121 @@
package org.yobble.messenger.presentation.auth.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.domain.repository.AuthRepository
import org.yobble.messenger.domain.validation.AuthValidator
import org.yobble.messenger.domain.validation.ValidationResult
import javax.inject.Inject
data class LoginUiState(
val login: String = "",
val password: String = "",
val loginError: String? = null,
val passwordError: String? = null,
val isLoading: Boolean = false,
val isPasswordVisible: Boolean = false
)
sealed class LoginEvent {
data object LoginSuccess : LoginEvent()
data class CodeRequested(val login: String) : LoginEvent()
data class ShowError(val message: String) : LoginEvent()
}
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<LoginEvent>()
val events: SharedFlow<LoginEvent> = _events.asSharedFlow()
fun onLoginChange(value: String) {
_uiState.update { it.copy(login = value, loginError = null) }
}
fun onPasswordChange(value: String) {
_uiState.update { it.copy(password = value, passwordError = null) }
}
fun togglePasswordVisibility() {
_uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
}
fun login() {
val state = _uiState.value
val loginValidation = AuthValidator.validateLogin(state.login)
val passwordValidation = AuthValidator.validatePassword(state.password)
if (loginValidation is ValidationResult.Error || passwordValidation is ValidationResult.Error) {
_uiState.update {
it.copy(
loginError = (loginValidation as? ValidationResult.Error)?.message,
passwordError = (passwordValidation as? ValidationResult.Error)?.message
)
}
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = authRepository.login(state.login, state.password)) {
is NetworkResult.Success -> {
_events.emit(LoginEvent.LoginSuccess)
}
is NetworkResult.Error -> {
val errorMessage = result.errors.firstOrNull()?.message
?: result.message
?: "Login failed"
_events.emit(LoginEvent.ShowError(errorMessage))
}
is NetworkResult.Exception -> {
_events.emit(LoginEvent.ShowError("Connection error. Please try again."))
}
}
_uiState.update { it.copy(isLoading = false) }
}
}
fun requestLoginCode() {
val state = _uiState.value
val loginValidation = AuthValidator.validateLogin(state.login)
if (loginValidation is ValidationResult.Error) {
_uiState.update { it.copy(loginError = loginValidation.message) }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = authRepository.requestLoginCode(state.login)) {
is NetworkResult.Success -> {
_events.emit(LoginEvent.CodeRequested(state.login))
}
is NetworkResult.Error -> {
val errorMessage = result.errors.firstOrNull()?.message
?: result.message
?: "Failed to send code"
_events.emit(LoginEvent.ShowError(errorMessage))
}
is NetworkResult.Exception -> {
_events.emit(LoginEvent.ShowError("Connection error. Please try again."))
}
}
_uiState.update { it.copy(isLoading = false) }
}
}
}

View File

@ -0,0 +1,235 @@
package org.yobble.messenger.presentation.auth.register
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RegisterScreen(
onNavigateBack: () -> Unit,
onRegisterSuccess: () -> Unit,
viewModel: RegisterViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is RegisterEvent.RegisterSuccess -> {
snackbarHostState.showSnackbar("Registration successful! Please log in.")
onRegisterSuccess()
}
is RegisterEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Register") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Create your account",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enter your details to get started",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = uiState.login,
onValueChange = viewModel::onLoginChange,
label = { Text("Login") },
singleLine = true,
isError = uiState.loginError != null,
supportingText = uiState.loginError?.let { error -> { Text(error) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = uiState.password,
onValueChange = viewModel::onPasswordChange,
label = { Text("Password") },
singleLine = true,
isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { error -> { Text(error) } },
visualTransformation = if (uiState.isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
trailingIcon = {
IconButton(onClick = viewModel::togglePasswordVisibility) {
Icon(
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (uiState.isPasswordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = uiState.confirmPassword,
onValueChange = viewModel::onConfirmPasswordChange,
label = { Text("Confirm Password") },
singleLine = true,
isError = uiState.confirmPasswordError != null,
supportingText = uiState.confirmPasswordError?.let { error -> { Text(error) } },
visualTransformation = if (uiState.isConfirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
trailingIcon = {
IconButton(onClick = viewModel::toggleConfirmPasswordVisibility) {
Icon(
imageVector = if (uiState.isConfirmPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (uiState.isConfirmPasswordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = uiState.invite,
onValueChange = viewModel::onInviteChange,
label = { Text("Invite Code (optional)") },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.register()
}
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = viewModel::register,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
"Register",
style = MaterialTheme.typography.labelLarge
)
}
}
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.padding(bottom = 32.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Already have an account?",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = onNavigateBack) {
Text(
"Log In",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}

View File

@ -0,0 +1,113 @@
package org.yobble.messenger.presentation.auth.register
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.domain.repository.AuthRepository
import org.yobble.messenger.domain.validation.AuthValidator
import org.yobble.messenger.domain.validation.ValidationResult
import javax.inject.Inject
data class RegisterUiState(
val login: String = "",
val password: String = "",
val confirmPassword: String = "",
val invite: String = "",
val loginError: String? = null,
val passwordError: String? = null,
val confirmPasswordError: String? = null,
val isLoading: Boolean = false,
val isPasswordVisible: Boolean = false,
val isConfirmPasswordVisible: Boolean = false
)
sealed class RegisterEvent {
data object RegisterSuccess : RegisterEvent()
data class ShowError(val message: String) : RegisterEvent()
}
@HiltViewModel
class RegisterViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(RegisterUiState())
val uiState: StateFlow<RegisterUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<RegisterEvent>()
val events: SharedFlow<RegisterEvent> = _events.asSharedFlow()
fun onLoginChange(value: String) {
_uiState.update { it.copy(login = value, loginError = null) }
}
fun onPasswordChange(value: String) {
_uiState.update { it.copy(password = value, passwordError = null) }
}
fun onConfirmPasswordChange(value: String) {
_uiState.update { it.copy(confirmPassword = value, confirmPasswordError = null) }
}
fun onInviteChange(value: String) {
_uiState.update { it.copy(invite = value) }
}
fun togglePasswordVisibility() {
_uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
}
fun toggleConfirmPasswordVisibility() {
_uiState.update { it.copy(isConfirmPasswordVisible = !it.isConfirmPasswordVisible) }
}
fun register() {
val state = _uiState.value
val loginValidation = AuthValidator.validateLogin(state.login)
val passwordValidation = AuthValidator.validatePassword(state.password)
val confirmValidation = AuthValidator.validatePasswordConfirmation(state.password, state.confirmPassword)
if (loginValidation is ValidationResult.Error ||
passwordValidation is ValidationResult.Error ||
confirmValidation is ValidationResult.Error
) {
_uiState.update {
it.copy(
loginError = (loginValidation as? ValidationResult.Error)?.message,
passwordError = (passwordValidation as? ValidationResult.Error)?.message,
confirmPasswordError = (confirmValidation as? ValidationResult.Error)?.message
)
}
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val invite = state.invite.ifBlank { null }
when (val result = authRepository.register(state.login, state.password, invite)) {
is NetworkResult.Success -> {
_events.emit(RegisterEvent.RegisterSuccess)
}
is NetworkResult.Error -> {
val errorMessage = result.errors.firstOrNull()?.message
?: result.message
?: "Registration failed"
_events.emit(RegisterEvent.ShowError(errorMessage))
}
is NetworkResult.Exception -> {
_events.emit(RegisterEvent.ShowError("Connection error. Please try again."))
}
}
_uiState.update { it.copy(isLoading = false) }
}
}
}

View File

@ -0,0 +1,374 @@
package org.yobble.messenger.presentation.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.dto.MessageItemDto
import org.yobble.messenger.util.formatUtcToLocalTime
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
onNavigateBack: () -> Unit,
onNavigateToProfile: (userId: String) -> Unit = {},
viewModel: ChatViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
// Save scroll position when leaving the chat
DisposableEffect(Unit) {
onDispose {
val messages = viewModel.uiState.value.messages
if (messages.isNotEmpty()) {
val firstVisibleIndex = listState.firstVisibleItemIndex
val reversed = messages.asReversed()
val messageId = reversed.getOrNull(firstVisibleIndex)?.messageId
if (messageId != null) {
viewModel.saveScrollPosition(messageId)
}
}
}
}
// Scroll to saved position after initial load
LaunchedEffect(uiState.scrollToMessageId) {
val targetId = uiState.scrollToMessageId ?: return@LaunchedEffect
val targetInt = targetId.toIntOrNull() ?: return@LaunchedEffect
val reversed = uiState.messages.asReversed()
val index = reversed.indexOfFirst { it.messageId == targetInt }
if (index >= 0) {
listState.scrollToItem(index)
}
viewModel.clearScrollTarget()
}
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is ChatEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
}
}
}
// Show scroll-to-bottom button when scrolled up more than 15 messages
val showScrollToBottom by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 15
}
}
// Load more when scrolled near the top (high indices in reversed layout)
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) viewModel.loadMore()
}
Scaffold(
modifier = Modifier.imePadding(),
contentWindowInsets = WindowInsets(0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
uiState.otherUserId?.let { onNavigateToProfile(it) }
}
) {
Text(uiState.chatTitle, fontWeight = FontWeight.Bold)
if (uiState.isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
if (uiState.rating != null) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
Icons.Default.Star,
contentDescription = "Rating",
tint = Color(0xFFFFC107),
modifier = Modifier.size(16.dp)
)
Text(
text = String.format("%.1f", uiState.rating),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
)
}
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
bottomBar = {
if (uiState.canSendMessage) {
MessageInputBar(
text = uiState.messageText,
onTextChange = viewModel::onMessageTextChange,
onSend = viewModel::sendMessage,
isSending = uiState.isSending
)
} else {
Surface(
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Text(
text = "You can't send messages to this user",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.navigationBarsPadding(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (uiState.isLoading && uiState.messages.isEmpty()) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.Center)
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp),
state = listState,
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(uiState.messages.asReversed(), key = { it.messageId }) { message ->
MessageBubble(
message = message,
isOutgoing = message.senderId == uiState.currentUserId
)
}
if (uiState.isLoading && uiState.messages.isNotEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
AnimatedVisibility(
visible = showScrollToBottom,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
SmallFloatingActionButton(
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(0)
}
},
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
) {
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = "Scroll to bottom"
)
}
}
}
}
}
@Composable
private fun MessageBubble(
message: MessageItemDto,
isOutgoing: Boolean
) {
val bubbleColor = if (isOutgoing)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant
val textColor = if (isOutgoing)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurface
val timeColor = if (isOutgoing)
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else
MaterialTheme.colorScheme.onSurfaceVariant
val alignment = if (isOutgoing) Arrangement.End else Arrangement.Start
val shape = if (isOutgoing)
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
else
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
val time = formatUtcToLocalTime(message.createdAt)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = alignment
) {
Box(
modifier = Modifier
.widthIn(max = 280.dp)
.clip(shape)
.background(bubbleColor)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column {
if (!message.content.isNullOrBlank()) {
Text(
text = message.content,
color = textColor,
fontSize = 15.sp
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = time,
color = timeColor,
fontSize = 11.sp,
modifier = Modifier.align(Alignment.End)
)
}
}
}
}
@Composable
private fun MessageInputBar(
text: String,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
isSending: Boolean
) {
Surface(
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
.navigationBarsPadding(),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Message") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
maxLines = 4,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
shape = RoundedCornerShape(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = onSend,
enabled = text.isNotBlank() && !isSending,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (text.isNotBlank() && !isSending)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (text.isNotBlank() && !isSending)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@ -0,0 +1,222 @@
package org.yobble.messenger.presentation.chat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import android.util.Log
import kotlinx.serialization.json.Json
import org.yobble.messenger.data.local.SessionManager
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.MessageItemDto
import org.yobble.messenger.data.remote.socket.SocketEvent
import org.yobble.messenger.data.remote.socket.SocketManager
import org.yobble.messenger.domain.repository.ChatRepository
import javax.inject.Inject
data class ChatUiState(
val chatId: String = "",
val chatTitle: String = "Chat",
val isVerified: Boolean = false,
val rating: Double? = null,
val canSendMessage: Boolean = true,
val messages: List<MessageItemDto> = emptyList(),
val messageText: String = "",
val isLoading: Boolean = false,
val isSending: Boolean = false,
val hasMore: Boolean = false,
val currentUserId: String = "",
val otherUserId: String? = null,
val scrollToMessageId: String? = null
)
sealed class ChatEvent {
data class ShowError(val message: String) : ChatEvent()
}
@HiltViewModel
class ChatViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val chatRepository: ChatRepository,
private val sessionManager: SessionManager,
private val socketManager: SocketManager,
private val json: Json
) : ViewModel() {
private val chatId = savedStateHandle.get<String>("chatId") ?: ""
private val _uiState = MutableStateFlow(
ChatUiState(
chatId = chatId,
currentUserId = sessionManager.userId ?: "",
messageText = sessionManager.getDraft(chatId)
)
)
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<ChatEvent>()
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val savedMessageId = sessionManager.getLastReadMessageId(chatId)
init {
loadMessages()
observeSocket()
}
private fun observeSocket() {
viewModelScope.launch {
socketManager.events.collect { event ->
when (event) {
is SocketEvent.NewMessage -> {
val data = event.data
val payload = data.optJSONObject("payload") ?: data
val eventChatId = payload.optString("chat_id", "")
if (eventChatId == _uiState.value.chatId) {
// Parse message from socket payload and insert instantly
try {
val message = json.decodeFromString<MessageItemDto>(payload.toString())
val current = _uiState.value.messages
if (current.none { it.messageId == message.messageId }) {
_uiState.update { it.copy(messages = current + message) }
}
} catch (e: Exception) {
Log.w("ChatViewModel", "Failed to parse socket message, reloading", e)
loadMessages()
}
}
}
else -> {}
}
}
}
}
fun loadMessages() {
val chatId = _uiState.value.chatId
if (chatId.isBlank()) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = chatRepository.getChatHistory(chatId)) {
is NetworkResult.Success -> {
val items = result.data.data.items.reversed()
val otherMessage = items.firstOrNull { it.senderId != _uiState.value.currentUserId }
val otherUser = otherMessage?.senderData
val title = otherUser?.customName
?: otherUser?.fullName
?: otherUser?.login?.let { "@$it" }
?: _uiState.value.chatTitle
val scrollTarget = if (_uiState.value.messages.isEmpty()) savedMessageId else null
_uiState.update {
it.copy(
messages = items,
chatTitle = title,
otherUserId = otherMessage?.senderId,
isVerified = otherUser?.isVerified == true,
rating = otherUser?.rating?.rating,
canSendMessage = otherUser?.permissions?.youCanSendMessage != false,
hasMore = result.data.data.hasMore,
isLoading = false,
scrollToMessageId = scrollTarget
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ChatEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load messages"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ChatEvent.ShowError("Connection error"))
}
}
}
}
fun loadMore() {
val state = _uiState.value
if (state.isLoading || !state.hasMore || state.messages.isEmpty()) return
val oldestMessageId = state.messages.first().messageId
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = chatRepository.getChatHistory(state.chatId, beforeMessageId = oldestMessageId)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
messages = result.data.data.items.reversed() + it.messages,
hasMore = result.data.data.hasMore,
isLoading = false
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
}
}
}
}
fun saveScrollPosition(messageId: Int) {
val chatId = _uiState.value.chatId
if (chatId.isNotBlank()) {
sessionManager.saveLastReadMessageId(chatId, messageId.toString())
}
}
fun clearScrollTarget() {
_uiState.update { it.copy(scrollToMessageId = null) }
}
override fun onCleared() {
super.onCleared()
sessionManager.saveDraft(chatId, _uiState.value.messageText)
}
fun onMessageTextChange(text: String) {
_uiState.update { it.copy(messageText = text) }
}
fun sendMessage() {
val state = _uiState.value
val text = state.messageText.trim()
if (text.isBlank() || state.isSending) return
viewModelScope.launch {
_uiState.update { it.copy(isSending = true, messageText = "") }
when (val result = chatRepository.sendMessage(state.chatId, text)) {
is NetworkResult.Success -> {
sessionManager.saveDraft(chatId, "")
loadMessages()
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isSending = false, messageText = text) }
_events.emit(ChatEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to send message"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isSending = false, messageText = text) }
_events.emit(ChatEvent.ShowError("Connection error"))
}
}
_uiState.update { it.copy(isSending = false) }
}
}
}

View File

@ -0,0 +1,88 @@
package org.yobble.messenger.presentation.common
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.request.ImageRequest
@Composable
fun FullScreenImageViewer(
imageUrl: String,
onDismiss: () -> Unit
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = onDismiss)
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 5f)
if (scale > 1f) {
offsetX += pan.x
offsetY += pan.y
} else {
offsetX = 0f
offsetY = 0f
}
}
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
contentScale = ContentScale.Fit
)
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.statusBarsPadding()
) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = Color.White
)
}
}
}
}

View File

@ -0,0 +1,82 @@
package org.yobble.messenger.presentation.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import org.yobble.messenger.BuildConfig
@Composable
fun UserAvatar(
userId: String?,
fileId: String?,
displayName: String,
size: Dp = 52.dp,
fontSize: TextUnit = 20.sp
) {
val avatarUrl = buildAvatarUrl(userId, fileId)
if (avatarUrl != null) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(avatarUrl)
.crossfade(true)
.build(),
contentDescription = "Avatar",
modifier = Modifier
.size(size)
.clip(CircleShape),
contentScale = ContentScale.Crop,
error = {
InitialsAvatar(displayName, size, fontSize)
},
loading = {
InitialsAvatar(displayName, size, fontSize)
}
)
} else {
InitialsAvatar(displayName, size, fontSize)
}
}
@Composable
fun InitialsAvatar(
displayName: String,
size: Dp = 52.dp,
fontSize: TextUnit = 20.sp
) {
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
Text(
text = displayName.take(1).uppercase(),
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.Bold,
fontSize = fontSize
)
}
}
fun buildAvatarUrl(userId: String?, fileId: String?): String? {
if (userId.isNullOrBlank() || fileId.isNullOrBlank()) return null
return "${BuildConfig.BASE_URL}v1/storage/download/avatar/$userId?file_id=$fileId"
}

View File

@ -0,0 +1,430 @@
package org.yobble.messenger.presentation.contacts
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.dto.ContactInfoDto
import org.yobble.messenger.presentation.common.UserAvatar
import kotlin.math.roundToInt
@Composable
fun ContactsScreen(
onNavigateToChat: (chatId: String) -> Unit,
bottomPadding: androidx.compose.ui.unit.Dp,
viewModel: ContactsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is ContactsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
is ContactsEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
is ContactsEvent.NavigateToChat -> onNavigateToChat(event.chatId)
}
}
}
if (uiState.showAddDialog) {
AddContactDialog(
query = uiState.addContactQuery,
onQueryChange = viewModel::onAddContactQueryChange,
onAdd = viewModel::addContact,
onDismiss = viewModel::hideAddDialog,
isAdding = uiState.isAdding
)
}
if (uiState.showRenameDialog) {
RenameContactDialog(
currentName = uiState.renameQuery,
onNameChange = viewModel::onRenameQueryChange,
onSave = viewModel::saveRename,
onDismiss = viewModel::hideRenameDialog,
isSaving = uiState.isRenaming
)
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::showAddDialog,
modifier = Modifier.padding(bottom = bottomPadding),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Default.PersonAdd, contentDescription = "Add contact")
}
}
) { padding ->
if (uiState.isLoading && uiState.contacts.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
} else {
val listState = rememberLazyListState()
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) viewModel.loadMore()
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
state = listState,
contentPadding = PaddingValues(bottom = bottomPadding)
) {
if (uiState.contacts.isEmpty() && !uiState.isLoading) {
item {
Box(
modifier = Modifier
.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"No contacts yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Tap + to add someone",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
items(uiState.contacts, key = { it.userId }) { contact ->
SwipeableContactItem(
contact = contact,
isCreatingChat = uiState.creatingChatForUserId == contact.userId,
onClick = { viewModel.createChatWithContact(contact.userId) },
onRename = { viewModel.showRenameDialog(contact.userId, contact.customName ?: "") },
onRemove = { viewModel.removeContact(contact.userId) }
)
}
}
if (uiState.isLoading && uiState.contacts.isNotEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
}
@Composable
private fun SwipeableContactItem(
contact: ContactInfoDto,
isCreatingChat: Boolean,
onClick: () -> Unit,
onRename: () -> Unit,
onRemove: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val offsetX = remember { Animatable(0f) }
val density = LocalDensity.current
val actionWidthPx = with(density) { 140.dp.toPx() }
Box(
modifier = Modifier
.fillMaxWidth()
.clipToBounds()
) {
// Action buttons behind
Row(
modifier = Modifier
.matchParentSize(),
horizontalArrangement = Arrangement.End
) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(70.dp)
.background(Color(0xFF5C6BC0))
.clickable {
coroutineScope.launch {
offsetX.animateTo(0f, tween(200))
}
onRename()
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
Box(
modifier = Modifier
.fillMaxHeight()
.width(70.dp)
.background(MaterialTheme.colorScheme.error)
.clickable {
coroutineScope.launch {
offsetX.animateTo(0f, tween(200))
}
onRemove()
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
}
// Foreground content
ContactItemContent(
contact = contact,
isCreatingChat = isCreatingChat,
onClick = onClick,
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
coroutineScope.launch {
val target = if (offsetX.value < -actionWidthPx / 2) -actionWidthPx else 0f
offsetX.animateTo(target, tween(200))
}
},
onHorizontalDrag = { change, dragAmount ->
change.consume()
coroutineScope.launch {
val newValue = (offsetX.value + dragAmount).coerceIn(-actionWidthPx, 0f)
offsetX.snapTo(newValue)
}
}
)
}
)
}
HorizontalDivider(
modifier = Modifier.padding(start = 76.dp),
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}
@Composable
private fun ContactItemContent(
contact: ContactInfoDto,
isCreatingChat: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val displayName = contact.customName
?: contact.fullName
?: contact.login?.let { "@$it" }
?: "User"
Row(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable(enabled = !isCreatingChat, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserAvatar(
userId = contact.userId,
fileId = null,
displayName = displayName,
size = 48.dp,
fontSize = 18.sp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (contact.login != null) {
Text(
text = "@${contact.login}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
if (isCreatingChat) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
private fun AddContactDialog(
query: String,
onQueryChange: (String) -> Unit,
onAdd: () -> Unit,
onDismiss: () -> Unit,
isAdding: Boolean
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add contact") },
text = {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
label = { Text("Login") },
placeholder = { Text("username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
},
confirmButton = {
Button(
onClick = onAdd,
enabled = query.isNotBlank() && !isAdding
) {
if (isAdding) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Add")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun RenameContactDialog(
currentName: String,
onNameChange: (String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
isSaving: Boolean
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename contact") },
text = {
OutlinedTextField(
value = currentName,
onValueChange = onNameChange,
label = { Text("Display name") },
placeholder = { Text("Custom name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
},
confirmButton = {
Button(
onClick = onSave,
enabled = !isSaving
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Save")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@ -0,0 +1,233 @@
package org.yobble.messenger.presentation.contacts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.ContactCreateRequestDto
import org.yobble.messenger.data.remote.dto.ContactInfoDto
import org.yobble.messenger.data.remote.dto.ContactUpdateRequestDto
import org.yobble.messenger.domain.repository.ChatRepository
import org.yobble.messenger.domain.repository.UserRepository
import javax.inject.Inject
data class ContactsUiState(
val contacts: List<ContactInfoDto> = emptyList(),
val isLoading: Boolean = false,
val hasMore: Boolean = false,
val addContactQuery: String = "",
val isAdding: Boolean = false,
val showAddDialog: Boolean = false,
val creatingChatForUserId: String? = null,
val showRenameDialog: Boolean = false,
val renameUserId: String? = null,
val renameQuery: String = "",
val isRenaming: Boolean = false
)
sealed class ContactsEvent {
data class ShowError(val message: String) : ContactsEvent()
data class ShowSuccess(val message: String) : ContactsEvent()
data class NavigateToChat(val chatId: String) : ContactsEvent()
}
@HiltViewModel
class ContactsViewModel @Inject constructor(
private val userRepository: UserRepository,
private val chatRepository: ChatRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ContactsUiState())
val uiState: StateFlow<ContactsUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<ContactsEvent>()
val events: SharedFlow<ContactsEvent> = _events.asSharedFlow()
init {
loadContacts()
}
fun loadContacts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = userRepository.getContacts()) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
contacts = result.data.data.items,
hasMore = result.data.data.hasMore,
isLoading = false
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ContactsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load contacts"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ContactsEvent.ShowError("Connection error"))
}
}
}
}
fun loadMore() {
val state = _uiState.value
if (state.isLoading || !state.hasMore) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = userRepository.getContacts(offset = state.contacts.size)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
contacts = it.contacts + result.data.data.items,
hasMore = result.data.data.hasMore,
isLoading = false
)
}
}
is NetworkResult.Error -> _uiState.update { it.copy(isLoading = false) }
is NetworkResult.Exception -> _uiState.update { it.copy(isLoading = false) }
}
}
}
fun showAddDialog() {
_uiState.update { it.copy(showAddDialog = true, addContactQuery = "") }
}
fun hideAddDialog() {
_uiState.update { it.copy(showAddDialog = false) }
}
fun onAddContactQueryChange(value: String) {
_uiState.update { it.copy(addContactQuery = value) }
}
fun addContact() {
val query = _uiState.value.addContactQuery.trim()
if (query.isBlank() || _uiState.value.isAdding) return
val request = if (query.startsWith("@")) {
ContactCreateRequestDto(login = query.removePrefix("@"))
} else {
ContactCreateRequestDto(login = query)
}
viewModelScope.launch {
_uiState.update { it.copy(isAdding = true) }
when (val result = userRepository.addContact(request)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isAdding = false, showAddDialog = false) }
_events.emit(ContactsEvent.ShowSuccess("Contact added"))
loadContacts()
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isAdding = false) }
_events.emit(ContactsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to add contact"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isAdding = false) }
_events.emit(ContactsEvent.ShowError("Connection error"))
}
}
}
}
fun removeContact(userId: String) {
viewModelScope.launch {
when (val result = userRepository.removeContact(userId)) {
is NetworkResult.Success -> {
_events.emit(ContactsEvent.ShowSuccess("Contact removed"))
loadContacts()
}
is NetworkResult.Error -> {
_events.emit(ContactsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to remove contact"
))
}
is NetworkResult.Exception -> {
_events.emit(ContactsEvent.ShowError("Connection error"))
}
}
}
}
fun showRenameDialog(userId: String, currentName: String) {
_uiState.update { it.copy(showRenameDialog = true, renameUserId = userId, renameQuery = currentName) }
}
fun hideRenameDialog() {
_uiState.update { it.copy(showRenameDialog = false, renameUserId = null) }
}
fun onRenameQueryChange(value: String) {
_uiState.update { it.copy(renameQuery = value) }
}
fun saveRename() {
val userId = _uiState.value.renameUserId ?: return
val name = _uiState.value.renameQuery.trim()
if (_uiState.value.isRenaming) return
viewModelScope.launch {
_uiState.update { it.copy(isRenaming = true) }
when (val result = userRepository.updateContact(
ContactUpdateRequestDto(userId, name.ifBlank { null })
)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isRenaming = false, showRenameDialog = false, renameUserId = null) }
_events.emit(ContactsEvent.ShowSuccess("Contact updated"))
loadContacts()
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isRenaming = false) }
_events.emit(ContactsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to update contact"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isRenaming = false) }
_events.emit(ContactsEvent.ShowError("Connection error"))
}
}
}
}
fun createChatWithContact(userId: String) {
if (_uiState.value.creatingChatForUserId != null) return
viewModelScope.launch {
_uiState.update { it.copy(creatingChatForUserId = userId) }
when (val result = chatRepository.createChat(userId)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(creatingChatForUserId = null) }
_events.emit(ContactsEvent.NavigateToChat(result.data.data.chatId))
}
is NetworkResult.Error -> {
_uiState.update { it.copy(creatingChatForUserId = null) }
_events.emit(ContactsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to create chat"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(creatingChatForUserId = null) }
_events.emit(ContactsEvent.ShowError("Connection error"))
}
}
}
}
}

View File

@ -0,0 +1,448 @@
package org.yobble.messenger.presentation.main
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
import org.yobble.messenger.presentation.common.UserAvatar
import org.yobble.messenger.presentation.contacts.ContactsScreen
import org.yobble.messenger.presentation.profile.ProfileScreen
import org.yobble.messenger.presentation.settings.SettingsScreen
import org.yobble.messenger.util.formatUtcToLocalTime
private const val SWIPE_NAVIGATION_ENABLED = true
private enum class HomeTab(val label: String, val icon: ImageVector) {
CHATS("Chats", Icons.AutoMirrored.Filled.Chat),
CONTACTS("Contacts", Icons.Default.Contacts),
SETTINGS("Settings", Icons.Default.Settings),
PROFILE("Profile", Icons.Default.Person)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onNavigateToChat: (chatId: String) -> Unit,
onNavigateToPrivacy: () -> Unit,
onNavigateToSessions: () -> Unit,
onNavigateToChangePassword: () -> Unit,
onNavigateToBlacklist: () -> Unit,
onNavigateToUserProfile: (userId: String) -> Unit = {},
onNavigateToSearch: () -> Unit,
onLogout: () -> Unit,
viewModel: HomeViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val tabs = HomeTab.entries
val pagerState = rememberPagerState(pageCount = { tabs.size })
val coroutineScope = rememberCoroutineScope()
val selectedTab = pagerState.currentPage
val density = LocalDensity.current
var navBarHeightDp by remember { mutableStateOf(0.dp) }
LifecycleResumeEffect(Unit) {
viewModel.loadChats()
onPauseOrDispose { }
}
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is HomeEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
}
}
}
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.padding(bottom = navBarHeightDp)
)
},
topBar = {
TopAppBar(
title = {
Text(
tabs[selectedTab].label,
fontWeight = FontWeight.Bold
)
},
actions = {
IconButton(onClick = onNavigateToSearch) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
contentWindowInsets = WindowInsets(0)
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (SWIPE_NAVIGATION_ENABLED) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1
) { page ->
TabContent(
tab = tabs[page],
uiState = uiState,
onNavigateToChat = onNavigateToChat,
onLoadMore = viewModel::loadMore,
onNavigateToPrivacy = onNavigateToPrivacy,
onNavigateToSessions = onNavigateToSessions,
onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToBlacklist = onNavigateToBlacklist,
onNavigateToUserProfile = onNavigateToUserProfile,
onLogout = {
viewModel.logout()
onLogout()
},
bottomPadding = navBarHeightDp
)
}
} else {
TabContent(
tab = tabs[selectedTab],
uiState = uiState,
onNavigateToChat = onNavigateToChat,
onLoadMore = viewModel::loadMore,
onNavigateToPrivacy = onNavigateToPrivacy,
onNavigateToSessions = onNavigateToSessions,
onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToBlacklist = onNavigateToBlacklist,
onNavigateToUserProfile = onNavigateToUserProfile,
onLogout = {
viewModel.logout()
onLogout()
},
bottomPadding = navBarHeightDp
)
}
// Floating bottom navigation bar
NavigationBar(
modifier = Modifier
.align(Alignment.BottomCenter)
.onGloballyPositioned { coordinates ->
val heightPx = coordinates.size.height
navBarHeightDp = with(density) { heightPx.toDp() }
}
.padding(horizontal = 16.dp, vertical = 12.dp)
.clip(RoundedCornerShape(24.dp))
.navigationBarsPadding(),
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
tonalElevation = 8.dp
) {
tabs.forEachIndexed { index, tab ->
NavigationBarItem(
selected = selectedTab == index,
onClick = {
coroutineScope.launch {
if (SWIPE_NAVIGATION_ENABLED) {
pagerState.animateScrollToPage(index)
} else {
pagerState.scrollToPage(index)
}
}
},
icon = { Icon(tab.icon, contentDescription = null) },
label = { Text(tab.label, fontSize = 11.sp) },
colors = NavigationBarItemDefaults.colors(
indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
)
)
}
}
}
}
}
@Composable
private fun TabContent(
tab: HomeTab,
uiState: HomeUiState,
onNavigateToChat: (String) -> Unit,
onLoadMore: () -> Unit,
onNavigateToPrivacy: () -> Unit,
onNavigateToSessions: () -> Unit,
onNavigateToChangePassword: () -> Unit,
onNavigateToBlacklist: () -> Unit,
onNavigateToUserProfile: (String) -> Unit,
onLogout: () -> Unit,
bottomPadding: androidx.compose.ui.unit.Dp
) {
when (tab) {
HomeTab.CHATS -> ChatsContent(
uiState = uiState,
onNavigateToChat = onNavigateToChat,
onLoadMore = onLoadMore,
bottomPadding = bottomPadding
)
HomeTab.CONTACTS -> ContactsScreen(
onNavigateToChat = onNavigateToChat,
bottomPadding = bottomPadding
)
HomeTab.SETTINGS -> SettingsScreen(
onNavigateToPrivacy = onNavigateToPrivacy,
onNavigateToSessions = onNavigateToSessions,
onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToBlacklist = onNavigateToBlacklist,
onLogout = onLogout,
bottomPadding = bottomPadding
)
HomeTab.PROFILE -> ProfileScreen(
onNavigateBack = { /* already on home, no-op */ },
onNavigateToChat = onNavigateToChat,
showTopBar = false,
bottomPadding = bottomPadding
)
}
}
@Composable
private fun ChatsContent(
uiState: HomeUiState,
onNavigateToChat: (String) -> Unit,
onLoadMore: () -> Unit,
bottomPadding: androidx.compose.ui.unit.Dp
) {
if (uiState.isLoading && uiState.chats.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
} else if (uiState.chats.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"No chats yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Start a new conversation",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
val listState = rememberLazyListState()
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) onLoadMore()
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(bottom = bottomPadding)
) {
items(uiState.chats, key = { it.chatId }) { chat ->
ChatListItem(
chat = chat,
onClick = { onNavigateToChat(chat.chatId) }
)
}
if (uiState.isLoading && uiState.chats.isNotEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
@Composable
private fun ChatListItem(
chat: PrivateChatListItemDto,
onClick: () -> Unit
) {
val chatData = chat.chatData
val displayName = chatData?.customName
?: chatData?.fullName
?: chatData?.login?.let { "@$it" }
?: chat.chatType.replaceFirstChar { it.uppercase() }
val isVerified = chatData?.isVerified == true
val rating = chatData?.rating?.rating
val lastMessageText = chat.lastMessage?.content ?: ""
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserAvatar(
userId = chatData?.userId,
fileId = chatData?.avatars?.current?.fileId,
displayName = displayName
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
if (rating != null) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
Icons.Default.Star,
contentDescription = "Rating",
tint = Color(0xFFFFC107),
modifier = Modifier.size(14.dp)
)
Text(
text = String.format("%.1f", rating),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = time,
style = MaterialTheme.typography.bodySmall,
color = if (chat.unreadCount > 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = lastMessageText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (chat.unreadCount > 0) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (chat.unreadCount > 99) "99+" else chat.unreadCount.toString(),
color = MaterialTheme.colorScheme.onPrimary,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
HorizontalDivider(
modifier = Modifier.padding(start = 80.dp),
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}

View File

@ -0,0 +1,132 @@
package org.yobble.messenger.presentation.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
import org.yobble.messenger.data.remote.socket.SocketEvent
import org.yobble.messenger.data.remote.socket.SocketManager
import org.yobble.messenger.domain.repository.AuthRepository
import org.yobble.messenger.domain.repository.ChatRepository
import javax.inject.Inject
data class HomeUiState(
val chats: List<PrivateChatListItemDto> = emptyList(),
val isLoading: Boolean = false,
val hasMore: Boolean = false
)
sealed class HomeEvent {
data class ShowError(val message: String) : HomeEvent()
}
@HiltViewModel
class HomeViewModel @Inject constructor(
private val chatRepository: ChatRepository,
private val authRepository: AuthRepository,
private val socketManager: SocketManager
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<HomeEvent>()
val events: SharedFlow<HomeEvent> = _events.asSharedFlow()
private var refreshJob: Job? = null
init {
socketManager.connect()
loadChats()
observeSocket()
}
private fun observeSocket() {
viewModelScope.launch {
socketManager.events.collect { event ->
when (event) {
is SocketEvent.NewMessage -> debouncedRefresh()
is SocketEvent.Connected -> loadChats()
is SocketEvent.Disconnected -> {}
}
}
}
}
private fun debouncedRefresh() {
refreshJob?.cancel()
refreshJob = viewModelScope.launch {
delay(300)
loadChats()
}
}
fun loadChats() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = chatRepository.getChatList()) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
chats = result.data.data.items,
hasMore = result.data.data.hasMore,
isLoading = false
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(HomeEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load chats"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(HomeEvent.ShowError("Connection error"))
}
}
}
}
fun loadMore() {
val state = _uiState.value
if (state.isLoading || !state.hasMore) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = chatRepository.getChatList(offset = state.chats.size)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
chats = it.chats + result.data.data.items,
hasMore = result.data.data.hasMore,
isLoading = false
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
}
}
}
}
fun logout() {
socketManager.disconnect()
authRepository.logout()
}
}

View File

@ -0,0 +1,174 @@
package org.yobble.messenger.presentation.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import org.yobble.messenger.presentation.auth.code.CodeVerificationScreen
import org.yobble.messenger.presentation.auth.login.LoginScreen
import org.yobble.messenger.presentation.auth.register.RegisterScreen
import org.yobble.messenger.presentation.chat.ChatScreen
import org.yobble.messenger.presentation.main.HomeScreen
import org.yobble.messenger.presentation.profile.ProfileScreen
import org.yobble.messenger.presentation.search.SearchScreen
import org.yobble.messenger.presentation.settings.BlacklistScreen
import org.yobble.messenger.presentation.settings.ChangePasswordScreen
import org.yobble.messenger.presentation.settings.PrivacyScreen
import org.yobble.messenger.presentation.settings.SessionsScreen
object Routes {
const val LOGIN = "login"
const val REGISTER = "register"
const val CODE_VERIFICATION = "code_verification/{login}"
const val HOME = "home"
const val CHAT = "chat/{chatId}"
const val USER_PROFILE = "profile/{userId}"
const val PRIVACY = "settings/privacy"
const val SESSIONS = "settings/sessions"
const val CHANGE_PASSWORD = "settings/change_password"
const val BLACKLIST = "settings/blacklist"
const val SEARCH = "search"
fun codeVerification(login: String) = "code_verification/$login"
fun chat(chatId: String) = "chat/$chatId"
fun userProfile(userId: String) = "profile/$userId"
}
@Composable
fun AppNavGraph(
navController: NavHostController,
startDestination: String
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
composable(Routes.LOGIN) {
LoginScreen(
onNavigateToRegister = {
navController.navigate(Routes.REGISTER)
},
onNavigateToCodeLogin = { login ->
navController.navigate(Routes.codeVerification(login))
},
onLoginSuccess = {
navController.navigate(Routes.HOME) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
}
)
}
composable(Routes.REGISTER) {
RegisterScreen(
onNavigateBack = {
navController.popBackStack()
},
onRegisterSuccess = {
navController.popBackStack(Routes.LOGIN, false)
}
)
}
composable(Routes.CODE_VERIFICATION) { backStackEntry ->
val login = backStackEntry.arguments?.getString("login") ?: ""
CodeVerificationScreen(
login = login,
onNavigateBack = {
navController.popBackStack()
},
onVerifySuccess = {
navController.navigate(Routes.HOME) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
}
)
}
composable(Routes.HOME) {
HomeScreen(
onNavigateToChat = { chatId ->
navController.navigate(Routes.chat(chatId))
},
onNavigateToPrivacy = {
navController.navigate(Routes.PRIVACY)
},
onNavigateToSessions = {
navController.navigate(Routes.SESSIONS)
},
onNavigateToChangePassword = {
navController.navigate(Routes.CHANGE_PASSWORD)
},
onNavigateToBlacklist = {
navController.navigate(Routes.BLACKLIST)
},
onNavigateToUserProfile = { userId ->
navController.navigate(Routes.userProfile(userId))
},
onNavigateToSearch = {
navController.navigate(Routes.SEARCH)
},
onLogout = {
navController.navigate(Routes.LOGIN) {
popUpTo(Routes.HOME) { inclusive = true }
}
}
)
}
composable(Routes.USER_PROFILE) {
ProfileScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToChat = { chatId ->
navController.navigate(Routes.chat(chatId))
}
)
}
composable(Routes.CHAT) {
ChatScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToProfile = { userId ->
navController.navigate(Routes.userProfile(userId))
}
)
}
composable(Routes.PRIVACY) {
PrivacyScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
composable(Routes.SESSIONS) {
SessionsScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
composable(Routes.CHANGE_PASSWORD) {
ChangePasswordScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
composable(Routes.BLACKLIST) {
BlacklistScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
composable(Routes.SEARCH) {
SearchScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToProfile = { userId ->
navController.navigate(Routes.userProfile(userId))
}
)
}
}
}

View File

@ -0,0 +1,449 @@
package org.yobble.messenger.presentation.profile
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.yobble.messenger.presentation.common.FullScreenImageViewer
import org.yobble.messenger.presentation.common.UserAvatar
import org.yobble.messenger.presentation.common.buildAvatarUrl
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
onNavigateBack: () -> Unit,
onNavigateToChat: ((chatId: String) -> Unit)? = null,
showTopBar: Boolean = true,
bottomPadding: Dp = 0.dp,
viewModel: ProfileViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is ProfileEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
is ProfileEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
is ProfileEvent.NavigateToChat -> onNavigateToChat?.invoke(event.chatId)
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
if (showTopBar) {
TopAppBar(
title = { Text("Profile", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (uiState.isMyProfile && !uiState.isEditing && uiState.profile != null) {
IconButton(onClick = viewModel::startEditing) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
}
) { padding ->
if (uiState.isLoading && uiState.profile == null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
} else {
val profile = uiState.profile ?: return@Scaffold
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
val context = LocalContext.current
val allowedMimeTypes = setOf("image/jpeg", "image/png", "image/heic", "image/heif", "image/gif")
val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri ?: return@rememberLauncherForActivityResult
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
if (mimeType !in allowedMimeTypes) {
return@rememberLauncherForActivityResult
}
val ext = when (mimeType) {
"image/png" -> "png"
"image/gif" -> "gif"
"image/heic" -> "heic"
"image/heif" -> "heif"
else -> "jpg"
}
val inputStream = context.contentResolver.openInputStream(uri) ?: return@rememberLauncherForActivityResult
val tempFile = File(context.cacheDir, "avatar_upload.$ext")
tempFile.outputStream().use { out -> inputStream.copyTo(out) }
viewModel.uploadAvatar(tempFile, mimeType)
}
val avatarUrl = buildAvatarUrl(profile.userId, profile.avatarFileId)
var showFullScreenAvatar by remember { mutableStateOf(false) }
if (showFullScreenAvatar && avatarUrl != null) {
FullScreenImageViewer(
imageUrl = avatarUrl,
onDismiss = { showFullScreenAvatar = false }
)
}
Spacer(modifier = Modifier.height(24.dp))
// Avatar
Box(contentAlignment = Alignment.BottomEnd) {
Box(
modifier = Modifier.clickable(enabled = avatarUrl != null) {
showFullScreenAvatar = true
}
) {
UserAvatar(
userId = profile.userId,
fileId = profile.avatarFileId,
displayName = profile.displayName,
size = 100.dp,
fontSize = 40.sp
)
}
if (uiState.isMyProfile) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.clickable { imagePicker.launch("image/*") },
contentAlignment = Alignment.Center
) {
if (uiState.isUploading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Icon(
Icons.Default.CameraAlt,
contentDescription = "Change avatar",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp)
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Name + verified
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = profile.displayName,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
if (profile.isVerified) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
Icons.Default.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp)
)
}
}
// Login
if (profile.login.isNotBlank()) {
Text(
text = "@${profile.login}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Rating
if (profile.rating != null) {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = "Rating",
tint = Color(0xFFFFC107),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = String.format("%.1f", profile.rating),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
}
if (!showTopBar && uiState.isMyProfile && !uiState.isEditing && uiState.profile != null) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = viewModel::startEditing) {
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Edit profile")
}
}
if (!uiState.isMyProfile && profile.canSendMessage && onNavigateToChat != null) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = viewModel::createChat,
enabled = !uiState.isCreatingChat
) {
if (uiState.isCreatingChat) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Send message")
}
}
}
Spacer(modifier = Modifier.height(24.dp))
if (uiState.isEditing) {
EditProfileSection(
fullName = uiState.editFullName,
bio = uiState.editBio,
onFullNameChange = viewModel::onFullNameChange,
onBioChange = viewModel::onBioChange,
onSave = viewModel::saveProfile,
onCancel = viewModel::cancelEditing,
isSaving = uiState.isSaving,
canSave = uiState.editBio.length <= 1024
)
} else {
ProfileInfoSection(
profile = profile,
isMyProfile = uiState.isMyProfile
)
}
Spacer(modifier = Modifier.height(24.dp + bottomPadding))
}
}
}
}
@Composable
private fun ProfileInfoSection(
profile: ProfileInfo,
isMyProfile: Boolean
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
if (!profile.bio.isNullOrBlank()) {
ProfileInfoCard(label = "Bio", value = profile.bio)
Spacer(modifier = Modifier.height(12.dp))
}
ProfileInfoCard(
label = "Member since",
value = formatUtcToLocalDate(profile.createdAt)
)
if (isMyProfile && profile.balances.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
ProfileInfoCard(
label = "Balance",
value = profile.balances.joinToString("\n") {
"${it.displayBalance ?: it.balance} ${it.currency.uppercase()}"
}
)
}
if (!isMyProfile) {
val rel = profile.relationship
if (rel != null) {
Spacer(modifier = Modifier.height(12.dp))
if (rel.isTargetInContacts) {
ProfileInfoCard(label = "Contact", value = "In your contacts")
}
if (rel.isTargetBlocked) {
ProfileInfoCard(label = "Blocked", value = "You blocked this user")
}
}
}
}
}
@Composable
private fun ProfileInfoCard(label: String, value: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@Composable
private fun EditProfileSection(
fullName: String,
bio: String,
onFullNameChange: (String) -> Unit,
onBioChange: (String) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit,
isSaving: Boolean,
canSave: Boolean
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
OutlinedTextField(
value = fullName,
onValueChange = onFullNameChange,
label = { Text("Full name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(12.dp))
val bioMaxLength = 1024
val bioOverLimit = bio.length > bioMaxLength
OutlinedTextField(
value = bio,
onValueChange = onBioChange,
label = { Text("Bio") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
isError = bioOverLimit,
shape = RoundedCornerShape(12.dp),
supportingText = {
Text(
text = "${bio.length}/$bioMaxLength",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End,
color = if (bioOverLimit)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f),
enabled = !isSaving
) {
Text("Cancel")
}
Button(
onClick = onSave,
modifier = Modifier.weight(1f),
enabled = !isSaving && canSave
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Save")
}
}
}
}
}
private fun formatUtcToLocalDate(isoString: String): String {
return try {
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
val local = zonedUtc.withZoneSameInstant(java.time.ZoneId.systemDefault())
local.format(java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy"))
} catch (_: Exception) {
isoString.substringBefore("T")
}
}

View File

@ -0,0 +1,258 @@
package org.yobble.messenger.presentation.profile
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
import org.yobble.messenger.data.remote.dto.RatingDataDto
import org.yobble.messenger.data.remote.dto.RelationshipStatusDto
import org.yobble.messenger.data.remote.dto.WalletBalanceDto
import org.yobble.messenger.domain.repository.ChatRepository
import org.yobble.messenger.domain.repository.ProfileRepository
import javax.inject.Inject
data class ProfileInfo(
val userId: String = "",
val login: String = "",
val displayName: String = "",
val bio: String? = null,
val isVerified: Boolean = false,
val isSystem: Boolean = false,
val rating: Double? = null,
val createdAt: String = "",
val avatarFileId: String? = null,
val balances: List<WalletBalanceDto> = emptyList(),
val relationship: RelationshipStatusDto? = null,
val canSendMessage: Boolean = true
)
data class ProfileUiState(
val profile: ProfileInfo? = null,
val isMyProfile: Boolean = true,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val isUploading: Boolean = false,
val isCreatingChat: Boolean = false,
val isEditing: Boolean = false,
val editFullName: String = "",
val editBio: String = ""
)
sealed class ProfileEvent {
data class ShowError(val message: String) : ProfileEvent()
data class ShowSuccess(val message: String) : ProfileEvent()
data class NavigateToChat(val chatId: String) : ProfileEvent()
}
@HiltViewModel
class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val profileRepository: ProfileRepository,
private val chatRepository: ChatRepository
) : ViewModel() {
private val userId: String? = savedStateHandle.get<String>("userId")
private val isMyProfile = userId == null
private val _uiState = MutableStateFlow(ProfileUiState(isMyProfile = isMyProfile))
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<ProfileEvent>()
val events: SharedFlow<ProfileEvent> = _events.asSharedFlow()
init {
loadProfile()
}
fun loadProfile() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
if (isMyProfile) {
loadMyProfile()
} else {
loadUserProfile(userId!!)
}
}
}
private suspend fun loadMyProfile() {
when (val result = profileRepository.getMyProfile()) {
is NetworkResult.Success -> {
val p = result.data.data
val info = ProfileInfo(
userId = p.userId,
login = p.login,
displayName = p.fullName ?: p.login,
bio = p.bio,
isVerified = p.isVerified == true,
rating = p.rating.rating,
createdAt = p.createdAt,
avatarFileId = p.avatars?.current?.fileId,
balances = p.balances
)
_uiState.update {
it.copy(
profile = info,
isLoading = false,
editFullName = p.fullName ?: "",
editBio = p.bio ?: ""
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ProfileEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load profile"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ProfileEvent.ShowError("Connection error"))
}
}
}
private suspend fun loadUserProfile(id: String) {
when (val result = profileRepository.getUserProfile(id)) {
is NetworkResult.Success -> {
val p = result.data.data
val info = ProfileInfo(
userId = p.userId,
login = p.login ?: "",
displayName = p.customName ?: p.fullName ?: p.login?.let { "@$it" } ?: "User",
bio = p.bio,
isVerified = p.isVerified == true,
isSystem = p.isSystem == true,
rating = p.rating.rating,
createdAt = p.createdAt,
avatarFileId = p.avatars?.current?.fileId,
relationship = p.relationship,
canSendMessage = p.permissions.youCanSendMessage
)
_uiState.update { it.copy(profile = info, isLoading = false) }
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ProfileEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load profile"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ProfileEvent.ShowError("Connection error"))
}
}
}
fun startEditing() {
if (!isMyProfile) return
val profile = _uiState.value.profile ?: return
_uiState.update {
it.copy(
isEditing = true,
editFullName = if (profile.displayName != profile.login) profile.displayName else "",
editBio = profile.bio ?: ""
)
}
}
fun cancelEditing() {
_uiState.update { it.copy(isEditing = false) }
}
fun onFullNameChange(value: String) {
_uiState.update { it.copy(editFullName = value) }
}
fun onBioChange(value: String) {
_uiState.update { it.copy(editBio = value) }
}
fun saveProfile() {
val state = _uiState.value
if (state.isSaving || !isMyProfile) return
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true) }
val request = ProfileUpdateRequestDto(
fullName = state.editFullName.trim().ifBlank { null },
bio = state.editBio.trim().ifBlank { null }
)
when (val result = profileRepository.editProfile(request)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isSaving = false, isEditing = false) }
_events.emit(ProfileEvent.ShowSuccess("Profile updated"))
loadProfile()
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isSaving = false) }
_events.emit(ProfileEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to update profile"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isSaving = false) }
_events.emit(ProfileEvent.ShowError("Connection error"))
}
}
}
}
fun uploadAvatar(file: java.io.File, mimeType: String) {
if (!isMyProfile || _uiState.value.isUploading) return
viewModelScope.launch {
_uiState.update { it.copy(isUploading = true) }
when (val result = profileRepository.uploadAvatar(file, mimeType)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isUploading = false) }
_events.emit(ProfileEvent.ShowSuccess("Avatar updated"))
loadProfile()
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isUploading = false) }
_events.emit(ProfileEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to upload avatar"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isUploading = false) }
_events.emit(ProfileEvent.ShowError("Connection error"))
}
}
}
}
fun createChat() {
val targetUserId = userId ?: return
if (_uiState.value.isCreatingChat) return
viewModelScope.launch {
_uiState.update { it.copy(isCreatingChat = true) }
when (val result = chatRepository.createChat(targetUserId)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isCreatingChat = false) }
_events.emit(ProfileEvent.NavigateToChat(result.data.data.chatId))
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isCreatingChat = false) }
_events.emit(ProfileEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to create chat"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isCreatingChat = false) }
_events.emit(ProfileEvent.ShowError("Connection error"))
}
}
}
}
}

View File

@ -0,0 +1,222 @@
package org.yobble.messenger.presentation.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.yobble.messenger.data.remote.dto.UserSearchResultDto
import org.yobble.messenger.presentation.common.UserAvatar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
onNavigateBack: () -> Unit,
onNavigateToProfile: (userId: String) -> Unit,
viewModel: SearchViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Scaffold(
topBar = {
TopAppBar(
title = {
OutlinedTextField(
value = uiState.query,
onValueChange = viewModel::onQueryChange,
placeholder = { Text("Search users...", color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.6f)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = MaterialTheme.colorScheme.onPrimary,
unfocusedTextColor = MaterialTheme.colorScheme.onPrimary,
cursorColor = MaterialTheme.colorScheme.onPrimary,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
),
trailingIcon = {
if (uiState.query.isNotEmpty()) {
IconButton(onClick = { viewModel.onQueryChange("") }) {
Icon(
Icons.Default.Clear,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
)
}
}
}
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when {
uiState.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.primary
)
}
uiState.query.isBlank() -> {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Search by login",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
uiState.results.isEmpty() && !uiState.isLoading -> {
Text(
"No users found",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
else -> {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(uiState.results, key = { it.userId }) { result ->
SearchResultItem(
result = result,
onClick = { onNavigateToProfile(result.userId) }
)
}
}
}
}
}
}
}
@Composable
private fun SearchResultItem(
result: UserSearchResultDto,
onClick: () -> Unit
) {
val profile = result.profile
val displayName = profile?.customName
?: profile?.fullName
?: profile?.login?.let { "@$it" }
?: "User"
val isVerified = profile?.isVerified == true
val rating = profile?.rating?.rating
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserAvatar(
userId = result.userId,
fileId = profile?.avatars?.current?.fileId,
displayName = displayName,
size = 48.dp,
fontSize = 18.sp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
if (rating != null) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
Icons.Default.Star,
contentDescription = "Rating",
tint = Color(0xFFFFC107),
modifier = Modifier.size(14.dp)
)
Text(
text = String.format("%.1f", rating),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (profile?.login != null) {
Text(
text = "@${profile.login}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
}
HorizontalDivider(
modifier = Modifier.padding(start = 76.dp),
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}

View File

@ -0,0 +1,64 @@
package org.yobble.messenger.presentation.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.UserSearchResultDto
import org.yobble.messenger.domain.repository.FeedRepository
import javax.inject.Inject
data class SearchUiState(
val query: String = "",
val results: List<UserSearchResultDto> = emptyList(),
val isLoading: Boolean = false
)
@HiltViewModel
class SearchViewModel @Inject constructor(
private val feedRepository: FeedRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null
fun onQueryChange(query: String) {
_uiState.update { it.copy(query = query) }
searchJob?.cancel()
if (query.isBlank()) {
_uiState.update { it.copy(results = emptyList(), isLoading = false) }
return
}
searchJob = viewModelScope.launch {
delay(400)
_uiState.update { it.copy(isLoading = true) }
when (val result = feedRepository.searchUsers(query.trim())) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
results = result.data.data.users,
isLoading = false
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false, results = emptyList()) }
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false, results = emptyList()) }
}
}
}
}
}

View File

@ -0,0 +1,246 @@
package org.yobble.messenger.presentation.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.yobble.messenger.data.remote.dto.BlacklistInfoDto
import org.yobble.messenger.presentation.common.UserAvatar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BlacklistScreen(
onNavigateBack: () -> Unit,
viewModel: BlacklistViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is BlacklistEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
is BlacklistEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
}
}
}
if (uiState.showBlockDialog) {
BlockUserDialog(
query = uiState.blockQuery,
onQueryChange = viewModel::onBlockQueryChange,
onBlock = viewModel::blockUser,
onDismiss = viewModel::hideBlockDialog,
isBlocking = uiState.isBlocking
)
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Blacklist", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::showBlockDialog,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Default.PersonAdd, contentDescription = "Block user")
}
}
) { padding ->
if (uiState.isLoading && uiState.blacklist.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
} else if (uiState.blacklist.isEmpty() && !uiState.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Block,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Blacklist is empty",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
items(uiState.blacklist, key = { it.userId }) { blocked ->
BlacklistItem(
item = blocked,
onUnblock = { viewModel.unblockUser(blocked.userId) }
)
}
if (uiState.isLoading && uiState.blacklist.isNotEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
}
}
}
@Composable
private fun BlacklistItem(
item: BlacklistInfoDto,
onUnblock: () -> Unit
) {
val displayName = item.fullName
?: item.login?.let { "@$it" }
?: "User"
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserAvatar(
userId = item.userId,
fileId = null,
displayName = displayName,
size = 44.dp,
fontSize = 16.sp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (item.login != null) {
Text(
text = "@${item.login}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
TextButton(onClick = onUnblock) {
Text("Unblock", color = MaterialTheme.colorScheme.error)
}
}
HorizontalDivider(
modifier = Modifier.padding(start = 72.dp),
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}
@Composable
private fun BlockUserDialog(
query: String,
onQueryChange: (String) -> Unit,
onBlock: () -> Unit,
onDismiss: () -> Unit,
isBlocking: Boolean
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Block user") },
text = {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
label = { Text("Login") },
placeholder = { Text("username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
},
confirmButton = {
Button(
onClick = onBlock,
enabled = query.isNotBlank() && !isBlocking,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
if (isBlocking) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onError
)
} else {
Text("Block")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@ -0,0 +1,134 @@
package org.yobble.messenger.presentation.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.BlacklistCreateRequestDto
import org.yobble.messenger.data.remote.dto.BlacklistInfoDto
import org.yobble.messenger.domain.repository.UserRepository
import javax.inject.Inject
data class BlacklistUiState(
val blacklist: List<BlacklistInfoDto> = emptyList(),
val isLoading: Boolean = false,
val hasMore: Boolean = false,
val blockQuery: String = "",
val isBlocking: Boolean = false,
val showBlockDialog: Boolean = false
)
sealed class BlacklistEvent {
data class ShowError(val message: String) : BlacklistEvent()
data class ShowSuccess(val message: String) : BlacklistEvent()
}
@HiltViewModel
class BlacklistViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(BlacklistUiState())
val uiState: StateFlow<BlacklistUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<BlacklistEvent>()
val events: SharedFlow<BlacklistEvent> = _events.asSharedFlow()
init {
loadBlacklist()
}
fun loadBlacklist() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = userRepository.getBlacklist()) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
blacklist = result.data.data.items,
hasMore = result.data.data.hasMore,
isLoading = false
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(BlacklistEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load blacklist"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(BlacklistEvent.ShowError("Connection error"))
}
}
}
}
fun showBlockDialog() {
_uiState.update { it.copy(showBlockDialog = true, blockQuery = "") }
}
fun hideBlockDialog() {
_uiState.update { it.copy(showBlockDialog = false) }
}
fun onBlockQueryChange(value: String) {
_uiState.update { it.copy(blockQuery = value) }
}
fun blockUser() {
val query = _uiState.value.blockQuery.trim()
if (query.isBlank() || _uiState.value.isBlocking) return
val login = if (query.startsWith("@")) query.removePrefix("@") else query
viewModelScope.launch {
_uiState.update { it.copy(isBlocking = true) }
when (val result = userRepository.addToBlacklist(BlacklistCreateRequestDto(login = login))) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isBlocking = false, showBlockDialog = false) }
_events.emit(BlacklistEvent.ShowSuccess("User blocked"))
loadBlacklist()
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isBlocking = false) }
_events.emit(BlacklistEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to block user"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isBlocking = false) }
_events.emit(BlacklistEvent.ShowError("Connection error"))
}
}
}
}
fun unblockUser(userId: String) {
viewModelScope.launch {
when (val result = userRepository.removeFromBlacklist(userId)) {
is NetworkResult.Success -> {
_events.emit(BlacklistEvent.ShowSuccess("User unblocked"))
loadBlacklist()
}
is NetworkResult.Error -> {
_events.emit(BlacklistEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to unblock user"
))
}
is NetworkResult.Exception -> {
_events.emit(BlacklistEvent.ShowError("Connection error"))
}
}
}
}
}

View File

@ -0,0 +1,147 @@
package org.yobble.messenger.presentation.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangePasswordScreen(
onNavigateBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is SettingsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
is SettingsEvent.ShowSuccess -> {
snackbarHostState.showSnackbar(event.message)
onNavigateBack()
}
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Change password", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
PasswordField(
value = uiState.oldPassword,
onValueChange = viewModel::onOldPasswordChange,
label = "Current password"
)
PasswordField(
value = uiState.newPassword,
onValueChange = viewModel::onNewPasswordChange,
label = "New password",
isError = uiState.newPassword.isNotEmpty() && uiState.newPassword.length < 8,
supportingText = if (uiState.newPassword.isNotEmpty() && uiState.newPassword.length < 8)
"At least 8 characters" else null
)
PasswordField(
value = uiState.confirmPassword,
onValueChange = viewModel::onConfirmPasswordChange,
label = "Confirm new password",
isError = uiState.confirmPassword.isNotEmpty() && uiState.confirmPassword != uiState.newPassword,
supportingText = if (uiState.confirmPassword.isNotEmpty() && uiState.confirmPassword != uiState.newPassword)
"Passwords don't match" else null
)
Spacer(modifier = Modifier.height(8.dp))
val canSubmit = uiState.oldPassword.isNotBlank() &&
uiState.newPassword.length >= 8 &&
uiState.newPassword == uiState.confirmPassword &&
!uiState.isChangingPassword
Button(
onClick = viewModel::changePassword,
modifier = Modifier.fillMaxWidth(),
enabled = canSubmit,
shape = RoundedCornerShape(12.dp)
) {
if (uiState.isChangingPassword) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Change password", modifier = Modifier.padding(vertical = 4.dp))
}
}
}
}
}
@Composable
private fun PasswordField(
value: String,
onValueChange: (String) -> Unit,
label: String,
isError: Boolean = false,
supportingText: String? = null
) {
var visible by remember { mutableStateOf(false) }
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = isError,
supportingText = supportingText?.let { { Text(it) } },
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { visible = !visible }) {
Icon(
if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (visible) "Hide" else "Show"
)
}
},
shape = RoundedCornerShape(12.dp)
)
}

View File

@ -0,0 +1,306 @@
package org.yobble.messenger.presentation.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.yobble.messenger.data.remote.dto.ProfilePermissionsRequestDto
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyScreen(
onNavigateBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.loadPermissions()
viewModel.events.collectLatest { event ->
when (event) {
is SettingsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
is SettingsEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Privacy", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { padding ->
val permissions = uiState.permissions
if (uiState.isLoadingPermissions && permissions == null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
} else if (permissions != null) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
) {
SectionHeader("Visibility")
ToggleItem(
title = "Searchable",
subtitle = "Others can find you by search",
checked = permissions.isSearchable,
enabled = !uiState.isSavingPermissions,
onToggle = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(isSearchable = value)
}
}
)
ToggleItem(
title = "Show bio to non-contacts",
subtitle = "Users not in your contacts can see your bio",
checked = permissions.showBioToNonContacts,
enabled = !uiState.isSavingPermissions,
onToggle = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(showBioToNonContacts = value)
}
}
)
ToggleItem(
title = "Show photo to non-contacts",
subtitle = "Users not in your contacts can see your photo",
checked = permissions.showProfilePhotoToNonContacts,
enabled = !uiState.isSavingPermissions,
onToggle = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(showProfilePhotoToNonContacts = value)
}
}
)
ToggleItem(
title = "Show stories to non-contacts",
subtitle = "Users not in your contacts can see your stories",
checked = permissions.showStoriesToNonContacts,
enabled = !uiState.isSavingPermissions,
onToggle = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(showStoriesToNonContacts = value)
}
}
)
SelectItem(
title = "Last seen visibility",
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
selected = permissions.lastSeenVisibility,
enabled = !uiState.isSavingPermissions,
onSelect = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(lastSeenVisibility = value)
}
}
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
SectionHeader("Messages")
ToggleItem(
title = "Allow messages from non-contacts",
subtitle = "Receive messages from users not in your contacts",
checked = permissions.allowMessagesFromNonContacts,
enabled = !uiState.isSavingPermissions,
onToggle = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(allowMessagesFromNonContacts = value)
}
}
)
ToggleItem(
title = "Allow message forwarding",
subtitle = "Others can forward your messages",
checked = permissions.allowMessageForwarding,
enabled = !uiState.isSavingPermissions,
onToggle = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(allowMessageForwarding = value)
}
}
)
ToggleItem(
title = "Force auto-delete in private",
subtitle = "Messages in private chats auto-delete",
checked = permissions.forceAutoDeleteMessagesInPrivate,
enabled = !uiState.isSavingPermissions,
onToggle = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(forceAutoDeleteMessagesInPrivate = value)
}
}
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
SectionHeader("Groups & Invites")
ToggleItem(
title = "Allow server chats",
subtitle = "Participate in server-based chats",
checked = permissions.allowServerChats,
enabled = !uiState.isSavingPermissions,
onToggle = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(allowServerChats = value)
}
}
)
SelectItem(
title = "Public invite",
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
selected = permissions.publicInvitePermission,
enabled = !uiState.isSavingPermissions,
onSelect = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(publicInvitePermission = value)
}
}
)
SelectItem(
title = "Group invite",
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
selected = permissions.groupInvitePermission,
enabled = !uiState.isSavingPermissions,
onSelect = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(groupInvitePermission = value)
}
}
)
SelectItem(
title = "Calls",
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
selected = permissions.callPermission,
enabled = !uiState.isSavingPermissions,
onSelect = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(callPermission = value)
}
}
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
private fun ToggleItem(
title: String,
subtitle: String,
checked: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = checked,
onCheckedChange = onToggle,
enabled = enabled
)
}
}
@Composable
private fun SelectItem(
title: String,
options: List<Pair<String, Int>>,
selected: Int,
enabled: Boolean,
onSelect: (Int) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
SingleChoiceSegmentedButtonRow {
options.forEachIndexed { index, (label, value) ->
SegmentedButton(
selected = selected == value,
onClick = { onSelect(value) },
shape = SegmentedButtonDefaults.itemShape(index, options.size),
enabled = enabled
) {
Text(label, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}

View File

@ -0,0 +1,211 @@
package org.yobble.messenger.presentation.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.yobble.messenger.data.remote.dto.UserSessionItemDto
import org.yobble.messenger.util.formatUtcToLocalTime
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionsScreen(
onNavigateBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.loadSessions()
viewModel.events.collectLatest { event ->
when (event) {
is SettingsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
is SettingsEvent.ShowSuccess -> snackbarHostState.showSnackbar(event.message)
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Sessions", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { padding ->
if (uiState.isLoadingSessions && uiState.sessions.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val otherSessions = uiState.sessions.filter { !it.isCurrent }
if (otherSessions.size > 1) {
item {
OutlinedButton(
onClick = viewModel::revokeAllExceptCurrent,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
shape = RoundedCornerShape(12.dp)
) {
Text("Revoke all other sessions")
}
}
}
val currentSession = uiState.sessions.find { it.isCurrent }
if (currentSession != null) {
item {
Text(
"Current session",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
item {
SessionCard(session = currentSession, onRevoke = null)
}
}
if (otherSessions.isNotEmpty()) {
item {
Text(
"Other sessions",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
items(otherSessions, key = { it.id }) { session ->
SessionCard(
session = session,
onRevoke = { viewModel.revokeSession(session.id) }
)
}
}
}
}
}
}
@Composable
private fun SessionCard(
session: UserSessionItemDto,
onRevoke: (() -> Unit)?
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (session.isCurrent)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.PhoneAndroid,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = session.clientType.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
if (session.isCurrent) {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Current",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
if (!session.ipAddress.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "IP: ${session.ipAddress}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Last active: ${formatSessionDate(session.lastRefreshAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Created: ${formatSessionDate(session.createdAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (onRevoke != null) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = onRevoke,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Revoke")
}
}
}
}
}
private fun formatSessionDate(isoString: String): String {
return try {
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
val local = zonedUtc.withZoneSameInstant(java.time.ZoneId.systemDefault())
local.format(java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm"))
} catch (_: Exception) {
isoString.substringBefore("T")
}
}

View File

@ -0,0 +1,119 @@
package org.yobble.messenger.presentation.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun SettingsScreen(
onNavigateToPrivacy: () -> Unit,
onNavigateToSessions: () -> Unit,
onNavigateToChangePassword: () -> Unit,
onNavigateToBlacklist: () -> Unit,
onLogout: () -> Unit,
bottomPadding: Dp = 0.dp
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 8.dp, bottom = bottomPadding + 8.dp)
) {
SettingsMenuItem(
icon = Icons.Default.Shield,
title = "Privacy",
subtitle = "Visibility, messages, search",
onClick = onNavigateToPrivacy
)
SettingsMenuItem(
icon = Icons.Default.PhoneAndroid,
title = "Sessions",
subtitle = "Active sessions and devices",
onClick = onNavigateToSessions
)
SettingsMenuItem(
icon = Icons.Default.Lock,
title = "Change password",
subtitle = "Update your account password",
onClick = onNavigateToChangePassword
)
SettingsMenuItem(
icon = Icons.Default.Block,
title = "Blacklist",
subtitle = "Blocked users",
onClick = onNavigateToBlacklist
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onLogout,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
shape = RoundedCornerShape(12.dp)
) {
Text("Log out", modifier = Modifier.padding(vertical = 4.dp))
}
}
}
@Composable
private fun SettingsMenuItem(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@ -0,0 +1,221 @@
package org.yobble.messenger.presentation.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.MyProfilePermissionsDto
import org.yobble.messenger.data.remote.dto.ProfilePermissionsRequestDto
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
import org.yobble.messenger.data.remote.dto.UserSessionItemDto
import org.yobble.messenger.domain.repository.AuthRepository
import org.yobble.messenger.domain.repository.ProfileRepository
import javax.inject.Inject
data class SettingsUiState(
val sessions: List<UserSessionItemDto> = emptyList(),
val isLoadingSessions: Boolean = false,
val permissions: MyProfilePermissionsDto? = null,
val isLoadingPermissions: Boolean = false,
val isSavingPermissions: Boolean = false,
val oldPassword: String = "",
val newPassword: String = "",
val confirmPassword: String = "",
val isChangingPassword: Boolean = false
)
sealed class SettingsEvent {
data class ShowError(val message: String) : SettingsEvent()
data class ShowSuccess(val message: String) : SettingsEvent()
}
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val profileRepository: ProfileRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<SettingsEvent>()
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
fun loadSessions() {
viewModelScope.launch {
_uiState.update { it.copy(isLoadingSessions = true) }
when (val result = authRepository.getSessionsList()) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
sessions = result.data.data.sessions,
isLoadingSessions = false
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoadingSessions = false) }
_events.emit(SettingsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load sessions"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoadingSessions = false) }
_events.emit(SettingsEvent.ShowError("Connection error"))
}
}
}
}
fun revokeSession(sessionId: String) {
viewModelScope.launch {
when (val result = authRepository.revokeSession(sessionId)) {
is NetworkResult.Success -> {
_events.emit(SettingsEvent.ShowSuccess("Session revoked"))
loadSessions()
}
is NetworkResult.Error -> {
_events.emit(SettingsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to revoke session"
))
}
is NetworkResult.Exception -> {
_events.emit(SettingsEvent.ShowError("Connection error"))
}
}
}
}
fun revokeAllExceptCurrent() {
viewModelScope.launch {
when (val result = authRepository.revokeAllExceptCurrent()) {
is NetworkResult.Success -> {
_events.emit(SettingsEvent.ShowSuccess("All other sessions revoked"))
loadSessions()
}
is NetworkResult.Error -> {
_events.emit(SettingsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to revoke sessions"
))
}
is NetworkResult.Exception -> {
_events.emit(SettingsEvent.ShowError("Connection error"))
}
}
}
}
fun loadPermissions() {
viewModelScope.launch {
_uiState.update { it.copy(isLoadingPermissions = true) }
when (val result = profileRepository.getMyProfile()) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
permissions = result.data.data.profilePermissions,
isLoadingPermissions = false
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoadingPermissions = false) }
_events.emit(SettingsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load privacy settings"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoadingPermissions = false) }
_events.emit(SettingsEvent.ShowError("Connection error"))
}
}
}
}
fun updatePermission(update: (MyProfilePermissionsDto) -> ProfilePermissionsRequestDto) {
val current = _uiState.value.permissions ?: return
viewModelScope.launch {
_uiState.update { it.copy(isSavingPermissions = true) }
val request = ProfileUpdateRequestDto(profilePermissions = update(current))
when (val result = profileRepository.editProfile(request)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isSavingPermissions = false) }
loadPermissions()
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isSavingPermissions = false) }
_events.emit(SettingsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to update setting"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isSavingPermissions = false) }
_events.emit(SettingsEvent.ShowError("Connection error"))
}
}
}
}
fun onOldPasswordChange(value: String) {
_uiState.update { it.copy(oldPassword = value) }
}
fun onNewPasswordChange(value: String) {
_uiState.update { it.copy(newPassword = value) }
}
fun onConfirmPasswordChange(value: String) {
_uiState.update { it.copy(confirmPassword = value) }
}
fun changePassword() {
val state = _uiState.value
if (state.isChangingPassword) return
if (state.newPassword != state.confirmPassword) {
viewModelScope.launch {
_events.emit(SettingsEvent.ShowError("Passwords don't match"))
}
return
}
if (state.newPassword.length < 8) {
viewModelScope.launch {
_events.emit(SettingsEvent.ShowError("Password must be at least 8 characters"))
}
return
}
viewModelScope.launch {
_uiState.update { it.copy(isChangingPassword = true) }
when (val result = authRepository.changePassword(state.oldPassword, state.newPassword)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isChangingPassword = false,
oldPassword = "",
newPassword = "",
confirmPassword = ""
)
}
_events.emit(SettingsEvent.ShowSuccess("Password changed successfully"))
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isChangingPassword = false) }
_events.emit(SettingsEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to change password"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isChangingPassword = false) }
_events.emit(SettingsEvent.ShowError("Connection error"))
}
}
}
}
}

View File

@ -0,0 +1,31 @@
package org.yobble.messenger.ui.theme
import androidx.compose.ui.graphics.Color
// Telegram-style primary palette
val TelegramBlue = Color(0xFF2AABEE)
val TelegramBlueDark = Color(0xFF0088CC)
val TelegramBlueLight = Color(0xFF6DD0FF)
// Light theme colors
val LightBackground = Color(0xFFF7F7F8)
val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceVariant = Color(0xFFEFEFF4)
val LightOnBackground = Color(0xFF1C1C1E)
val LightOnSurface = Color(0xFF1C1C1E)
val LightOnSurfaceVariant = Color(0xFF8E8E93)
val LightOutline = Color(0xFFC7C7CC)
// Dark theme colors
val DarkBackground = Color(0xFF1C1C1E)
val DarkSurface = Color(0xFF2C2C2E)
val DarkSurfaceVariant = Color(0xFF3A3A3C)
val DarkOnBackground = Color(0xFFF2F2F7)
val DarkOnSurface = Color(0xFFF2F2F7)
val DarkOnSurfaceVariant = Color(0xFF8E8E93)
val DarkOutline = Color(0xFF48484A)
// Semantic colors
val ErrorRed = Color(0xFFFF3B30)
val SuccessGreen = Color(0xFF34C759)
val OnPrimary = Color(0xFFFFFFFF)

View File

@ -0,0 +1,69 @@
package org.yobble.messenger.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColorScheme = lightColorScheme(
primary = TelegramBlue,
onPrimary = OnPrimary,
primaryContainer = TelegramBlueLight,
onPrimaryContainer = TelegramBlueDark,
secondary = TelegramBlueDark,
onSecondary = OnPrimary,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
outline = LightOutline,
error = ErrorRed,
onError = OnPrimary
)
private val DarkColorScheme = darkColorScheme(
primary = TelegramBlue,
onPrimary = OnPrimary,
primaryContainer = TelegramBlueDark,
onPrimaryContainer = TelegramBlueLight,
secondary = TelegramBlueLight,
onSecondary = DarkOnBackground,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
outline = DarkOutline,
error = ErrorRed,
onError = OnPrimary
)
@Composable
fun YobbleTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@ -0,0 +1,74 @@
package org.yobble.messenger.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
// Screen titles — "Yobble"
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 34.sp,
letterSpacing = 0.sp
),
// Section headers — "Create your account"
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = 0.sp
),
// TopAppBar titles
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
// Button text
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp
),
// Secondary text buttons, links
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 18.sp,
letterSpacing = 0.sp
),
// Body text
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
// Subtitles / descriptions
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
// Small captions
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
)
)

View File

@ -0,0 +1,15 @@
package org.yobble.messenger.util
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
fun formatUtcToLocalTime(isoString: String?): String {
if (isoString.isNullOrBlank()) return ""
return try {
val zonedUtc = ZonedDateTime.parse(isoString)
val local = zonedUtc.withZoneSameInstant(java.time.ZoneId.systemDefault())
local.format(DateTimeFormatter.ofPattern("HH:mm"))
} catch (_: Exception) {
isoString.substringAfter("T").take(5)
}
}

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show More