commit 7e8fcfc24a0290542e50ece2daa718c0422b6680 Author: YaAndreyIgorevich Date: Sat Mar 7 03:42:50 2026 +0700 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..368ad42 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..a1f0900 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..35f214a --- /dev/null +++ b/app/google-services.json @@ -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" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/org/yobble/messenger/ExampleInstrumentedTest.kt b/app/src/androidTest/java/org/yobble/messenger/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..72951f8 --- /dev/null +++ b/app/src/androidTest/java/org/yobble/messenger/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3440126 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..d4bb84e Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/org/yobble/messenger/MainActivity.kt b/app/src/main/java/org/yobble/messenger/MainActivity.kt new file mode 100644 index 0000000..1fa18a3 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/MainActivity.kt @@ -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 + ) +} diff --git a/app/src/main/java/org/yobble/messenger/YobbleApp.kt b/app/src/main/java/org/yobble/messenger/YobbleApp.kt new file mode 100644 index 0000000..ca824ae --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/YobbleApp.kt @@ -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" + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/local/PushTokenManager.kt b/app/src/main/java/org/yobble/messenger/data/local/PushTokenManager.kt new file mode 100644 index 0000000..bbac1cd --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/local/PushTokenManager.kt @@ -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 + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/local/SessionManager.kt b/app/src/main/java/org/yobble/messenger/data/local/SessionManager.kt new file mode 100644 index 0000000..849fea3 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/local/SessionManager.kt @@ -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" + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/NetworkResult.kt b/app/src/main/java/org/yobble/messenger/data/remote/NetworkResult.kt new file mode 100644 index 0000000..fc2d5e1 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/NetworkResult.kt @@ -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 { + data class Success(val data: T) : NetworkResult() + data class Error( + val code: Int, + val errors: List = emptyList(), + val message: String? = null + ) : NetworkResult() + data class Exception(val throwable: Throwable) : NetworkResult() +} + +data class FieldError(val field: String, val message: String) + +suspend fun safeApiCall(json: Json, apiCall: suspend () -> Response): NetworkResult { + 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(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) + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/api/AuthApi.kt b/app/src/main/java/org/yobble/messenger/data/remote/api/AuthApi.kt new file mode 100644 index 0000000..724b6d1 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/api/AuthApi.kt @@ -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 + + @POST("v1/auth/login/code") + suspend fun requestLoginCode( + @Body request: LoginCodeRequestDto, + @Header("X-Client-Type") clientType: String = "android" + ): Response + + @POST("v1/auth/login/verify_code") + suspend fun verifyCode( + @Body request: VerifyCodeRequestDto, + @Header("X-Client-Type") clientType: String = "android" + ): Response + + @POST("v1/auth/register") + suspend fun register( + @Body request: RegisterRequestDto + ): Response + + @POST("v1/auth/token/refresh") + suspend fun refreshToken( + @Body request: TokenRefreshRequestDto + ): Response + + @POST("v1/auth/password/change") + suspend fun changePassword( + @Body request: ChangePasswordRequestDto + ): Response + + @POST("v1/auth/sessions/update_push_token") + suspend fun updatePushToken( + @Query("fcm_token") fcmToken: String + ): Response + + @GET("v1/auth/sessions/list") + suspend fun getSessionsList(): Response + + @POST("v1/auth/sessions/revoke/{session_id}") + suspend fun revokeSession( + @Path("session_id") sessionId: String + ): Response + + @POST("v1/auth/sessions/revoke_all_except_current") + suspend fun revokeAllExceptCurrent(): Response +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/api/ChatPrivateApi.kt b/app/src/main/java/org/yobble/messenger/data/remote/api/ChatPrivateApi.kt new file mode 100644 index 0000000..2deead2 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/api/ChatPrivateApi.kt @@ -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 + + @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 + + @POST("v1/chat/private/create") + suspend fun createChat( + @Query("target_user_id") targetUserId: String + ): Response + + @POST("v1/chat/private/send") + suspend fun sendMessage( + @Body request: PrivateMessageSendRequestDto + ): Response + + @HTTP(method = "DELETE", path = "v1/chat/private/delete", hasBody = true) + suspend fun deleteChat( + @Body request: PrivateChatDeleteRequestDto + ): Response +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/api/FeedApi.kt b/app/src/main/java/org/yobble/messenger/data/remote/api/FeedApi.kt new file mode 100644 index 0000000..389c146 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/api/FeedApi.kt @@ -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 +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/api/ProfileApi.kt b/app/src/main/java/org/yobble/messenger/data/remote/api/ProfileApi.kt new file mode 100644 index 0000000..58c9d77 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/api/ProfileApi.kt @@ -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 + + @PUT("v1/profile/edit") + suspend fun editProfile( + @Body request: ProfileUpdateRequestDto + ): Response + + @GET("v1/profile/{user_id}") + suspend fun getUserProfile( + @Path("user_id") userId: String + ): Response +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/api/StorageApi.kt b/app/src/main/java/org/yobble/messenger/data/remote/api/StorageApi.kt new file mode 100644 index 0000000..53879a9 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/api/StorageApi.kt @@ -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 +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/api/UserApi.kt b/app/src/main/java/org/yobble/messenger/data/remote/api/UserApi.kt new file mode 100644 index 0000000..0b10d18 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/api/UserApi.kt @@ -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 + + @GET("v1/user/contact/count") + suspend fun getContactCount(): Response + + @POST("v1/user/contact/add") + suspend fun addContact( + @Body request: ContactCreateRequestDto + ): Response + + @PATCH("v1/user/contact/update") + suspend fun updateContact( + @Body request: ContactUpdateRequestDto + ): Response + + @HTTP(method = "DELETE", path = "v1/user/contact/remove", hasBody = true) + suspend fun removeContact( + @Body request: ContactDeleteRequestDto + ): Response + + // Blacklist + + @GET("v1/user/blacklist/list") + suspend fun getBlacklist( + @Query("offset") offset: Int = 0, + @Query("limit") limit: Int = 20 + ): Response + + @POST("v1/user/blacklist/add") + suspend fun addToBlacklist( + @Body request: BlacklistCreateRequestDto + ): Response + + @HTTP(method = "DELETE", path = "v1/user/blacklist/remove", hasBody = true) + suspend fun removeFromBlacklist( + @Body request: BlacklistDeleteRequestDto + ): Response +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/dto/AuthRequestDto.kt b/app/src/main/java/org/yobble/messenger/data/remote/dto/AuthRequestDto.kt new file mode 100644 index 0000000..c855118 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/dto/AuthRequestDto.kt @@ -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 +) diff --git a/app/src/main/java/org/yobble/messenger/data/remote/dto/AuthResponseDto.kt b/app/src/main/java/org/yobble/messenger/data/remote/dto/AuthResponseDto.kt new file mode 100644 index 0000000..2ccec49 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/dto/AuthResponseDto.kt @@ -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 +) + +@Serializable +data class ErrorItemDto( + @SerialName("field") val field: String, + @SerialName("message") val message: String +) diff --git a/app/src/main/java/org/yobble/messenger/data/remote/dto/ChatPrivateDto.kt b/app/src/main/java/org/yobble/messenger/data/remote/dto/ChatPrivateDto.kt new file mode 100644 index 0000000..e5c6186 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/dto/ChatPrivateDto.kt @@ -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 = 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, + @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, + @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, + @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 diff --git a/app/src/main/java/org/yobble/messenger/data/remote/dto/ContactDto.kt b/app/src/main/java/org/yobble/messenger/data/remote/dto/ContactDto.kt new file mode 100644 index 0000000..8afd871 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/dto/ContactDto.kt @@ -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, + @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, + @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 diff --git a/app/src/main/java/org/yobble/messenger/data/remote/dto/FeedDto.kt b/app/src/main/java/org/yobble/messenger/data/remote/dto/FeedDto.kt new file mode 100644 index 0000000..f4a4995 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/dto/FeedDto.kt @@ -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 +) + +@Serializable +data class UserSearchResultDto( + @SerialName("user_id") val userId: String, + @SerialName("profile") val profile: ProfileByUserIdDataDto? = null +) diff --git a/app/src/main/java/org/yobble/messenger/data/remote/dto/ProfileDto.kt b/app/src/main/java/org/yobble/messenger/data/remote/dto/ProfileDto.kt new file mode 100644 index 0000000..fd80c06 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/dto/ProfileDto.kt @@ -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, + @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 = 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 diff --git a/app/src/main/java/org/yobble/messenger/data/remote/dto/SessionDto.kt b/app/src/main/java/org/yobble/messenger/data/remote/dto/SessionDto.kt new file mode 100644 index 0000000..81180fc --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/dto/SessionDto.kt @@ -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 +) + +@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 +) diff --git a/app/src/main/java/org/yobble/messenger/data/remote/dto/StorageDto.kt b/app/src/main/java/org/yobble/messenger/data/remote/dto/StorageDto.kt new file mode 100644 index 0000000..d08e16a --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/dto/StorageDto.kt @@ -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 +) diff --git a/app/src/main/java/org/yobble/messenger/data/remote/fcm/YobbleFcmService.kt b/app/src/main/java/org/yobble/messenger/data/remote/fcm/YobbleFcmService.kt new file mode 100644 index 0000000..94fb46a --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/fcm/YobbleFcmService.kt @@ -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 + ) { + 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 + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/interceptor/AuthInterceptor.kt b/app/src/main/java/org/yobble/messenger/data/remote/interceptor/AuthInterceptor.kt new file mode 100644 index 0000000..2ed7d60 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/interceptor/AuthInterceptor.kt @@ -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) + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/interceptor/TokenAuthenticator.kt b/app/src/main/java/org/yobble/messenger/data/remote/interceptor/TokenAuthenticator.kt new file mode 100644 index 0000000..f0b047e --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/interceptor/TokenAuthenticator.kt @@ -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 +) : 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 + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/remote/socket/SocketManager.kt b/app/src/main/java/org/yobble/messenger/data/remote/socket/SocketManager.kt new file mode 100644 index 0000000..fc1d29e --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/remote/socket/SocketManager.kt @@ -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(extraBufferCapacity = 64) + val events: SharedFlow = _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) { + 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" + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/org/yobble/messenger/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..8821ab0 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/repository/AuthRepositoryImpl.kt @@ -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 { + 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 { + return safeApiCall(json) { + authApi.requestLoginCode(LoginCodeRequestDto(login)) + } + } + + override suspend fun verifyCode(login: String, otp: String): NetworkResult { + 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 { + return safeApiCall(json) { + authApi.register(RegisterRequestDto(login, password, invite)) + } + } + + override suspend fun updatePushToken(fcmToken: String): NetworkResult { + return safeApiCall(json) { + authApi.updatePushToken(fcmToken) + } + } + + override suspend fun changePassword(oldPassword: String, newPassword: String): NetworkResult { + return safeApiCall(json) { + authApi.changePassword(ChangePasswordRequestDto(oldPassword, newPassword)) + } + } + + override suspend fun getSessionsList(): NetworkResult { + return safeApiCall(json) { + authApi.getSessionsList() + } + } + + override suspend fun revokeSession(sessionId: String): NetworkResult { + return safeApiCall(json) { + authApi.revokeSession(sessionId) + } + } + + override suspend fun revokeAllExceptCurrent(): NetworkResult { + return safeApiCall(json) { + authApi.revokeAllExceptCurrent() + } + } + + override suspend fun isLoggedIn(): Boolean { + return sessionManager.isLoggedIn + } + + override fun logout() { + sessionManager.clearSession() + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/repository/ChatRepositoryImpl.kt b/app/src/main/java/org/yobble/messenger/data/repository/ChatRepositoryImpl.kt new file mode 100644 index 0000000..b09390a --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/repository/ChatRepositoryImpl.kt @@ -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 { + return safeApiCall(json) { chatApi.getChatList(offset, limit) } + } + + override suspend fun getChatHistory(chatId: String, beforeMessageId: Int?, limit: Int): NetworkResult { + return safeApiCall(json) { chatApi.getChatHistory(chatId, beforeMessageId, limit) } + } + + override suspend fun createChat(targetUserId: String): NetworkResult { + return safeApiCall(json) { chatApi.createChat(targetUserId) } + } + + override suspend fun sendMessage(chatId: String, content: String): NetworkResult { + return safeApiCall(json) { + chatApi.sendMessage(PrivateMessageSendRequestDto(chatId = chatId, content = content)) + } + } + + override suspend fun deleteChat(chatId: String, deleteForBoth: Boolean): NetworkResult { + return safeApiCall(json) { + chatApi.deleteChat(PrivateChatDeleteRequestDto(chatId = chatId, deleteForBoth = deleteForBoth)) + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/repository/FeedRepositoryImpl.kt b/app/src/main/java/org/yobble/messenger/data/repository/FeedRepositoryImpl.kt new file mode 100644 index 0000000..3de81c9 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/repository/FeedRepositoryImpl.kt @@ -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 { + return safeApiCall(json) { feedApi.searchUsers(query) } + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/repository/ProfileRepositoryImpl.kt b/app/src/main/java/org/yobble/messenger/data/repository/ProfileRepositoryImpl.kt new file mode 100644 index 0000000..4c63624 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/repository/ProfileRepositoryImpl.kt @@ -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 { + return safeApiCall(json) { profileApi.getMyProfile() } + } + + override suspend fun editProfile(request: ProfileUpdateRequestDto): NetworkResult { + return safeApiCall(json) { profileApi.editProfile(request) } + } + + override suspend fun getUserProfile(userId: String): NetworkResult { + return safeApiCall(json) { profileApi.getUserProfile(userId) } + } + + override suspend fun uploadAvatar(file: File, mimeType: String): NetworkResult { + val mediaType = mimeType.toMediaType() + val requestBody = file.asRequestBody(mediaType) + val part = MultipartBody.Part.createFormData("file", file.name, requestBody) + return safeApiCall(json) { storageApi.uploadAvatar(part) } + } +} diff --git a/app/src/main/java/org/yobble/messenger/data/repository/UserRepositoryImpl.kt b/app/src/main/java/org/yobble/messenger/data/repository/UserRepositoryImpl.kt new file mode 100644 index 0000000..24847c0 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/data/repository/UserRepositoryImpl.kt @@ -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 { + return safeApiCall(json) { userApi.getContacts(offset, limit) } + } + + override suspend fun getContactCount(): NetworkResult { + return safeApiCall(json) { userApi.getContactCount() } + } + + override suspend fun addContact(request: ContactCreateRequestDto): NetworkResult { + return safeApiCall(json) { userApi.addContact(request) } + } + + override suspend fun updateContact(request: ContactUpdateRequestDto): NetworkResult { + return safeApiCall(json) { userApi.updateContact(request) } + } + + override suspend fun removeContact(userId: String): NetworkResult { + return safeApiCall(json) { userApi.removeContact(ContactDeleteRequestDto(userId)) } + } + + override suspend fun getBlacklist(offset: Int, limit: Int): NetworkResult { + return safeApiCall(json) { userApi.getBlacklist(offset, limit) } + } + + override suspend fun addToBlacklist(request: BlacklistCreateRequestDto): NetworkResult { + return safeApiCall(json) { userApi.addToBlacklist(request) } + } + + override suspend fun removeFromBlacklist(userId: String): NetworkResult { + return safeApiCall(json) { userApi.removeFromBlacklist(BlacklistDeleteRequestDto(userId)) } + } +} diff --git a/app/src/main/java/org/yobble/messenger/di/NetworkModule.kt b/app/src/main/java/org/yobble/messenger/di/NetworkModule.kt new file mode 100644 index 0000000..e554a61 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/di/NetworkModule.kt @@ -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) + } +} diff --git a/app/src/main/java/org/yobble/messenger/di/RepositoryModule.kt b/app/src/main/java/org/yobble/messenger/di/RepositoryModule.kt new file mode 100644 index 0000000..964f3a0 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/di/RepositoryModule.kt @@ -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 +} diff --git a/app/src/main/java/org/yobble/messenger/domain/repository/AuthRepository.kt b/app/src/main/java/org/yobble/messenger/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..cbd821a --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/domain/repository/AuthRepository.kt @@ -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 + suspend fun requestLoginCode(login: String): NetworkResult + suspend fun verifyCode(login: String, otp: String): NetworkResult + suspend fun register(login: String, password: String, invite: String? = null): NetworkResult + suspend fun updatePushToken(fcmToken: String): NetworkResult + suspend fun changePassword(oldPassword: String, newPassword: String): NetworkResult + suspend fun getSessionsList(): NetworkResult + suspend fun revokeSession(sessionId: String): NetworkResult + suspend fun revokeAllExceptCurrent(): NetworkResult + suspend fun isLoggedIn(): Boolean + fun logout() +} diff --git a/app/src/main/java/org/yobble/messenger/domain/repository/ChatRepository.kt b/app/src/main/java/org/yobble/messenger/domain/repository/ChatRepository.kt new file mode 100644 index 0000000..5c04e89 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/domain/repository/ChatRepository.kt @@ -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 + suspend fun getChatHistory(chatId: String, beforeMessageId: Int? = null, limit: Int = 30): NetworkResult + suspend fun createChat(targetUserId: String): NetworkResult + suspend fun sendMessage(chatId: String, content: String): NetworkResult + suspend fun deleteChat(chatId: String, deleteForBoth: Boolean = false): NetworkResult +} diff --git a/app/src/main/java/org/yobble/messenger/domain/repository/FeedRepository.kt b/app/src/main/java/org/yobble/messenger/domain/repository/FeedRepository.kt new file mode 100644 index 0000000..40317f3 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/domain/repository/FeedRepository.kt @@ -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 +} diff --git a/app/src/main/java/org/yobble/messenger/domain/repository/ProfileRepository.kt b/app/src/main/java/org/yobble/messenger/domain/repository/ProfileRepository.kt new file mode 100644 index 0000000..300a1d5 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/domain/repository/ProfileRepository.kt @@ -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 + suspend fun editProfile(request: ProfileUpdateRequestDto): NetworkResult + suspend fun getUserProfile(userId: String): NetworkResult + suspend fun uploadAvatar(file: File, mimeType: String): NetworkResult +} diff --git a/app/src/main/java/org/yobble/messenger/domain/repository/UserRepository.kt b/app/src/main/java/org/yobble/messenger/domain/repository/UserRepository.kt new file mode 100644 index 0000000..571ad87 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/domain/repository/UserRepository.kt @@ -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 + suspend fun getContactCount(): NetworkResult + suspend fun addContact(request: ContactCreateRequestDto): NetworkResult + suspend fun updateContact(request: ContactUpdateRequestDto): NetworkResult + suspend fun removeContact(userId: String): NetworkResult + + // Blacklist + suspend fun getBlacklist(offset: Int = 0, limit: Int = 20): NetworkResult + suspend fun addToBlacklist(request: BlacklistCreateRequestDto): NetworkResult + suspend fun removeFromBlacklist(userId: String): NetworkResult +} diff --git a/app/src/main/java/org/yobble/messenger/domain/validation/AuthValidator.kt b/app/src/main/java/org/yobble/messenger/domain/validation/AuthValidator.kt new file mode 100644 index 0000000..849a2ed --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/domain/validation/AuthValidator.kt @@ -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() +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/auth/code/CodeVerificationScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/auth/code/CodeVerificationScreen.kt new file mode 100644 index 0000000..adc5591 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/auth/code/CodeVerificationScreen.kt @@ -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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/yobble/messenger/presentation/auth/code/CodeVerificationViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/auth/code/CodeVerificationViewModel.kt new file mode 100644 index 0000000..747024e --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/auth/code/CodeVerificationViewModel.kt @@ -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("login") ?: "" + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/yobble/messenger/presentation/auth/login/LoginScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/auth/login/LoginScreen.kt new file mode 100644 index 0000000..efa3251 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/auth/login/LoginScreen.kt @@ -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 + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/yobble/messenger/presentation/auth/login/LoginViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/auth/login/LoginViewModel.kt new file mode 100644 index 0000000..201c089 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/auth/login/LoginViewModel.kt @@ -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 = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/yobble/messenger/presentation/auth/register/RegisterScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/auth/register/RegisterScreen.kt new file mode 100644 index 0000000..d3e06b4 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/auth/register/RegisterScreen.kt @@ -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 + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/yobble/messenger/presentation/auth/register/RegisterViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/auth/register/RegisterViewModel.kt new file mode 100644 index 0000000..831eacd --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/auth/register/RegisterViewModel.kt @@ -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 = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/yobble/messenger/presentation/chat/ChatScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/chat/ChatScreen.kt new file mode 100644 index 0000000..1652ba7 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/chat/ChatScreen.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/chat/ChatViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/chat/ChatViewModel.kt new file mode 100644 index 0000000..82343c2 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/chat/ChatViewModel.kt @@ -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 = 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("chatId") ?: "" + + private val _uiState = MutableStateFlow( + ChatUiState( + chatId = chatId, + currentUserId = sessionManager.userId ?: "", + messageText = sessionManager.getDraft(chatId) + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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(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) } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/common/FullScreenImageViewer.kt b/app/src/main/java/org/yobble/messenger/presentation/common/FullScreenImageViewer.kt new file mode 100644 index 0000000..6149515 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/common/FullScreenImageViewer.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/common/UserAvatar.kt b/app/src/main/java/org/yobble/messenger/presentation/common/UserAvatar.kt new file mode 100644 index 0000000..489e0db --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/common/UserAvatar.kt @@ -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" +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt new file mode 100644 index 0000000..452f74a --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt @@ -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") + } + } + ) +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsViewModel.kt new file mode 100644 index 0000000..1dc1280 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsViewModel.kt @@ -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 = 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 = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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")) + } + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt new file mode 100644 index 0000000..4c1f119 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt @@ -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 + ) +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt new file mode 100644 index 0000000..e0b247d --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt @@ -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 = 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 = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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() + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt b/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt new file mode 100644 index 0000000..667bbfa --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt @@ -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)) + } + ) + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileScreen.kt new file mode 100644 index 0000000..5990334 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileScreen.kt @@ -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") + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileViewModel.kt new file mode 100644 index 0000000..0e49272 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileViewModel.kt @@ -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 = 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("userId") + private val isMyProfile = userId == null + + private val _uiState = MutableStateFlow(ProfileUiState(isMyProfile = isMyProfile)) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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")) + } + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/search/SearchScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/search/SearchScreen.kt new file mode 100644 index 0000000..23d045c --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/search/SearchScreen.kt @@ -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 + ) +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/search/SearchViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/search/SearchViewModel.kt new file mode 100644 index 0000000..7c88a9b --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/search/SearchViewModel.kt @@ -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 = emptyList(), + val isLoading: Boolean = false +) + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val feedRepository: FeedRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = _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()) } + } + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/BlacklistScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/BlacklistScreen.kt new file mode 100644 index 0000000..7c49f9b --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/BlacklistScreen.kt @@ -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") + } + } + ) +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/BlacklistViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/BlacklistViewModel.kt new file mode 100644 index 0000000..3e0ec57 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/BlacklistViewModel.kt @@ -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 = 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 = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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")) + } + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/ChangePasswordScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/ChangePasswordScreen.kt new file mode 100644 index 0000000..8490012 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/ChangePasswordScreen.kt @@ -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) + ) +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/PrivacyScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/PrivacyScreen.kt new file mode 100644 index 0000000..1b301ed --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/PrivacyScreen.kt @@ -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>, + 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) + } + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/SessionsScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/SessionsScreen.kt new file mode 100644 index 0000000..fb5c9f3 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/SessionsScreen.kt @@ -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") + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt new file mode 100644 index 0000000..70d4af4 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsViewModel.kt new file mode 100644 index 0000000..ec2b222 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsViewModel.kt @@ -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 = 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 = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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")) + } + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/ui/theme/Color.kt b/app/src/main/java/org/yobble/messenger/ui/theme/Color.kt new file mode 100644 index 0000000..185ccb8 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/org/yobble/messenger/ui/theme/Theme.kt b/app/src/main/java/org/yobble/messenger/ui/theme/Theme.kt new file mode 100644 index 0000000..054d16f --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/yobble/messenger/ui/theme/Type.kt b/app/src/main/java/org/yobble/messenger/ui/theme/Type.kt new file mode 100644 index 0000000..cea5436 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/ui/theme/Type.kt @@ -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 + ) +) \ No newline at end of file diff --git a/app/src/main/java/org/yobble/messenger/util/TimeUtils.kt b/app/src/main/java/org/yobble/messenger/util/TimeUtils.kt new file mode 100644 index 0000000..1fb1440 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/util/TimeUtils.kt @@ -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) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..ef8ab6e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..137b9cf Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..db50875 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..0c020b2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f643b24 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f8b7953 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..8a71818 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..e23574d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..16b17aa Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..6299eea Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ec01b30 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..bfc3c60 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7726197 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d7dcb14 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..41927c6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..adced57 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Yobble + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..00c2614 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +