[feat/fix]:
- redesign: dark theme, new UI for all screens - feat: message edit/delete/mark-read, emoji picker, drawer navigation, achievements - fix: keyboard sync (adjustNothing + imePadding), token refresh on IP mismatch, pagination, profile API update
This commit is contained in:
parent
8a6035be49
commit
4f3df5e440
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@ -4,7 +4,7 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-03-06T22:11:28.794766722Z">
|
<DropdownSelection timestamp="2026-03-07T21:14:00.266022244Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/cardinalnsk/.config/.android/avd/Pixel_6.avd" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=/home/cardinalnsk/.config/.android/avd/Pixel_6.avd" />
|
||||||
|
|||||||
4
.idea/misc.xml
generated
4
.idea/misc.xml
generated
@ -1,4 +1,8 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="ASMSmaliIdeaPluginConfiguration">
|
||||||
|
<asm skipDebug="true" skipFrames="true" skipCode="false" expandFrames="false" />
|
||||||
|
<groovy codeStyle="LEGACY" />
|
||||||
|
</component>
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
|||||||
@ -92,6 +92,9 @@ dependencies {
|
|||||||
// Image loading
|
// Image loading
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
// Emoji picker
|
||||||
|
implementation(libs.androidx.emoji2.emojipicker)
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:windowSoftInputMode="adjustNothing"
|
||||||
android:theme="@style/Theme.Yobble">
|
android:theme="@style/Theme.Yobble">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@ -6,10 +6,10 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.yobble.messenger.data.local.SessionManager
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
@ -30,7 +30,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
// Required for smooth keyboard animations (official docs)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
|
|
||||||
val startDestination = if (sessionManager.isLoggedIn) Routes.HOME else Routes.LOGIN
|
val startDestination = if (sessionManager.isLoggedIn) Routes.HOME else Routes.LOGIN
|
||||||
|
|||||||
@ -3,13 +3,16 @@ package org.yobble.messenger
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import coil.Coil
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
import coil.disk.DiskCache
|
import coil.disk.DiskCache
|
||||||
|
import coil.memory.MemoryCache
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.yobble.messenger.data.local.CacheManager
|
import org.yobble.messenger.data.local.CacheManager
|
||||||
import org.yobble.messenger.data.local.SessionManager
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import org.yobble.messenger.di.CoilClient
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ -17,6 +20,7 @@ import javax.inject.Inject
|
|||||||
class YobbleApp : Application(), ImageLoaderFactory {
|
class YobbleApp : Application(), ImageLoaderFactory {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
@CoilClient
|
||||||
lateinit var okHttpClient: OkHttpClient
|
lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@ -31,9 +35,27 @@ class YobbleApp : Application(), ImageLoaderFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun newImageLoader(): ImageLoader {
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
return buildImageLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentImageLoaderUserId: String? = null
|
||||||
|
|
||||||
|
fun resetImageLoaderIfNeeded() {
|
||||||
|
val userId = sessionManager.userId
|
||||||
|
if (userId != currentImageLoaderUserId) {
|
||||||
|
currentImageLoaderUserId = userId
|
||||||
|
Coil.setImageLoader(buildImageLoader())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildImageLoader(): ImageLoader {
|
||||||
val builder = ImageLoader.Builder(this)
|
val builder = ImageLoader.Builder(this)
|
||||||
.okHttpClient(okHttpClient)
|
.okHttpClient(okHttpClient)
|
||||||
.crossfade(true)
|
.memoryCache {
|
||||||
|
MemoryCache.Builder(this)
|
||||||
|
.maxSizePercent(0.25)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
val userId = sessionManager.userId
|
val userId = sessionManager.userId
|
||||||
if (userId != null) {
|
if (userId != null) {
|
||||||
|
|||||||
@ -36,6 +36,8 @@ class SessionManager @Inject constructor(
|
|||||||
private val _activeAccountId = MutableStateFlow(accountsPrefs.getString(KEY_ACTIVE_ACCOUNT, null))
|
private val _activeAccountId = MutableStateFlow(accountsPrefs.getString(KEY_ACTIVE_ACCOUNT, null))
|
||||||
val activeAccountId: StateFlow<String?> = _activeAccountId.asStateFlow()
|
val activeAccountId: StateFlow<String?> = _activeAccountId.asStateFlow()
|
||||||
|
|
||||||
|
private var pendingNewAccount = false
|
||||||
|
|
||||||
private val sessionCaches = mutableMapOf<String, SharedPreferences>()
|
private val sessionCaches = mutableMapOf<String, SharedPreferences>()
|
||||||
|
|
||||||
private val localPrefs: SharedPreferences = context.getSharedPreferences(
|
private val localPrefs: SharedPreferences = context.getSharedPreferences(
|
||||||
@ -76,10 +78,9 @@ class SessionManager @Inject constructor(
|
|||||||
get() = accessToken != null
|
get() = accessToken != null
|
||||||
|
|
||||||
fun saveSession(accessToken: String, refreshToken: String, userId: String) {
|
fun saveSession(accessToken: String, refreshToken: String, userId: String) {
|
||||||
val accountId = _activeAccountId.value ?: userId
|
val accountId = if (pendingNewAccount) userId else (_activeAccountId.value ?: userId)
|
||||||
if (_activeAccountId.value == null) {
|
pendingNewAccount = false
|
||||||
setActiveAccount(accountId)
|
setActiveAccount(accountId)
|
||||||
}
|
|
||||||
val prefs = getSessionPrefs(accountId)
|
val prefs = getSessionPrefs(accountId)
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putString(KEY_ACCESS_TOKEN, accessToken)
|
putString(KEY_ACCESS_TOKEN, accessToken)
|
||||||
@ -121,8 +122,7 @@ class SessionManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun prepareNewAccountLogin() {
|
fun prepareNewAccountLogin() {
|
||||||
_activeAccountId.value = null
|
pendingNewAccount = true
|
||||||
accountsPrefs.edit { remove(KEY_ACTIVE_ACCOUNT) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeAccount(accountId: String) {
|
fun removeAccount(accountId: String) {
|
||||||
@ -198,6 +198,10 @@ class SessionManager @Inject constructor(
|
|||||||
return localPrefs.getString("draft_$chatId", null) ?: ""
|
return localPrefs.getString("draft_$chatId", null) ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var navStyle: String
|
||||||
|
get() = localPrefs.getString("nav_style", "bottom_bar") ?: "bottom_bar"
|
||||||
|
set(value) = localPrefs.edit { putString("nav_style", value) }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
package org.yobble.messenger.data.remote.api
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.dto.AchievementListResponseDto
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
interface AchievementApi {
|
||||||
|
|
||||||
|
@GET("v1/achievement/my")
|
||||||
|
suspend fun getMyAchievements(): Response<AchievementListResponseDto>
|
||||||
|
|
||||||
|
@GET("v1/achievement/user/{user_id}")
|
||||||
|
suspend fun getUserAchievements(
|
||||||
|
@Path("user_id") userId: String
|
||||||
|
): Response<AchievementListResponseDto>
|
||||||
|
}
|
||||||
@ -16,7 +16,8 @@ interface ChatPrivateApi {
|
|||||||
suspend fun getChatHistory(
|
suspend fun getChatHistory(
|
||||||
@Query("chat_id") chatId: String,
|
@Query("chat_id") chatId: String,
|
||||||
@Query("before_message_id") beforeMessageId: Int? = null,
|
@Query("before_message_id") beforeMessageId: Int? = null,
|
||||||
@Query("limit") limit: Int = 30
|
@Query("limit") limit: Int = 30,
|
||||||
|
@Query("is_forward") isForward: Boolean = false
|
||||||
): Response<PrivateChatHistoryResponseDto>
|
): Response<PrivateChatHistoryResponseDto>
|
||||||
|
|
||||||
@POST("v1/chat/private/create")
|
@POST("v1/chat/private/create")
|
||||||
@ -24,11 +25,26 @@ interface ChatPrivateApi {
|
|||||||
@Query("target_user_id") targetUserId: String
|
@Query("target_user_id") targetUserId: String
|
||||||
): Response<PrivateChatCreateResponseDto>
|
): Response<PrivateChatCreateResponseDto>
|
||||||
|
|
||||||
@POST("v1/chat/private/send")
|
@POST("v1/chat/private/message/send")
|
||||||
suspend fun sendMessage(
|
suspend fun sendMessage(
|
||||||
@Body request: PrivateMessageSendRequestDto
|
@Body request: PrivateMessageSendRequestDto
|
||||||
): Response<PrivateMessageSendResponseDto>
|
): Response<PrivateMessageSendResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/chat/private/message/delete")
|
||||||
|
suspend fun deleteMessage(
|
||||||
|
@Body request: PrivateMessageDeleteRequestDto
|
||||||
|
): Response<PrivateMessageDeleteResponseDto>
|
||||||
|
|
||||||
|
@PUT("v1/chat/private/message/edit")
|
||||||
|
suspend fun editMessage(
|
||||||
|
@Body request: PrivateMessageEditRequestDto
|
||||||
|
): Response<PrivateMessageEditResponseDto>
|
||||||
|
|
||||||
|
@POST("v1/chat/private/message/mark-read")
|
||||||
|
suspend fun markRead(
|
||||||
|
@Body request: PrivateChatMarkReadRequestDto
|
||||||
|
): Response<PrivateChatMarkReadResponseDto>
|
||||||
|
|
||||||
@HTTP(method = "DELETE", path = "v1/chat/private/delete", hasBody = true)
|
@HTTP(method = "DELETE", path = "v1/chat/private/delete", hasBody = true)
|
||||||
suspend fun deleteChat(
|
suspend fun deleteChat(
|
||||||
@Body request: PrivateChatDeleteRequestDto
|
@Body request: PrivateChatDeleteRequestDto
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
package org.yobble.messenger.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AchievementListResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: AchievementListDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AchievementListDataDto(
|
||||||
|
@SerialName("items") val items: Map<String, List<AchievementItemDto>> = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AchievementItemDto(
|
||||||
|
@SerialName("achievement_id") val achievementId: Int,
|
||||||
|
@SerialName("code") val code: String,
|
||||||
|
@SerialName("name") val name: String,
|
||||||
|
@SerialName("description") val description: String? = null,
|
||||||
|
@SerialName("icon") val icon: String? = null,
|
||||||
|
@SerialName("category") val category: String? = null,
|
||||||
|
@SerialName("badge_type") val badgeType: String,
|
||||||
|
@SerialName("is_completed") val isCompleted: Boolean,
|
||||||
|
@SerialName("unlocked_at") val unlockedAt: String? = null,
|
||||||
|
@SerialName("progress") val progress: Int? = null,
|
||||||
|
@SerialName("required_progress") val requiredProgress: Int? = null
|
||||||
|
)
|
||||||
@ -15,8 +15,28 @@ data class PrivateMessageSendRequestDto(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PrivateChatDeleteRequestDto(
|
data class PrivateChatDeleteRequestDto(
|
||||||
|
@SerialName("chat_id") val chatId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateMessageDeleteRequestDto(
|
||||||
@SerialName("chat_id") val chatId: String,
|
@SerialName("chat_id") val chatId: String,
|
||||||
@SerialName("delete_for_both") val deleteForBoth: Boolean = false
|
@SerialName("message_id") val messageId: Int,
|
||||||
|
@SerialName("delete_for_all") val deleteForAll: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateMessageEditRequestDto(
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("message_id") val messageId: Int,
|
||||||
|
@SerialName("content") val content: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatMarkReadRequestDto(
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("message_id") val messageId: Int? = null,
|
||||||
|
@SerialName("mark_all") val markAll: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
@ -39,6 +59,7 @@ data class PrivateChatListDataDto(
|
|||||||
data class PrivateChatListItemDto(
|
data class PrivateChatListItemDto(
|
||||||
@SerialName("chat_id") val chatId: String,
|
@SerialName("chat_id") val chatId: String,
|
||||||
@SerialName("chat_type") val chatType: String,
|
@SerialName("chat_type") val chatType: String,
|
||||||
|
@SerialName("chat_companion_ids") val chatCompanionIds: List<String>? = null,
|
||||||
@SerialName("chat_data") val chatData: ProfileByUserIdDataDto? = null,
|
@SerialName("chat_data") val chatData: ProfileByUserIdDataDto? = null,
|
||||||
@SerialName("last_message") val lastMessage: MessageItemDto? = null,
|
@SerialName("last_message") val lastMessage: MessageItemDto? = null,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
@ -58,6 +79,7 @@ data class MessageItemDto(
|
|||||||
@SerialName("is_viewed") val isViewed: Boolean,
|
@SerialName("is_viewed") val isViewed: Boolean,
|
||||||
@SerialName("viewed_at") val viewedAt: String? = null,
|
@SerialName("viewed_at") val viewedAt: String? = null,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("is_edited") val isEdited: Boolean = false,
|
||||||
@SerialName("updated_at") val updatedAt: String? = null
|
@SerialName("updated_at") val updatedAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -65,7 +87,8 @@ data class MessageItemDto(
|
|||||||
data class MessageForwardDto(
|
data class MessageForwardDto(
|
||||||
@SerialName("forward_type") val forwardType: String? = null,
|
@SerialName("forward_type") val forwardType: String? = null,
|
||||||
@SerialName("forward_sender_id") val forwardSenderId: String? = null,
|
@SerialName("forward_sender_id") val forwardSenderId: String? = null,
|
||||||
@SerialName("forward_message_id") val forwardMessageId: Int? = null
|
@SerialName("forward_message_id") val forwardMessageId: Int? = null,
|
||||||
|
@SerialName("forward_chat_data") val forwardChatData: JsonObject? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -107,4 +130,44 @@ data class PrivateMessageSendDataDto(
|
|||||||
@SerialName("created_at") val createdAt: String
|
@SerialName("created_at") val createdAt: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateMessageDeleteResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: PrivateMessageDeleteDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateMessageDeleteDataDto(
|
||||||
|
@SerialName("message_id") val messageId: Int,
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("deleted_at") val deletedAt: String,
|
||||||
|
@SerialName("delete_for_all") val deleteForAll: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateMessageEditResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: PrivateMessageEditDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateMessageEditDataDto(
|
||||||
|
@SerialName("message_id") val messageId: Int,
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("content") val content: String,
|
||||||
|
@SerialName("updated_at") val updatedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatMarkReadResponseDto(
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("data") val data: PrivateChatMarkReadDataDto
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PrivateChatMarkReadDataDto(
|
||||||
|
@SerialName("chat_id") val chatId: String,
|
||||||
|
@SerialName("marked_count") val markedCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|||||||
@ -48,7 +48,8 @@ data class ContactInfoDto(
|
|||||||
@SerialName("custom_name") val customName: String? = null,
|
@SerialName("custom_name") val customName: String? = null,
|
||||||
@SerialName("friend_code") val friendCode: Boolean = false,
|
@SerialName("friend_code") val friendCode: Boolean = false,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
@SerialName("last_seen_at") val lastSeenAt: String? = null
|
@SerialName("last_seen_at") val lastSeenAt: String? = null,
|
||||||
|
@SerialName("avatars") val avatars: AvatarsBlockDto? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -104,7 +105,8 @@ data class BlacklistInfoDto(
|
|||||||
@SerialName("user_id") val userId: String,
|
@SerialName("user_id") val userId: String,
|
||||||
@SerialName("login") val login: String? = null,
|
@SerialName("login") val login: String? = null,
|
||||||
@SerialName("full_name") val fullName: String? = null,
|
@SerialName("full_name") val fullName: String? = null,
|
||||||
@SerialName("created_at") val createdAt: String
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("avatars") val avatars: AvatarsBlockDto? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@ -21,12 +21,9 @@ data class ProfilePermissionsRequestDto(
|
|||||||
@SerialName("last_seen_visibility") val lastSeenVisibility: Int? = null,
|
@SerialName("last_seen_visibility") val lastSeenVisibility: Int? = null,
|
||||||
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean? = null,
|
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean? = null,
|
||||||
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: 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("public_invite_permission") val publicInvitePermission: Int? = null,
|
||||||
@SerialName("group_invite_permission") val groupInvitePermission: Int? = null,
|
@SerialName("group_invite_permission") val groupInvitePermission: Int? = null,
|
||||||
@SerialName("call_permission") val callPermission: 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
|
@SerialName("auto_delete_after_days") val autoDeleteAfterDays: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -46,7 +43,8 @@ data class ProfileDataDto(
|
|||||||
@SerialName("login") val login: String,
|
@SerialName("login") val login: String,
|
||||||
@SerialName("full_name") val fullName: String? = null,
|
@SerialName("full_name") val fullName: String? = null,
|
||||||
@SerialName("bio") val bio: String? = null,
|
@SerialName("bio") val bio: String? = null,
|
||||||
@SerialName("is_verified") val isVerified: Boolean? = false,
|
@SerialName("verification") val verification: VerificationItemDto? = null,
|
||||||
|
@SerialName("partner_verifications") val partnerVerifications: List<VerificationItemDto> = emptyList(),
|
||||||
@SerialName("rating") val rating: RatingDataDto,
|
@SerialName("rating") val rating: RatingDataDto,
|
||||||
@SerialName("balances") val balances: List<WalletBalanceDto>,
|
@SerialName("balances") val balances: List<WalletBalanceDto>,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
@ -67,10 +65,11 @@ data class ProfileByUserIdDataDto(
|
|||||||
@SerialName("full_name") val fullName: String? = null,
|
@SerialName("full_name") val fullName: String? = null,
|
||||||
@SerialName("custom_name") val customName: String? = null,
|
@SerialName("custom_name") val customName: String? = null,
|
||||||
@SerialName("bio") val bio: String? = null,
|
@SerialName("bio") val bio: String? = null,
|
||||||
@SerialName("is_verified") val isVerified: Boolean? = false,
|
@SerialName("verification") val verification: VerificationItemDto? = null,
|
||||||
|
@SerialName("partner_verifications") val partnerVerifications: List<VerificationItemDto> = emptyList(),
|
||||||
@SerialName("is_system") val isSystem: Boolean? = false,
|
@SerialName("is_system") val isSystem: Boolean? = false,
|
||||||
@SerialName("rating") val rating: RatingDataDto,
|
@SerialName("rating") val rating: RatingDataDto,
|
||||||
@SerialName("last_seen") val lastSeen: Int? = null,
|
@SerialName("last_seen_at") val lastSeenAt: String? = null,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
@SerialName("avatars") val avatars: AvatarsBlockDto? = null,
|
@SerialName("avatars") val avatars: AvatarsBlockDto? = null,
|
||||||
@SerialName("permissions") val permissions: PermissionsResponseDto,
|
@SerialName("permissions") val permissions: PermissionsResponseDto,
|
||||||
@ -127,32 +126,36 @@ data class RelationshipStatusDto(
|
|||||||
@SerialName("is_current_user_in_blacklist_of_target") val isCurrentInBlacklist: Boolean
|
@SerialName("is_current_user_in_blacklist_of_target") val isCurrentInBlacklist: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VerificationItemDto(
|
||||||
|
@SerialName("type") val type: String,
|
||||||
|
@SerialName("reason") val reason: String? = null,
|
||||||
|
@SerialName("issuer_id") val issuerId: String,
|
||||||
|
@SerialName("issuer_name") val issuerName: String? = null,
|
||||||
|
@SerialName("issued_at") val issuedAt: String? = null,
|
||||||
|
@SerialName("expires_at") val expiresAt: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MyProfilePermissionsDto(
|
data class MyProfilePermissionsDto(
|
||||||
@SerialName("is_searchable") val isSearchable: Boolean,
|
@SerialName("is_searchable") val isSearchable: Boolean = true,
|
||||||
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean,
|
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean = true,
|
||||||
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean,
|
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean = true,
|
||||||
@SerialName("show_profile_photo_to_non_contacts") val showProfilePhotoToNonContacts: Boolean,
|
@SerialName("show_profile_photo_to_non_contacts") val showProfilePhotoToNonContacts: Boolean = true,
|
||||||
@SerialName("last_seen_visibility") val lastSeenVisibility: Int,
|
@SerialName("last_seen_visibility") val lastSeenVisibility: Int = 0,
|
||||||
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean,
|
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean = true,
|
||||||
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean,
|
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean = true,
|
||||||
@SerialName("allow_server_chats") val allowServerChats: Boolean,
|
@SerialName("public_invite_permission") val publicInvitePermission: Int = 0,
|
||||||
@SerialName("public_invite_permission") val publicInvitePermission: Int,
|
@SerialName("group_invite_permission") val groupInvitePermission: Int = 0,
|
||||||
@SerialName("group_invite_permission") val groupInvitePermission: Int,
|
@SerialName("call_permission") val callPermission: Int = 0,
|
||||||
@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
|
@SerialName("auto_delete_after_days") val autoDeleteAfterDays: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class UserProfilePermissionsDto(
|
data class UserProfilePermissionsDto(
|
||||||
@SerialName("is_searchable") val isSearchable: Boolean? = null,
|
@SerialName("is_searchable") val isSearchable: Boolean? = null,
|
||||||
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean,
|
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean = true,
|
||||||
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean,
|
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean = true
|
||||||
@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
|
// endregion
|
||||||
|
|||||||
@ -12,11 +12,12 @@ class AuthInterceptor @Inject constructor(
|
|||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
val token = sessionManager.accessToken
|
val token = sessionManager.accessToken
|
||||||
|
val isRefreshRequest = request.url.encodedPath.contains("token/refresh")
|
||||||
|
|
||||||
val newRequest = request.newBuilder()
|
val newRequest = request.newBuilder()
|
||||||
.header("User-Agent", BuildConfig.USER_AGENT)
|
.header("User-Agent", BuildConfig.USER_AGENT)
|
||||||
.apply {
|
.apply {
|
||||||
if (token != null) {
|
if (token != null && !isRefreshRequest) {
|
||||||
header("Authorization", "Bearer $token")
|
header("Authorization", "Bearer $token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,12 @@ class TokenAuthenticator @Inject constructor(
|
|||||||
) : Authenticator {
|
) : Authenticator {
|
||||||
|
|
||||||
override fun authenticate(route: Route?, response: Response): Request? {
|
override fun authenticate(route: Route?, response: Response): Request? {
|
||||||
|
// Don't retry if we already tried refreshing
|
||||||
|
if (responseCount(response) >= 2) {
|
||||||
|
sessionManager.clearSession()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
val accessToken = sessionManager.accessToken ?: return null
|
val accessToken = sessionManager.accessToken ?: return null
|
||||||
val refreshToken = sessionManager.refreshToken ?: return null
|
val refreshToken = sessionManager.refreshToken ?: return null
|
||||||
|
|
||||||
@ -61,4 +67,14 @@ class TokenAuthenticator @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun responseCount(response: Response): Int {
|
||||||
|
var count = 1
|
||||||
|
var prior = response.priorResponse
|
||||||
|
while (prior != null) {
|
||||||
|
count++
|
||||||
|
prior = prior.priorResponse
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,9 +8,15 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.yobble.messenger.BuildConfig
|
import org.yobble.messenger.BuildConfig
|
||||||
import org.yobble.messenger.data.local.SessionManager
|
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.Inject
|
||||||
|
import javax.inject.Provider
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
sealed class SocketEvent {
|
sealed class SocketEvent {
|
||||||
@ -21,9 +27,12 @@ sealed class SocketEvent {
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class SocketManager @Inject constructor(
|
class SocketManager @Inject constructor(
|
||||||
private val sessionManager: SessionManager
|
private val sessionManager: SessionManager,
|
||||||
|
private val authApiProvider: Provider<AuthApi>
|
||||||
) {
|
) {
|
||||||
private var socket: Socket? = null
|
private var socket: Socket? = null
|
||||||
|
private var isRefreshing = false
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
private val _events = MutableSharedFlow<SocketEvent>(extraBufferCapacity = 64)
|
private val _events = MutableSharedFlow<SocketEvent>(extraBufferCapacity = 64)
|
||||||
val events: SharedFlow<SocketEvent> = _events.asSharedFlow()
|
val events: SharedFlow<SocketEvent> = _events.asSharedFlow()
|
||||||
@ -103,6 +112,10 @@ class SocketManager @Inject constructor(
|
|||||||
if (error is Exception) {
|
if (error is Exception) {
|
||||||
Log.e(TAG, "Error details:", error)
|
Log.e(TAG, "Error details:", error)
|
||||||
}
|
}
|
||||||
|
val errorStr = error.toString()
|
||||||
|
if (errorStr.contains("IP address mismatch") || errorStr.contains("authentication_failed")) {
|
||||||
|
refreshTokenAndReconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server confirms connection
|
// Server confirms connection
|
||||||
@ -158,6 +171,57 @@ class SocketManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshTokenAndReconnect() {
|
||||||
|
synchronized(this) {
|
||||||
|
if (isRefreshing) return
|
||||||
|
isRefreshing = true
|
||||||
|
// Stop auto-reconnect to prevent repeated errors
|
||||||
|
socket?.disconnect()
|
||||||
|
socket?.off()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Refreshing token due to IP mismatch...")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val accessToken = sessionManager.accessToken
|
||||||
|
val refreshToken = sessionManager.refreshToken
|
||||||
|
if (accessToken == null || refreshToken == null) {
|
||||||
|
Log.e(TAG, "No tokens for refresh")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = authApiProvider.get().refreshToken(
|
||||||
|
TokenRefreshRequestDto(
|
||||||
|
accessToken = accessToken,
|
||||||
|
refreshToken = refreshToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val newTokens = response.body()?.data
|
||||||
|
if (newTokens != null) {
|
||||||
|
sessionManager.saveSession(
|
||||||
|
accessToken = newTokens.accessToken,
|
||||||
|
refreshToken = newTokens.refreshToken,
|
||||||
|
userId = sessionManager.userId ?: ""
|
||||||
|
)
|
||||||
|
Log.i(TAG, "Token refreshed, reconnecting socket...")
|
||||||
|
connect()
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Token refresh: empty body")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Token refresh failed: ${response.code()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Token refresh exception", e)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
Log.d(TAG, "Disconnecting socket")
|
Log.d(TAG, "Disconnecting socket")
|
||||||
socket?.disconnect()
|
socket?.disconnect()
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.yobble.messenger.data.repository
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.api.AchievementApi
|
||||||
|
import org.yobble.messenger.data.remote.dto.AchievementListResponseDto
|
||||||
|
import org.yobble.messenger.data.remote.safeApiCall
|
||||||
|
import org.yobble.messenger.domain.repository.AchievementRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class AchievementRepositoryImpl @Inject constructor(
|
||||||
|
private val achievementApi: AchievementApi,
|
||||||
|
private val json: Json
|
||||||
|
) : AchievementRepository {
|
||||||
|
|
||||||
|
override suspend fun getMyAchievements(): NetworkResult<AchievementListResponseDto> {
|
||||||
|
return safeApiCall(json) { achievementApi.getMyAchievements() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getUserAchievements(userId: String): NetworkResult<AchievementListResponseDto> {
|
||||||
|
return safeApiCall(json) { achievementApi.getUserAchievements(userId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,10 +15,19 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
private val chatCacheManager: ChatCacheManager
|
private val chatCacheManager: ChatCacheManager
|
||||||
) : ChatRepository {
|
) : ChatRepository {
|
||||||
|
|
||||||
|
private val chatDataMap = mutableMapOf<String, ProfileByUserIdDataDto>()
|
||||||
|
|
||||||
|
fun getChatData(chatId: String): ProfileByUserIdDataDto? = chatDataMap[chatId]
|
||||||
|
|
||||||
override suspend fun getChatList(offset: Int, limit: Int): NetworkResult<PrivateChatListResponseDto> {
|
override suspend fun getChatList(offset: Int, limit: Int): NetworkResult<PrivateChatListResponseDto> {
|
||||||
val result = safeApiCall(json) { chatApi.getChatList(offset, limit) }
|
val result = safeApiCall(json) { chatApi.getChatList(offset, limit) }
|
||||||
if (result is NetworkResult.Success && offset == 0) {
|
if (result is NetworkResult.Success) {
|
||||||
chatCacheManager.saveChatList(result.data.data.items)
|
result.data.data.items.forEach { chat ->
|
||||||
|
chat.chatData?.let { chatDataMap[chat.chatId] = it }
|
||||||
|
}
|
||||||
|
if (offset == 0) {
|
||||||
|
chatCacheManager.saveChatList(result.data.data.items)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@ -49,9 +58,27 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteChat(chatId: String, deleteForBoth: Boolean): NetworkResult<BaseResponseDto> {
|
override suspend fun deleteMessage(chatId: String, messageId: Int, deleteForAll: Boolean): NetworkResult<PrivateMessageDeleteResponseDto> {
|
||||||
return safeApiCall(json) {
|
return safeApiCall(json) {
|
||||||
chatApi.deleteChat(PrivateChatDeleteRequestDto(chatId = chatId, deleteForBoth = deleteForBoth))
|
chatApi.deleteMessage(PrivateMessageDeleteRequestDto(chatId = chatId, messageId = messageId, deleteForAll = deleteForAll))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun editMessage(chatId: String, messageId: Int, content: String): NetworkResult<PrivateMessageEditResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
chatApi.editMessage(PrivateMessageEditRequestDto(chatId = chatId, messageId = messageId, content = content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markRead(chatId: String, messageId: Int?, markAll: Boolean): NetworkResult<PrivateChatMarkReadResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
chatApi.markRead(PrivateChatMarkReadRequestDto(chatId = chatId, messageId = messageId, markAll = markAll))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteChat(chatId: String): NetworkResult<BaseResponseDto> {
|
||||||
|
return safeApiCall(json) {
|
||||||
|
chatApi.deleteChat(PrivateChatDeleteRequestDto(chatId = chatId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import dagger.Module
|
|||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Qualifier
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@ -12,6 +13,7 @@ import okhttp3.logging.HttpLoggingInterceptor
|
|||||||
import org.yobble.messenger.BuildConfig
|
import org.yobble.messenger.BuildConfig
|
||||||
import org.yobble.messenger.data.local.CacheManager
|
import org.yobble.messenger.data.local.CacheManager
|
||||||
import org.yobble.messenger.data.local.SessionManager
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import org.yobble.messenger.data.remote.api.AchievementApi
|
||||||
import org.yobble.messenger.data.remote.api.AuthApi
|
import org.yobble.messenger.data.remote.api.AuthApi
|
||||||
import org.yobble.messenger.data.remote.api.ChatPrivateApi
|
import org.yobble.messenger.data.remote.api.ChatPrivateApi
|
||||||
import org.yobble.messenger.data.remote.api.FeedApi
|
import org.yobble.messenger.data.remote.api.FeedApi
|
||||||
@ -25,6 +27,10 @@ import retrofit2.Retrofit
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class CoilClient
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object NetworkModule {
|
object NetworkModule {
|
||||||
@ -65,6 +71,24 @@ object NetworkModule {
|
|||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@CoilClient
|
||||||
|
fun provideCoilOkHttpClient(
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
tokenAuthenticator: TokenAuthenticator
|
||||||
|
): OkHttpClient {
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(
|
||||||
|
HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.HEADERS
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.authenticator(tokenAuthenticator)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit {
|
fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit {
|
||||||
@ -111,4 +135,10 @@ object NetworkModule {
|
|||||||
fun provideFeedApi(retrofit: Retrofit): FeedApi {
|
fun provideFeedApi(retrofit: Retrofit): FeedApi {
|
||||||
return retrofit.create(FeedApi::class.java)
|
return retrofit.create(FeedApi::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAchievementApi(retrofit: Retrofit): AchievementApi {
|
||||||
|
return retrofit.create(AchievementApi::class.java)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import dagger.Binds
|
|||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.yobble.messenger.data.repository.AchievementRepositoryImpl
|
||||||
import org.yobble.messenger.data.repository.AuthRepositoryImpl
|
import org.yobble.messenger.data.repository.AuthRepositoryImpl
|
||||||
import org.yobble.messenger.data.repository.ChatRepositoryImpl
|
import org.yobble.messenger.data.repository.ChatRepositoryImpl
|
||||||
import org.yobble.messenger.data.repository.FeedRepositoryImpl
|
import org.yobble.messenger.data.repository.FeedRepositoryImpl
|
||||||
import org.yobble.messenger.data.repository.ProfileRepositoryImpl
|
import org.yobble.messenger.data.repository.ProfileRepositoryImpl
|
||||||
import org.yobble.messenger.data.repository.UserRepositoryImpl
|
import org.yobble.messenger.data.repository.UserRepositoryImpl
|
||||||
|
import org.yobble.messenger.domain.repository.AchievementRepository
|
||||||
import org.yobble.messenger.domain.repository.AuthRepository
|
import org.yobble.messenger.domain.repository.AuthRepository
|
||||||
import org.yobble.messenger.domain.repository.ChatRepository
|
import org.yobble.messenger.domain.repository.ChatRepository
|
||||||
import org.yobble.messenger.domain.repository.FeedRepository
|
import org.yobble.messenger.domain.repository.FeedRepository
|
||||||
@ -39,4 +41,8 @@ abstract class RepositoryModule {
|
|||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindFeedRepository(impl: FeedRepositoryImpl): FeedRepository
|
abstract fun bindFeedRepository(impl: FeedRepositoryImpl): FeedRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindAchievementRepository(impl: AchievementRepositoryImpl): AchievementRepository
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
package org.yobble.messenger.domain.repository
|
||||||
|
|
||||||
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.AchievementListResponseDto
|
||||||
|
|
||||||
|
interface AchievementRepository {
|
||||||
|
suspend fun getMyAchievements(): NetworkResult<AchievementListResponseDto>
|
||||||
|
suspend fun getUserAchievements(userId: String): NetworkResult<AchievementListResponseDto>
|
||||||
|
}
|
||||||
@ -8,5 +8,8 @@ interface ChatRepository {
|
|||||||
suspend fun getChatHistory(chatId: String, beforeMessageId: Int? = null, limit: Int = 30): NetworkResult<PrivateChatHistoryResponseDto>
|
suspend fun getChatHistory(chatId: String, beforeMessageId: Int? = null, limit: Int = 30): NetworkResult<PrivateChatHistoryResponseDto>
|
||||||
suspend fun createChat(targetUserId: String): NetworkResult<PrivateChatCreateResponseDto>
|
suspend fun createChat(targetUserId: String): NetworkResult<PrivateChatCreateResponseDto>
|
||||||
suspend fun sendMessage(chatId: String, content: String): NetworkResult<PrivateMessageSendResponseDto>
|
suspend fun sendMessage(chatId: String, content: String): NetworkResult<PrivateMessageSendResponseDto>
|
||||||
suspend fun deleteChat(chatId: String, deleteForBoth: Boolean = false): NetworkResult<BaseResponseDto>
|
suspend fun deleteMessage(chatId: String, messageId: Int, deleteForAll: Boolean = false): NetworkResult<PrivateMessageDeleteResponseDto>
|
||||||
|
suspend fun editMessage(chatId: String, messageId: Int, content: String): NetworkResult<PrivateMessageEditResponseDto>
|
||||||
|
suspend fun markRead(chatId: String, messageId: Int? = null, markAll: Boolean = false): NetworkResult<PrivateChatMarkReadResponseDto>
|
||||||
|
suspend fun deleteChat(chatId: String): NetworkResult<BaseResponseDto>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,9 +52,9 @@ fun AccountSwitcherScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package org.yobble.messenger.presentation.auth.login
|
package org.yobble.messenger.presentation.auth.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@ -7,20 +8,24 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
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.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
@ -59,19 +64,11 @@ fun LoginScreen(
|
|||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(80.dp))
|
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(
|
||||||
text = "Yobble",
|
text = "Yobble",
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
fontSize = 36.sp,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@ -82,9 +79,9 @@ fun LoginScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(40.dp))
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
TextField(
|
||||||
value = uiState.login,
|
value = uiState.login,
|
||||||
onValueChange = viewModel::onLoginChange,
|
onValueChange = viewModel::onLoginChange,
|
||||||
label = { Text("Login") },
|
label = { Text("Login") },
|
||||||
@ -99,12 +96,13 @@ fun LoginScreen(
|
|||||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = authFieldColors()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
TextField(
|
||||||
value = uiState.password,
|
value = uiState.password,
|
||||||
onValueChange = viewModel::onPasswordChange,
|
onValueChange = viewModel::onPasswordChange,
|
||||||
label = { Text("Password") },
|
label = { Text("Password") },
|
||||||
@ -126,42 +124,64 @@ fun LoginScreen(
|
|||||||
IconButton(onClick = viewModel::togglePasswordVisibility) {
|
IconButton(onClick = viewModel::togglePasswordVisibility) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
contentDescription = if (uiState.isPasswordVisible) "Hide password" else "Show password"
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = authFieldColors()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
|
||||||
|
val primary = MaterialTheme.colorScheme.primary
|
||||||
|
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::login,
|
onClick = viewModel::login,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(48.dp),
|
.height(50.dp),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
contentPadding = PaddingValues(0.dp),
|
||||||
),
|
|
||||||
enabled = !uiState.isLoading
|
enabled = !uiState.isLoading
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
Box(
|
||||||
CircularProgressIndicator(
|
modifier = Modifier
|
||||||
modifier = Modifier.size(24.dp),
|
.fillMaxSize()
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
.background(
|
||||||
strokeWidth = 2.dp
|
if (!uiState.isLoading)
|
||||||
)
|
Brush.linearGradient(listOf(primary, primaryContainer))
|
||||||
} else {
|
else
|
||||||
Text(
|
Brush.linearGradient(
|
||||||
"Log In",
|
listOf(
|
||||||
style = MaterialTheme.typography.labelLarge
|
primary.copy(alpha = 0.5f),
|
||||||
)
|
primaryContainer.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Log In",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
TextButton(onClick = viewModel::requestLoginCode) {
|
TextButton(onClick = viewModel::requestLoginCode) {
|
||||||
Text(
|
Text(
|
||||||
@ -186,10 +206,21 @@ fun LoginScreen(
|
|||||||
Text(
|
Text(
|
||||||
"Register",
|
"Register",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun authFieldColors() = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
errorIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package org.yobble.messenger.presentation.auth.register
|
package org.yobble.messenger.presentation.auth.register
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@ -15,7 +16,10 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
@ -52,12 +56,17 @@ fun RegisterScreen(
|
|||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Register") },
|
title = { Text("Create Account", fontWeight = FontWeight.Bold) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
@ -71,14 +80,6 @@ fun RegisterScreen(
|
|||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
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(
|
||||||
text = "Enter your details to get started",
|
text = "Enter your details to get started",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@ -87,7 +88,7 @@ fun RegisterScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
TextField(
|
||||||
value = uiState.login,
|
value = uiState.login,
|
||||||
onValueChange = viewModel::onLoginChange,
|
onValueChange = viewModel::onLoginChange,
|
||||||
label = { Text("Login") },
|
label = { Text("Login") },
|
||||||
@ -102,12 +103,13 @@ fun RegisterScreen(
|
|||||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = authFieldColors()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
TextField(
|
||||||
value = uiState.password,
|
value = uiState.password,
|
||||||
onValueChange = viewModel::onPasswordChange,
|
onValueChange = viewModel::onPasswordChange,
|
||||||
label = { Text("Password") },
|
label = { Text("Password") },
|
||||||
@ -126,17 +128,19 @@ fun RegisterScreen(
|
|||||||
IconButton(onClick = viewModel::togglePasswordVisibility) {
|
IconButton(onClick = viewModel::togglePasswordVisibility) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
contentDescription = if (uiState.isPasswordVisible) "Hide password" else "Show password"
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = authFieldColors()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
TextField(
|
||||||
value = uiState.confirmPassword,
|
value = uiState.confirmPassword,
|
||||||
onValueChange = viewModel::onConfirmPasswordChange,
|
onValueChange = viewModel::onConfirmPasswordChange,
|
||||||
label = { Text("Confirm Password") },
|
label = { Text("Confirm Password") },
|
||||||
@ -155,17 +159,19 @@ fun RegisterScreen(
|
|||||||
IconButton(onClick = viewModel::toggleConfirmPasswordVisibility) {
|
IconButton(onClick = viewModel::toggleConfirmPasswordVisibility) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (uiState.isConfirmPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
imageVector = if (uiState.isConfirmPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
contentDescription = if (uiState.isConfirmPasswordVisible) "Hide password" else "Show password"
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = authFieldColors()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
TextField(
|
||||||
value = uiState.invite,
|
value = uiState.invite,
|
||||||
onValueChange = viewModel::onInviteChange,
|
onValueChange = viewModel::onInviteChange,
|
||||||
label = { Text("Invite Code (optional)") },
|
label = { Text("Invite Code (optional)") },
|
||||||
@ -181,33 +187,54 @@ fun RegisterScreen(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = authFieldColors()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
|
||||||
|
val primary = MaterialTheme.colorScheme.primary
|
||||||
|
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::register,
|
onClick = viewModel::register,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(48.dp),
|
.height(50.dp),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
contentPadding = PaddingValues(0.dp),
|
||||||
),
|
|
||||||
enabled = !uiState.isLoading
|
enabled = !uiState.isLoading
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
Box(
|
||||||
CircularProgressIndicator(
|
modifier = Modifier
|
||||||
modifier = Modifier.size(24.dp),
|
.fillMaxSize()
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
.background(
|
||||||
strokeWidth = 2.dp
|
if (!uiState.isLoading)
|
||||||
)
|
Brush.linearGradient(listOf(primary, primaryContainer))
|
||||||
} else {
|
else
|
||||||
Text(
|
Brush.linearGradient(
|
||||||
"Register",
|
listOf(
|
||||||
style = MaterialTheme.typography.labelLarge
|
primary.copy(alpha = 0.5f),
|
||||||
)
|
primaryContainer.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Create Account",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,10 +253,21 @@ fun RegisterScreen(
|
|||||||
Text(
|
Text(
|
||||||
"Log In",
|
"Log In",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun authFieldColors() = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
errorIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
package org.yobble.messenger.presentation.chat
|
package org.yobble.messenger.presentation.chat
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@ -16,13 +15,26 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.emoji2.emojipicker.EmojiPickerView
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
@ -38,7 +50,7 @@ import org.yobble.messenger.presentation.common.UserAvatar
|
|||||||
import org.yobble.messenger.util.formatLastSeen
|
import org.yobble.messenger.util.formatLastSeen
|
||||||
import org.yobble.messenger.util.formatUtcToLocalTime
|
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
@ -47,10 +59,17 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
var selectedMessage by remember { mutableStateOf<MessageItemDto?>(null) }
|
||||||
|
var editingMessage by remember { mutableStateOf<MessageItemDto?>(null) }
|
||||||
|
var deletingMessage by remember { mutableStateOf<MessageItemDto?>(null) }
|
||||||
|
var deleteForAll by remember { mutableStateOf(false) }
|
||||||
|
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Save scroll position when leaving the chat
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
val messages = viewModel.uiState.value.messages
|
val messages = viewModel.uiState.value.messages
|
||||||
@ -58,22 +77,16 @@ fun ChatScreen(
|
|||||||
val firstVisibleIndex = listState.firstVisibleItemIndex
|
val firstVisibleIndex = listState.firstVisibleItemIndex
|
||||||
val reversed = messages.asReversed()
|
val reversed = messages.asReversed()
|
||||||
val messageId = reversed.getOrNull(firstVisibleIndex)?.messageId
|
val messageId = reversed.getOrNull(firstVisibleIndex)?.messageId
|
||||||
if (messageId != null) {
|
if (messageId != null) viewModel.saveScrollPosition(messageId)
|
||||||
viewModel.saveScrollPosition(messageId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to saved position after initial load
|
|
||||||
LaunchedEffect(uiState.scrollToMessageId) {
|
LaunchedEffect(uiState.scrollToMessageId) {
|
||||||
val targetId = uiState.scrollToMessageId ?: return@LaunchedEffect
|
val targetId = uiState.scrollToMessageId ?: return@LaunchedEffect
|
||||||
val targetInt = targetId.toIntOrNull() ?: return@LaunchedEffect
|
val targetInt = targetId.toIntOrNull() ?: return@LaunchedEffect
|
||||||
val reversed = uiState.messages.asReversed()
|
val index = uiState.messages.asReversed().indexOfFirst { it.messageId == targetInt }
|
||||||
val index = reversed.indexOfFirst { it.messageId == targetInt }
|
if (index >= 0) listState.scrollToItem(index)
|
||||||
if (index >= 0) {
|
|
||||||
listState.scrollToItem(index)
|
|
||||||
}
|
|
||||||
viewModel.clearScrollTarget()
|
viewModel.clearScrollTarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,130 +94,49 @@ fun ChatScreen(
|
|||||||
viewModel.events.collectLatest { event ->
|
viewModel.events.collectLatest { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is ChatEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
is ChatEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
is ChatEvent.ScrollToBottom -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show scroll-to-bottom button when scrolled up more than 15 messages
|
val messageCount = uiState.messages.size
|
||||||
val showScrollToBottom by remember {
|
LaunchedEffect(messageCount) {
|
||||||
derivedStateOf {
|
if (messageCount > 0 && listState.firstVisibleItemIndex < 3) {
|
||||||
listState.firstVisibleItemIndex > 15
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load more when scrolled near the top (high indices in reversed layout)
|
val showScrollToBottom by remember {
|
||||||
val shouldLoadMore by remember {
|
derivedStateOf { listState.firstVisibleItemIndex > 15 }
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasMore = uiState.hasMore
|
||||||
|
val isLoading = uiState.isLoading
|
||||||
|
val shouldLoadMore by remember(hasMore, isLoading) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
val totalItems = listState.layoutInfo.totalItemsCount
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
|
lastVisible >= totalItems - 3 && hasMore && !isLoading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(shouldLoadMore) {
|
LaunchedEffect(shouldLoadMore) {
|
||||||
if (shouldLoadMore) viewModel.loadMore()
|
if (shouldLoadMore) viewModel.loadMore()
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
// Layout: Column with statusBarsPadding on top, imePadding on input at bottom
|
||||||
modifier = Modifier.imePadding(),
|
Column(
|
||||||
contentWindowInsets = WindowInsets(0),
|
modifier = Modifier
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
.fillMaxSize()
|
||||||
topBar = {
|
.background(MaterialTheme.colorScheme.background)
|
||||||
CenterAlignedTopAppBar(
|
.statusBarsPadding()
|
||||||
title = {
|
.navigationBarsPadding()
|
||||||
Column(
|
.imePadding()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
) {
|
||||||
modifier = Modifier.clickable {
|
// Top bar — fixed, not affected by keyboard
|
||||||
uiState.otherUserId?.let { onNavigateToProfile(it) }
|
ChatTopBar(uiState, onNavigateBack, onNavigateToProfile)
|
||||||
}
|
|
||||||
) {
|
// Messages — takes remaining space, shrinks when keyboard opens
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
|
||||||
uiState.chatTitle,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 17.sp,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
if (uiState.isVerified) {
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Verified,
|
|
||||||
contentDescription = "Verified",
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val lastSeenText = formatLastSeen(uiState.otherLastSeen)
|
|
||||||
if (lastSeenText.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = lastSeenText,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onNavigateBack) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (uiState.otherUserId != null) {
|
|
||||||
IconButton(onClick = {
|
|
||||||
uiState.otherUserId?.let { onNavigateToProfile(it) }
|
|
||||||
}) {
|
|
||||||
UserAvatar(
|
|
||||||
userId = uiState.otherUserId,
|
|
||||||
fileId = uiState.otherAvatarFileId,
|
|
||||||
displayName = uiState.chatTitle,
|
|
||||||
size = 32.dp,
|
|
||||||
fontSize = 13.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
actionIconContentColor = 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()) {
|
if (uiState.isLoading && uiState.messages.isEmpty()) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
@ -223,171 +155,352 @@ fun ChatScreen(
|
|||||||
items(uiState.messages.asReversed(), key = { it.messageId }) { message ->
|
items(uiState.messages.asReversed(), key = { it.messageId }) { message ->
|
||||||
MessageBubble(
|
MessageBubble(
|
||||||
message = message,
|
message = message,
|
||||||
isOutgoing = message.senderId == uiState.currentUserId
|
isOutgoing = message.senderId == uiState.currentUserId,
|
||||||
|
onLongClick = {
|
||||||
|
if (keyboardController != null) {
|
||||||
|
keyboardController.hide()
|
||||||
|
coroutineScope.launch {
|
||||||
|
kotlinx.coroutines.delay(250)
|
||||||
|
selectedMessage = message
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedMessage = message
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (uiState.isLoading && uiState.messages.isNotEmpty()) {
|
if (uiState.isLoading && uiState.messages.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Box(
|
Box(Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier
|
CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary)
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
if (showScrollToBottom) {
|
||||||
visible = showScrollToBottom,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
SmallFloatingActionButton(
|
SmallFloatingActionButton(
|
||||||
onClick = {
|
onClick = { coroutineScope.launch { listState.animateScrollToItem(0) } },
|
||||||
coroutineScope.launch {
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
listState.animateScrollToItem(0)
|
contentColor = MaterialTheme.colorScheme.primary,
|
||||||
}
|
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
|
||||||
},
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
contentColor = MaterialTheme.colorScheme.primary
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Scroll to bottom")
|
||||||
Icons.Default.KeyboardArrowDown,
|
|
||||||
contentDescription = "Scroll to bottom"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SnackbarHost(snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input bar
|
||||||
|
if (uiState.canSendMessage) {
|
||||||
|
MessageInputBar(
|
||||||
|
text = uiState.messageText,
|
||||||
|
onTextChange = viewModel::onMessageTextChange,
|
||||||
|
onSend = viewModel::sendMessage,
|
||||||
|
isSending = uiState.isSending,
|
||||||
|
showEmojiPicker = showEmojiPicker,
|
||||||
|
focusRequester = inputFocusRequester,
|
||||||
|
onToggleEmoji = {
|
||||||
|
if (showEmojiPicker) {
|
||||||
|
showEmojiPicker = false
|
||||||
|
inputFocusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
} else {
|
||||||
|
keyboardController?.hide()
|
||||||
|
showEmojiPicker = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "You can't send messages to this user",
|
||||||
|
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji picker panel
|
||||||
|
if (showEmojiPicker) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
EmojiPickerView(context).apply {
|
||||||
|
emojiGridColumns = 8
|
||||||
|
setOnEmojiPickedListener { emoji ->
|
||||||
|
val current = viewModel.uiState.value.messageText
|
||||||
|
viewModel.onMessageTextChange(current + emoji.emoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(280.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
MessageActionsSheet(selectedMessage, uiState.currentUserId, clipboardManager,
|
||||||
|
onDismiss = { selectedMessage = null },
|
||||||
|
onEdit = { editingMessage = it; viewModel.onMessageTextChange(it.content ?: ""); selectedMessage = null },
|
||||||
|
onDelete = { deletingMessage = it; deleteForAll = false; selectedMessage = null }
|
||||||
|
)
|
||||||
|
DeleteMessageDialog(deletingMessage, uiState.currentUserId, deleteForAll,
|
||||||
|
onDeleteForAllChange = { deleteForAll = it },
|
||||||
|
onConfirm = { viewModel.deleteMessage(it.messageId, deleteForAll); deletingMessage = null },
|
||||||
|
onDismiss = { deletingMessage = null }
|
||||||
|
)
|
||||||
|
EditMessageDialog(editingMessage, uiState.messageText, viewModel::onMessageTextChange,
|
||||||
|
onConfirm = { viewModel.editMessage(it.messageId, uiState.messageText.trim()); editingMessage = null; viewModel.onMessageTextChange("") },
|
||||||
|
onDismiss = { editingMessage = null; viewModel.onMessageTextChange("") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// region TopBar
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ChatTopBar(uiState: ChatUiState, onNavigateBack: () -> Unit, onNavigateToProfile: (String) -> Unit) {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable(enabled = uiState.otherUserId != null) {
|
||||||
|
uiState.otherUserId?.let { onNavigateToProfile(it) }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(uiState.chatTitle, fontWeight = FontWeight.Bold, fontSize = 17.sp, maxLines = 1)
|
||||||
|
if (uiState.isVerified) {
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(Icons.Default.Verified, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val lastSeenText = formatLastSeen(uiState.otherLastSeen)
|
||||||
|
if (lastSeenText.isNotEmpty()) {
|
||||||
|
Text(lastSeenText, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uiState.otherUserId != null) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
UserAvatar(uiState.otherUserId, uiState.otherAvatarFileId, uiState.chatTitle, 32.dp, 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
|
||||||
|
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Dialogs
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun MessageActionsSheet(msg: MessageItemDto?, currentUserId: String, clipboard: androidx.compose.ui.platform.ClipboardManager, onDismiss: () -> Unit, onEdit: (MessageItemDto) -> Unit, onDelete: (MessageItemDto) -> Unit) {
|
||||||
|
if (msg == null) return
|
||||||
|
val isOwn = msg.senderId == currentUserId
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceVariant) {
|
||||||
|
Column(Modifier.padding(bottom = 32.dp)) {
|
||||||
|
if (!msg.content.isNullOrBlank()) {
|
||||||
|
ActionRow(Icons.Default.ContentCopy, "Copy") { clipboard.setText(AnnotatedString(msg.content)); onDismiss() }
|
||||||
|
}
|
||||||
|
if (isOwn && !msg.content.isNullOrBlank()) {
|
||||||
|
ActionRow(Icons.Default.Edit, "Edit") { onEdit(msg) }
|
||||||
|
}
|
||||||
|
ActionRow(Icons.Default.Delete, "Delete", MaterialTheme.colorScheme.error) { onDelete(msg) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageBubble(
|
private fun DeleteMessageDialog(msg: MessageItemDto?, currentUserId: String, deleteForAll: Boolean, onDeleteForAllChange: (Boolean) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
||||||
message: MessageItemDto,
|
if (msg == null) return
|
||||||
isOutgoing: Boolean
|
AlertDialog(
|
||||||
) {
|
onDismissRequest = onDismiss,
|
||||||
val bubbleColor = if (isOutgoing)
|
title = { Text("Delete message?") },
|
||||||
MaterialTheme.colorScheme.primary
|
text = {
|
||||||
else
|
if (msg.senderId == currentUserId) {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
Row(Modifier.fillMaxWidth().clickable { onDeleteForAllChange(!deleteForAll) }, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(deleteForAll, onDeleteForAllChange)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Delete for everyone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(msg) }) { Text("Delete", color = MaterialTheme.colorScheme.error) } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val textColor = if (isOutgoing)
|
@Composable
|
||||||
MaterialTheme.colorScheme.onPrimary
|
private fun EditMessageDialog(msg: MessageItemDto?, text: String, onTextChange: (String) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
||||||
else
|
if (msg == null) return
|
||||||
MaterialTheme.colorScheme.onSurface
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Edit message") },
|
||||||
|
text = {
|
||||||
|
TextField(text, onTextChange, Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = TextFieldDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(msg) }, enabled = text.isNotBlank()) { Text("Save") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val timeColor = if (isOutgoing)
|
@Composable
|
||||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
|
private fun ActionRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, color: Color = MaterialTheme.colorScheme.onSurface, onClick: () -> Unit) {
|
||||||
else
|
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
Icon(icon, null, tint = color, modifier = Modifier.size(22.dp))
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val alignment = if (isOutgoing) Arrangement.End else Arrangement.Start
|
// endregion
|
||||||
val shape = if (isOutgoing)
|
|
||||||
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
|
|
||||||
else
|
|
||||||
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
|
|
||||||
|
|
||||||
|
// region MessageBubble
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun MessageBubble(message: MessageItemDto, isOutgoing: Boolean, onLongClick: () -> Unit = {}) {
|
||||||
|
val primary = MaterialTheme.colorScheme.primary
|
||||||
|
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
val textColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||||
|
val timeColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val shape = if (isOutgoing) RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp) else RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp)
|
||||||
val time = formatUtcToLocalTime(message.createdAt)
|
val time = formatUtcToLocalTime(message.createdAt)
|
||||||
|
|
||||||
Row(
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = alignment
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
Modifier.widthIn(max = 280.dp).clip(shape)
|
||||||
.widthIn(max = 280.dp)
|
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
||||||
.clip(shape)
|
.then(if (isOutgoing) Modifier.background(Brush.linearGradient(listOf(primary, primaryContainer))) else Modifier.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||||
.background(bubbleColor)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (!message.content.isNullOrBlank()) {
|
if (!message.content.isNullOrBlank()) Text(message.content, color = textColor, fontSize = 15.sp)
|
||||||
Text(
|
Spacer(Modifier.height(2.dp))
|
||||||
text = message.content,
|
Text("${if (message.isEdited) "edited " else ""}$time", color = timeColor, fontSize = 11.sp, modifier = Modifier.align(Alignment.End))
|
||||||
color = textColor,
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = time,
|
|
||||||
color = timeColor,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
modifier = Modifier.align(Alignment.End)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region InputBar
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageInputBar(
|
private fun MessageInputBar(
|
||||||
text: String,
|
text: String,
|
||||||
onTextChange: (String) -> Unit,
|
onTextChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
isSending: Boolean
|
isSending: Boolean,
|
||||||
|
showEmojiPicker: Boolean = false,
|
||||||
|
onToggleEmoji: () -> Unit = {},
|
||||||
|
focusRequester: androidx.compose.ui.focus.FocusRequester? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Surface(
|
val canSend = text.isNotBlank() && !isSending
|
||||||
shadowElevation = 8.dp,
|
|
||||||
color = MaterialTheme.colorScheme.surface
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
// Emoji toggle
|
||||||
modifier = Modifier
|
IconButton(onClick = onToggleEmoji, modifier = Modifier.size(44.dp)) {
|
||||||
.fillMaxWidth()
|
Icon(
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions,
|
||||||
.navigationBarsPadding(),
|
contentDescription = "Emoji",
|
||||||
verticalAlignment = Alignment.CenterVertically
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
) {
|
modifier = Modifier.size(24.dp)
|
||||||
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,
|
// Input field with attach inside
|
||||||
enabled = text.isNotBlank() && !isSending,
|
TextField(
|
||||||
modifier = Modifier
|
value = text,
|
||||||
.size(48.dp)
|
onValueChange = onTextChange,
|
||||||
.clip(CircleShape)
|
modifier = Modifier.weight(1f)
|
||||||
.background(
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
|
||||||
if (text.isNotBlank() && !isSending)
|
placeholder = {
|
||||||
MaterialTheme.colorScheme.primary
|
Text("Message...", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
else
|
},
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
)
|
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||||
) {
|
maxLines = 4,
|
||||||
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.Send,
|
Icons.Default.AttachFile,
|
||||||
contentDescription = "Send",
|
contentDescription = "Attach",
|
||||||
tint = if (text.isNotBlank() && !isSending)
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
MaterialTheme.colorScheme.onPrimary
|
modifier = Modifier
|
||||||
else
|
.size(24.dp)
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
.clickable { /* TODO: attach */ }
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.primary
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
// Send
|
||||||
|
IconButton(
|
||||||
|
onClick = onSend,
|
||||||
|
enabled = canSend,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (canSend)
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "Send",
|
||||||
|
tint = if (canSend)
|
||||||
|
MaterialTheme.colorScheme.onPrimary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import org.yobble.messenger.data.local.SessionManager
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
import org.yobble.messenger.data.remote.NetworkResult
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||||
|
import org.yobble.messenger.data.remote.dto.ProfileByUserIdDataDto
|
||||||
import org.yobble.messenger.data.remote.socket.SocketEvent
|
import org.yobble.messenger.data.remote.socket.SocketEvent
|
||||||
import org.yobble.messenger.data.remote.socket.SocketManager
|
import org.yobble.messenger.data.remote.socket.SocketManager
|
||||||
import org.yobble.messenger.data.repository.ChatRepositoryImpl
|
import org.yobble.messenger.data.repository.ChatRepositoryImpl
|
||||||
@ -36,12 +37,13 @@ data class ChatUiState(
|
|||||||
val currentUserId: String = "",
|
val currentUserId: String = "",
|
||||||
val otherUserId: String? = null,
|
val otherUserId: String? = null,
|
||||||
val otherAvatarFileId: String? = null,
|
val otherAvatarFileId: String? = null,
|
||||||
val otherLastSeen: Int? = null,
|
val otherLastSeen: String? = null,
|
||||||
val scrollToMessageId: String? = null
|
val scrollToMessageId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class ChatEvent {
|
sealed class ChatEvent {
|
||||||
data class ShowError(val message: String) : ChatEvent()
|
data class ShowError(val message: String) : ChatEvent()
|
||||||
|
data object ScrollToBottom : ChatEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@ -68,6 +70,8 @@ class ChatViewModel @Inject constructor(
|
|||||||
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
private val savedMessageId = sessionManager.getLastReadMessageId(chatId)
|
private val savedMessageId = sessionManager.getLastReadMessageId(chatId)
|
||||||
|
private val otherUserFromChatList: ProfileByUserIdDataDto? =
|
||||||
|
(chatRepository as? ChatRepositoryImpl)?.getChatData(chatId)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadCachedThenNetwork()
|
loadCachedThenNetwork()
|
||||||
@ -90,12 +94,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
if (current.none { it.messageId == message.messageId }) {
|
if (current.none { it.messageId == message.messageId }) {
|
||||||
val updated = current + message
|
val updated = current + message
|
||||||
_uiState.update { it.copy(messages = updated) }
|
_uiState.update { it.copy(messages = updated) }
|
||||||
// Save updated messages to cache
|
_events.emit(ChatEvent.ScrollToBottom)
|
||||||
(chatRepository as? ChatRepositoryImpl)?.let { repo ->
|
|
||||||
viewModelScope.launch {
|
|
||||||
repo.getCachedChatMessages(chatId) // trigger save via reversed list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("ChatViewModel", "Failed to parse socket message, reloading", e)
|
Log.w("ChatViewModel", "Failed to parse socket message, reloading", e)
|
||||||
@ -145,6 +144,8 @@ class ChatViewModel @Inject constructor(
|
|||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val items = result.data.data.items.reversed()
|
val items = result.data.data.items.reversed()
|
||||||
applyMessages(items, hasMore = result.data.data.hasMore, fromCache = false)
|
applyMessages(items, hasMore = result.data.data.hasMore, fromCache = false)
|
||||||
|
// Auto mark-read
|
||||||
|
markAllRead()
|
||||||
}
|
}
|
||||||
is NetworkResult.Error -> {
|
is NetworkResult.Error -> {
|
||||||
_uiState.update { it.copy(isLoading = false) }
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
@ -165,20 +166,29 @@ class ChatViewModel @Inject constructor(
|
|||||||
private fun applyMessages(items: List<MessageItemDto>, hasMore: Boolean, fromCache: Boolean) {
|
private fun applyMessages(items: List<MessageItemDto>, hasMore: Boolean, fromCache: Boolean) {
|
||||||
val otherMessage = items.firstOrNull { it.senderId != _uiState.value.currentUserId }
|
val otherMessage = items.firstOrNull { it.senderId != _uiState.value.currentUserId }
|
||||||
val otherUser = otherMessage?.senderData
|
val otherUser = otherMessage?.senderData
|
||||||
|
|
||||||
|
// Fall back to chat list data if no other user message found
|
||||||
|
val cachedChatData = if (otherUser == null) otherUserFromChatList else null
|
||||||
|
|
||||||
val title = otherUser?.customName
|
val title = otherUser?.customName
|
||||||
?: otherUser?.fullName
|
?: otherUser?.fullName
|
||||||
?: otherUser?.login
|
?: otherUser?.login
|
||||||
|
?: cachedChatData?.customName
|
||||||
|
?: cachedChatData?.fullName
|
||||||
|
?: cachedChatData?.login
|
||||||
?: _uiState.value.chatTitle
|
?: _uiState.value.chatTitle
|
||||||
|
|
||||||
|
val otherUserId = otherMessage?.senderId ?: cachedChatData?.userId ?: _uiState.value.otherUserId
|
||||||
val scrollTarget = if (_uiState.value.messages.isEmpty() && !fromCache) savedMessageId else null
|
val scrollTarget = if (_uiState.value.messages.isEmpty() && !fromCache) savedMessageId else null
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
messages = items,
|
messages = items,
|
||||||
chatTitle = title,
|
chatTitle = title,
|
||||||
otherUserId = otherMessage?.senderId,
|
otherUserId = otherUserId,
|
||||||
otherAvatarFileId = otherUser?.avatars?.current?.fileId,
|
otherAvatarFileId = otherUser?.avatars?.current?.fileId ?: cachedChatData?.avatars?.current?.fileId ?: it.otherAvatarFileId,
|
||||||
otherLastSeen = otherUser?.lastSeen,
|
otherLastSeen = otherUser?.lastSeenAt ?: cachedChatData?.lastSeenAt ?: it.otherLastSeen,
|
||||||
isVerified = otherUser?.isVerified == true,
|
isVerified = otherUser?.verification != null || cachedChatData?.verification != null,
|
||||||
canSendMessage = otherUser?.permissions?.youCanSendMessage != false,
|
canSendMessage = otherUser?.permissions?.youCanSendMessage ?: cachedChatData?.permissions?.youCanSendMessage ?: true,
|
||||||
hasMore = hasMore,
|
hasMore = hasMore,
|
||||||
isLoading = if (fromCache) true else false,
|
isLoading = if (fromCache) true else false,
|
||||||
scrollToMessageId = scrollTarget
|
scrollToMessageId = scrollTarget
|
||||||
@ -230,6 +240,57 @@ class ChatViewModel @Inject constructor(
|
|||||||
sessionManager.saveDraft(chatId, _uiState.value.messageText)
|
sessionManager.saveDraft(chatId, _uiState.value.messageText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun markAllRead() {
|
||||||
|
val chatId = _uiState.value.chatId
|
||||||
|
if (chatId.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.markRead(chatId, markAll = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessage(messageId: Int, deleteForAll: Boolean = false) {
|
||||||
|
val chatId = _uiState.value.chatId
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (chatRepository.deleteMessage(chatId, messageId, deleteForAll)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(messages = state.messages.filter { it.messageId != messageId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_events.emit(ChatEvent.ShowError("Failed to delete message"))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(ChatEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun editMessage(messageId: Int, newContent: String) {
|
||||||
|
val chatId = _uiState.value.chatId
|
||||||
|
if (newContent.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.editMessage(chatId, messageId, newContent)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(messages = state.messages.map {
|
||||||
|
if (it.messageId == messageId)
|
||||||
|
it.copy(content = result.data.data.content, isEdited = true, updatedAt = result.data.data.updatedAt)
|
||||||
|
else it
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error -> {
|
||||||
|
_events.emit(ChatEvent.ShowError("Failed to edit message"))
|
||||||
|
}
|
||||||
|
is NetworkResult.Exception -> {
|
||||||
|
_events.emit(ChatEvent.ShowError("Connection error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onMessageTextChange(text: String) {
|
fun onMessageTextChange(text: String) {
|
||||||
_uiState.update { it.copy(messageText = text) }
|
_uiState.update { it.copy(messageText = text) }
|
||||||
}
|
}
|
||||||
@ -245,6 +306,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
sessionManager.saveDraft(chatId, "")
|
sessionManager.saveDraft(chatId, "")
|
||||||
loadMessages()
|
loadMessages()
|
||||||
|
_events.emit(ChatEvent.ScrollToBottom)
|
||||||
}
|
}
|
||||||
is NetworkResult.Error -> {
|
is NetworkResult.Error -> {
|
||||||
_uiState.update { it.copy(isSending = false, messageText = text) }
|
_uiState.update { it.copy(isSending = false, messageText = text) }
|
||||||
|
|||||||
@ -18,6 +18,9 @@ import androidx.compose.ui.unit.TextUnit
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.SubcomposeAsyncImage
|
import coil.compose.SubcomposeAsyncImage
|
||||||
|
import coil.compose.SubcomposeAsyncImageContent
|
||||||
|
import coil.compose.AsyncImagePainter
|
||||||
|
import coil.request.CachePolicy
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import org.yobble.messenger.BuildConfig
|
import org.yobble.messenger.BuildConfig
|
||||||
|
|
||||||
@ -35,20 +38,24 @@ fun UserAvatar(
|
|||||||
SubcomposeAsyncImage(
|
SubcomposeAsyncImage(
|
||||||
model = ImageRequest.Builder(LocalContext.current)
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
.data(avatarUrl)
|
.data(avatarUrl)
|
||||||
.crossfade(true)
|
.memoryCacheKey(avatarUrl)
|
||||||
|
.diskCacheKey(avatarUrl)
|
||||||
|
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||||
|
.diskCachePolicy(CachePolicy.ENABLED)
|
||||||
|
.crossfade(false)
|
||||||
.build(),
|
.build(),
|
||||||
contentDescription = "Avatar",
|
contentDescription = "Avatar",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.clip(CircleShape),
|
.clip(CircleShape),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop
|
||||||
error = {
|
) {
|
||||||
InitialsAvatar(displayName, size, fontSize)
|
when (painter.state) {
|
||||||
},
|
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
|
||||||
loading = {
|
is AsyncImagePainter.State.Error -> InitialsAvatar(displayName, size, fontSize)
|
||||||
InitialsAvatar(displayName, size, fontSize)
|
else -> InitialsAvatar(displayName, size, fontSize)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
} else {
|
} else {
|
||||||
InitialsAvatar(displayName, size, fontSize)
|
InitialsAvatar(displayName, size, fontSize)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,11 +113,13 @@ fun ContactsScreen(
|
|||||||
} else {
|
} else {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
val shouldLoadMore by remember {
|
val hasMore = uiState.hasMore
|
||||||
|
val isLoading = uiState.isLoading
|
||||||
|
val shouldLoadMore by remember(hasMore, isLoading) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
val totalItems = listState.layoutInfo.totalItemsCount
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
|
lastVisible >= totalItems - 3 && hasMore && !isLoading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(shouldLoadMore) {
|
LaunchedEffect(shouldLoadMore) {
|
||||||
@ -239,7 +241,7 @@ private fun ContactItem(
|
|||||||
) {
|
) {
|
||||||
UserAvatar(
|
UserAvatar(
|
||||||
userId = contact.userId,
|
userId = contact.userId,
|
||||||
fileId = null,
|
fileId = contact.avatars?.current?.fileId,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
size = 48.dp,
|
size = 48.dp,
|
||||||
fontSize = 18.sp
|
fontSize = 18.sp
|
||||||
@ -276,11 +278,6 @@ private fun ContactItem(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(start = 76.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
thickness = 0.5.dp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
package org.yobble.messenger.presentation.main
|
package org.yobble.messenger.presentation.main
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@ -22,6 +28,7 @@ import androidx.compose.material.icons.filled.Search
|
|||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -39,6 +46,21 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
|
|||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
|
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import org.yobble.messenger.presentation.accounts.AccountSwitcherViewModel
|
||||||
|
import org.yobble.messenger.presentation.common.InitialsAvatar
|
||||||
import org.yobble.messenger.presentation.common.UserAvatar
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
import org.yobble.messenger.presentation.contacts.ContactsScreen
|
import org.yobble.messenger.presentation.contacts.ContactsScreen
|
||||||
import org.yobble.messenger.presentation.settings.SettingsScreen
|
import org.yobble.messenger.presentation.settings.SettingsScreen
|
||||||
@ -66,20 +88,35 @@ fun HomeScreen(
|
|||||||
onAccountSwitched: () -> Unit,
|
onAccountSwitched: () -> Unit,
|
||||||
onAddAccount: () -> Unit,
|
onAddAccount: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
viewModel: HomeViewModel = hiltViewModel()
|
viewModel: HomeViewModel = hiltViewModel(),
|
||||||
|
accountsViewModel: AccountSwitcherViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val accountsState by accountsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val tabs = HomeTab.entries
|
val tabs = HomeTab.entries
|
||||||
val pagerState = rememberPagerState(pageCount = { tabs.size })
|
val pagerState = rememberPagerState(pageCount = { tabs.size })
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val selectedTab = pagerState.currentPage
|
val useDrawer = uiState.navStyle == "drawer"
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
var drawerSelectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
val selectedTab = if (useDrawer) drawerSelectedTab else pagerState.currentPage
|
||||||
|
|
||||||
|
// Sync tabs when switching nav style
|
||||||
|
LaunchedEffect(useDrawer) {
|
||||||
|
if (useDrawer) {
|
||||||
|
drawerSelectedTab = pagerState.currentPage
|
||||||
|
} else {
|
||||||
|
pagerState.scrollToPage(drawerSelectedTab)
|
||||||
|
}
|
||||||
|
}
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
var navBarHeightDp by remember { mutableStateOf(0.dp) }
|
var navBarHeightDp by remember { mutableStateOf(0.dp) }
|
||||||
var selectedChatIds by remember { mutableStateOf(setOf<String>()) }
|
var selectedChatIds by remember { mutableStateOf(setOf<String>()) }
|
||||||
var selectedContactIds by remember { mutableStateOf(setOf<String>()) }
|
var selectedContactIds by remember { mutableStateOf(setOf<String>()) }
|
||||||
var deleteContactIds by remember { mutableStateOf(setOf<String>()) }
|
var deleteContactIds by remember { mutableStateOf(setOf<String>()) }
|
||||||
var renameContactId by remember { mutableStateOf<String?>(null) }
|
var renameContactId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showAccountPopup by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Clear selection when switching tabs
|
// Clear selection when switching tabs
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
@ -112,13 +149,181 @@ fun HomeScreen(
|
|||||||
selectedContactIds = emptySet()
|
selectedContactIds = emptySet()
|
||||||
}
|
}
|
||||||
selectedTab != 0 -> {
|
selectedTab != 0 -> {
|
||||||
coroutineScope.launch {
|
if (useDrawer) {
|
||||||
pagerState.animateScrollToPage(0)
|
drawerSelectedTab = 0
|
||||||
|
} else {
|
||||||
|
coroutineScope.launch {
|
||||||
|
pagerState.animateScrollToPage(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var showDrawerAccountPopup by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val drawerContent: @Composable () -> Unit = {
|
||||||
|
ModalDrawerSheet(
|
||||||
|
drawerContainerColor = MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
// Account header
|
||||||
|
val acc = uiState.activeAccount
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
drawerSelectedTab = tabs.indexOf(HomeTab.SETTINGS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = { showDrawerAccountPopup = true }
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 20.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (acc != null) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = acc.userId,
|
||||||
|
fileId = acc.avatarFileId,
|
||||||
|
displayName = acc.displayName ?: acc.login ?: "U",
|
||||||
|
size = 44.dp,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
acc.displayName ?: acc.login ?: "Account",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
if (acc.login != null) {
|
||||||
|
Text(
|
||||||
|
"@${acc.login}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showDrawerAccountPopup,
|
||||||
|
onDismissRequest = { showDrawerAccountPopup = false },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
accountsState.accounts.forEach { account ->
|
||||||
|
val isActive = account.accountId == accountsState.activeAccountId
|
||||||
|
val name = account.displayName ?: account.login ?: "Account"
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
UserAvatar(account.userId, account.avatarFileId, name, 28.dp, 11.sp)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showDrawerAccountPopup = false
|
||||||
|
if (!isActive) {
|
||||||
|
accountsViewModel.switchTo(account.accountId)
|
||||||
|
onAccountSwitched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.PersonAdd, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(28.dp).padding(2.dp))
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text("Add account", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showDrawerAccountPopup = false
|
||||||
|
accountsViewModel.prepareNewAccountLogin()
|
||||||
|
onAddAccount()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Chats
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text("Chats") },
|
||||||
|
selected = selectedTab == tabs.indexOf(HomeTab.CHATS),
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
drawerSelectedTab = tabs.indexOf(HomeTab.CHATS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
colors = NavigationDrawerItemDefaults.colors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text("Contacts") },
|
||||||
|
selected = selectedTab == tabs.indexOf(HomeTab.CONTACTS),
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
drawerSelectedTab = tabs.indexOf(HomeTab.CONTACTS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.Default.Contacts, contentDescription = null) },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
colors = NavigationDrawerItemDefaults.colors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text("Settings") },
|
||||||
|
selected = selectedTab == tabs.indexOf(HomeTab.SETTINGS),
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
drawerSelectedTab = tabs.indexOf(HomeTab.SETTINGS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
colors = NavigationDrawerItemDefaults.colors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Version
|
||||||
|
Text(
|
||||||
|
text = "Yobble v${org.yobble.messenger.BuildConfig.VERSION_NAME}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val scaffoldContent: @Composable () -> Unit = {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = {
|
snackbarHost = {
|
||||||
SnackbarHost(
|
SnackbarHost(
|
||||||
@ -164,10 +369,10 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@ -178,15 +383,28 @@ fun HomeScreen(
|
|||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = useDrawer,
|
||||||
|
enter = slideInHorizontally(initialOffsetX = { -it }),
|
||||||
|
exit = slideOutHorizontally(targetOffsetX = { -it })
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
coroutineScope.launch { drawerState.open() }
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Menu, contentDescription = "Menu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onNavigateToSearch) {
|
IconButton(onClick = onNavigateToSearch) {
|
||||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -198,7 +416,8 @@ fun HomeScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
if (SWIPE_NAVIGATION_ENABLED) {
|
if (SWIPE_NAVIGATION_ENABLED && !useDrawer) {
|
||||||
|
// Swipeable tabs with HorizontalPager
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@ -239,6 +458,8 @@ fun HomeScreen(
|
|||||||
viewModel.logout()
|
viewModel.logout()
|
||||||
onLogout()
|
onLogout()
|
||||||
},
|
},
|
||||||
|
navStyle = uiState.navStyle,
|
||||||
|
onToggleNavStyle = viewModel::toggleNavStyle,
|
||||||
bottomPadding = navBarHeightDp
|
bottomPadding = navBarHeightDp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -278,71 +499,168 @@ fun HomeScreen(
|
|||||||
viewModel.logout()
|
viewModel.logout()
|
||||||
onLogout()
|
onLogout()
|
||||||
},
|
},
|
||||||
|
navStyle = uiState.navStyle,
|
||||||
|
onToggleNavStyle = viewModel::toggleNavStyle,
|
||||||
bottomPadding = navBarHeightDp
|
bottomPadding = navBarHeightDp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating bottom navigation bar
|
// Bottom navigation bar
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !useDrawer,
|
||||||
|
enter = slideInVertically(initialOffsetY = { it }),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { it }),
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
|
) {
|
||||||
NavigationBar(
|
NavigationBar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.onGloballyPositioned { coordinates ->
|
.onGloballyPositioned { coordinates ->
|
||||||
val heightPx = coordinates.size.height
|
val heightPx = coordinates.size.height
|
||||||
navBarHeightDp = with(density) { heightPx.toDp() }
|
navBarHeightDp = with(density) { heightPx.toDp() }
|
||||||
}
|
},
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
.clip(RoundedCornerShape(24.dp))
|
tonalElevation = 0.dp
|
||||||
.navigationBarsPadding(),
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
|
||||||
tonalElevation = 8.dp
|
|
||||||
) {
|
) {
|
||||||
val activeAccount = uiState.activeAccount
|
val activeAccount = uiState.activeAccount
|
||||||
val indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
|
val indicatorColor = Color.Transparent
|
||||||
tabs.forEachIndexed { index, tab ->
|
tabs.forEachIndexed { index, tab ->
|
||||||
NavigationBarItem(
|
if (tab == HomeTab.SETTINGS) {
|
||||||
selected = selectedTab == index,
|
NavigationBarItem(
|
||||||
onClick = {
|
selected = selectedTab == index,
|
||||||
coroutineScope.launch {
|
onClick = {
|
||||||
if (SWIPE_NAVIGATION_ENABLED) {
|
coroutineScope.launch {
|
||||||
pagerState.animateScrollToPage(index)
|
if (SWIPE_NAVIGATION_ENABLED) pagerState.animateScrollToPage(index)
|
||||||
} else {
|
else pagerState.scrollToPage(index)
|
||||||
pagerState.scrollToPage(index)
|
}
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Box {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(22.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
if (SWIPE_NAVIGATION_ENABLED) pagerState.animateScrollToPage(index)
|
||||||
|
else pagerState.scrollToPage(index)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress = { showAccountPopup = true }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (activeAccount != null) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = activeAccount.userId,
|
||||||
|
fileId = activeAccount.avatarFileId,
|
||||||
|
displayName = activeAccount.displayName ?: activeAccount.login ?: "U",
|
||||||
|
size = 22.dp,
|
||||||
|
fontSize = 9.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showAccountPopup,
|
||||||
|
onDismissRequest = { showAccountPopup = false },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
accountsState.accounts.forEach { account ->
|
||||||
|
val isActive = account.accountId == accountsState.activeAccountId
|
||||||
|
val name = account.displayName ?: account.login ?: "Account"
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
UserAvatar(account.userId, account.avatarFileId, name, 28.dp, 11.sp)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showAccountPopup = false
|
||||||
|
if (!isActive) {
|
||||||
|
accountsViewModel.switchTo(account.accountId)
|
||||||
|
onAccountSwitched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.PersonAdd, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(28.dp).padding(2.dp))
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text("Add account", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showAccountPopup = false
|
||||||
|
accountsViewModel.prepareNewAccountLogin()
|
||||||
|
onAddAccount()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(tab.label, fontSize = 10.sp) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
indicatorColor = Color.Transparent,
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
if (SWIPE_NAVIGATION_ENABLED) pagerState.animateScrollToPage(index)
|
||||||
|
else pagerState.scrollToPage(index)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
icon = {
|
||||||
icon = {
|
if (tab.icon != null) Icon(
|
||||||
Box(
|
tab.icon, contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier.size(22.dp)
|
||||||
.size(36.dp)
|
)
|
||||||
.then(
|
},
|
||||||
if (selectedTab == index)
|
label = { Text(tab.label, fontSize = 10.sp) },
|
||||||
Modifier.clip(CircleShape).background(indicatorColor)
|
colors = NavigationBarItemDefaults.colors(
|
||||||
else Modifier
|
indicatorColor = Color.Transparent,
|
||||||
),
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
contentAlignment = Alignment.Center
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
) {
|
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||||
if (tab == HomeTab.SETTINGS && activeAccount != null) {
|
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
UserAvatar(
|
)
|
||||||
userId = activeAccount.userId,
|
|
||||||
fileId = activeAccount.avatarFileId,
|
|
||||||
displayName = activeAccount.displayName ?: activeAccount.login ?: "U",
|
|
||||||
size = 24.dp,
|
|
||||||
fontSize = 10.sp
|
|
||||||
)
|
|
||||||
} else if (tab.icon != null) {
|
|
||||||
Icon(tab.icon, contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(tab.label, fontSize = 11.sp) },
|
|
||||||
colors = NavigationBarItemDefaults.colors(
|
|
||||||
indicatorColor = Color.Transparent
|
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
gesturesEnabled = useDrawer,
|
||||||
|
drawerContent = drawerContent,
|
||||||
|
content = scaffoldContent
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -368,6 +686,8 @@ private fun TabContent(
|
|||||||
onAccountSwitched: () -> Unit,
|
onAccountSwitched: () -> Unit,
|
||||||
onAddAccount: () -> Unit,
|
onAddAccount: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
navStyle: String = "bottom_bar",
|
||||||
|
onToggleNavStyle: () -> Unit = {},
|
||||||
bottomPadding: androidx.compose.ui.unit.Dp
|
bottomPadding: androidx.compose.ui.unit.Dp
|
||||||
) {
|
) {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
@ -399,6 +719,8 @@ private fun TabContent(
|
|||||||
onAccountSwitched = onAccountSwitched,
|
onAccountSwitched = onAccountSwitched,
|
||||||
onAddAccount = onAddAccount,
|
onAddAccount = onAddAccount,
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
|
navStyle = navStyle,
|
||||||
|
onToggleNavStyle = onToggleNavStyle,
|
||||||
bottomPadding = bottomPadding
|
bottomPadding = bottomPadding
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -442,11 +764,13 @@ private fun ChatsContent(
|
|||||||
} else {
|
} else {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
val shouldLoadMore by remember {
|
val hasMore = uiState.hasMore
|
||||||
|
val isLoading = uiState.isLoading
|
||||||
|
val shouldLoadMore by remember(hasMore, isLoading) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
val totalItems = listState.layoutInfo.totalItemsCount
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
|
lastVisible >= totalItems - 3 && hasMore && !isLoading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(shouldLoadMore) {
|
LaunchedEffect(shouldLoadMore) {
|
||||||
@ -507,12 +831,12 @@ private fun ChatListItem(
|
|||||||
?: chatData?.fullName
|
?: chatData?.fullName
|
||||||
?: chatData?.login
|
?: chatData?.login
|
||||||
?: chat.chatType.replaceFirstChar { it.uppercase() }
|
?: chat.chatType.replaceFirstChar { it.uppercase() }
|
||||||
val isVerified = chatData?.isVerified == true
|
val isVerified = chatData?.verification != null
|
||||||
val lastMessageText = chat.lastMessage?.content ?: ""
|
val lastMessageText = chat.lastMessage?.content ?: ""
|
||||||
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
|
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
|
||||||
|
|
||||||
val bgColor = if (isSelected)
|
val bgColor = if (isSelected)
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
else
|
else
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
|
|
||||||
@ -524,7 +848,7 @@ private fun ChatListItem(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick
|
onLongClick = onLongClick
|
||||||
)
|
)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
UserAvatar(
|
UserAvatar(
|
||||||
@ -610,9 +934,4 @@ private fun ChatListItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(start = 80.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
thickness = 0.5.dp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import android.app.Application
|
||||||
|
import org.yobble.messenger.YobbleApp
|
||||||
import org.yobble.messenger.data.local.AccountInfo
|
import org.yobble.messenger.data.local.AccountInfo
|
||||||
import org.yobble.messenger.data.local.SessionManager
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
import org.yobble.messenger.data.remote.NetworkResult
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
@ -28,7 +30,8 @@ data class HomeUiState(
|
|||||||
val chats: List<PrivateChatListItemDto> = emptyList(),
|
val chats: List<PrivateChatListItemDto> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val hasMore: Boolean = false,
|
val hasMore: Boolean = false,
|
||||||
val activeAccount: AccountInfo? = null
|
val activeAccount: AccountInfo? = null,
|
||||||
|
val navStyle: String = "bottom_bar"
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class HomeEvent {
|
sealed class HomeEvent {
|
||||||
@ -40,7 +43,8 @@ class HomeViewModel @Inject constructor(
|
|||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val socketManager: SocketManager,
|
private val socketManager: SocketManager,
|
||||||
private val sessionManager: SessionManager
|
private val sessionManager: SessionManager,
|
||||||
|
application: Application
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(HomeUiState())
|
private val _uiState = MutableStateFlow(HomeUiState())
|
||||||
@ -52,6 +56,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
private var refreshJob: Job? = null
|
private var refreshJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
(application as? YobbleApp)?.resetImageLoaderIfNeeded()
|
||||||
socketManager.connect()
|
socketManager.connect()
|
||||||
loadCachedThenNetwork()
|
loadCachedThenNetwork()
|
||||||
loadActiveAccount()
|
loadActiveAccount()
|
||||||
@ -61,7 +66,13 @@ class HomeViewModel @Inject constructor(
|
|||||||
fun refreshActiveAccount() {
|
fun refreshActiveAccount() {
|
||||||
val activeId = sessionManager.activeAccountId.value
|
val activeId = sessionManager.activeAccountId.value
|
||||||
val account = sessionManager.getAccounts().find { it.accountId == activeId }
|
val account = sessionManager.getAccounts().find { it.accountId == activeId }
|
||||||
_uiState.update { it.copy(activeAccount = account) }
|
_uiState.update { it.copy(activeAccount = account, navStyle = sessionManager.navStyle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleNavStyle() {
|
||||||
|
val newStyle = if (sessionManager.navStyle == "bottom_bar") "drawer" else "bottom_bar"
|
||||||
|
sessionManager.navStyle = newStyle
|
||||||
|
_uiState.update { it.copy(navStyle = newStyle) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadActiveAccount() = refreshActiveAccount()
|
private fun loadActiveAccount() = refreshActiveAccount()
|
||||||
|
|||||||
@ -3,9 +3,11 @@ package org.yobble.messenger.presentation.profile
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@ -15,6 +17,8 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
import androidx.compose.material.icons.filled.CameraAlt
|
import androidx.compose.material.icons.filled.CameraAlt
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.EmojiEvents
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@ -26,12 +30,14 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import org.yobble.messenger.data.remote.dto.AchievementItemDto
|
||||||
import org.yobble.messenger.presentation.common.FullScreenImageViewer
|
import org.yobble.messenger.presentation.common.FullScreenImageViewer
|
||||||
import org.yobble.messenger.presentation.common.UserAvatar
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
import org.yobble.messenger.presentation.common.buildAvatarUrl
|
import org.yobble.messenger.presentation.common.buildAvatarUrl
|
||||||
@ -78,10 +84,10 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -207,44 +213,55 @@ fun ProfileScreen(
|
|||||||
if (profile.login.isNotBlank()) {
|
if (profile.login.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = "@${profile.login}",
|
text = "@${profile.login}",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rating
|
// Stats row
|
||||||
if (profile.rating != null) {
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Row(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
modifier = Modifier
|
||||||
Icon(
|
.fillMaxWidth()
|
||||||
Icons.Default.Star,
|
.padding(horizontal = 32.dp),
|
||||||
contentDescription = "Rating",
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
tint = Color(0xFFFFC107),
|
) {
|
||||||
modifier = Modifier.size(20.dp)
|
if (profile.rating != null) {
|
||||||
|
ProfileStat(
|
||||||
|
value = String.format("%.1f", profile.rating),
|
||||||
|
label = "Rating"
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
}
|
||||||
Text(
|
ProfileStat(
|
||||||
text = String.format("%.1f", profile.rating),
|
value = formatUtcToShortDate(profile.createdAt),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
label = "Joined"
|
||||||
fontWeight = FontWeight.Medium
|
)
|
||||||
|
if (uiState.isMyProfile && profile.balances.isNotEmpty()) {
|
||||||
|
val bal = profile.balances.first()
|
||||||
|
ProfileStat(
|
||||||
|
value = bal.displayBalance?.toString() ?: bal.balance,
|
||||||
|
label = bal.currency.uppercase()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
if (!showTopBar && uiState.isMyProfile && !uiState.isEditing && uiState.profile != null) {
|
if (!showTopBar && uiState.isMyProfile && !uiState.isEditing && uiState.profile != null) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
OutlinedButton(onClick = viewModel::startEditing) {
|
OutlinedButton(onClick = viewModel::startEditing) {
|
||||||
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp))
|
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text("Edit profile")
|
Text("Edit profile")
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!uiState.isMyProfile && profile.canSendMessage && onNavigateToChat != null) {
|
if (!uiState.isMyProfile && profile.canSendMessage && onNavigateToChat != null) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::createChat,
|
onClick = viewModel::createChat,
|
||||||
enabled = !uiState.isCreatingChat
|
enabled = !uiState.isCreatingChat,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
if (uiState.isCreatingChat) {
|
if (uiState.isCreatingChat) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@ -258,9 +275,10 @@ fun ProfileScreen(
|
|||||||
Text("Send message")
|
Text("Send message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
if (uiState.isEditing) {
|
if (uiState.isEditing) {
|
||||||
EditProfileSection(
|
EditProfileSection(
|
||||||
@ -280,6 +298,19 @@ fun ProfileScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Achievements section
|
||||||
|
if (uiState.achievements.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
AchievementsSection(achievements = uiState.achievements)
|
||||||
|
} else if (uiState.isLoadingAchievements) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp + bottomPadding))
|
Spacer(modifier = Modifier.height(24.dp + bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -297,34 +328,65 @@ private fun ProfileInfoSection(
|
|||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
if (!profile.bio.isNullOrBlank()) {
|
if (!profile.bio.isNullOrBlank()) {
|
||||||
ProfileInfoCard(label = "Bio", value = profile.bio)
|
Text(
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
text = "About",
|
||||||
}
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
ProfileInfoCard(
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
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()}"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = profile.bio,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isMyProfile) {
|
if (!isMyProfile) {
|
||||||
val rel = profile.relationship
|
val rel = profile.relationship
|
||||||
if (rel != null) {
|
if (rel != null) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
if (rel.isTargetInContacts) {
|
if (rel.isTargetInContacts) {
|
||||||
ProfileInfoCard(label = "Contact", value = "In your contacts")
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "In your contacts",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
if (rel.isTargetBlocked) {
|
if (rel.isTargetBlocked) {
|
||||||
ProfileInfoCard(label = "Blocked", value = "You blocked this user")
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "You blocked this user",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -332,26 +394,19 @@ private fun ProfileInfoSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProfileInfoCard(label: String, value: String) {
|
private fun ProfileStat(value: String, label: String) {
|
||||||
Card(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Text(
|
||||||
colors = CardDefaults.cardColors(
|
text = value,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
style = MaterialTheme.typography.titleMedium,
|
||||||
),
|
fontWeight = FontWeight.Bold,
|
||||||
shape = RoundedCornerShape(12.dp)
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
) {
|
)
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Text(
|
||||||
Text(
|
text = label,
|
||||||
text = label,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
)
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -438,6 +493,226 @@ private fun EditProfileSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AchievementsSection(
|
||||||
|
achievements: Map<String, List<AchievementItemDto>>
|
||||||
|
) {
|
||||||
|
if (achievements.isEmpty()) return
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Achievements",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
achievements.forEach { (category, items) ->
|
||||||
|
AchievementGroup(category = category, items = items)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AchievementGroup(
|
||||||
|
category: String,
|
||||||
|
items: List<AchievementItemDto>
|
||||||
|
) {
|
||||||
|
// First item = master achievement, rest = sub-achievements
|
||||||
|
val master = items.firstOrNull() ?: return
|
||||||
|
val others = items.drop(1)
|
||||||
|
val completedCount = items.count { it.isCompleted }
|
||||||
|
val masterColor = badgeColor(master.badgeType)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
|
// Category header + progress
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = category,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$completedCount/${items.size}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Master achievement — larger
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(masterColor.copy(alpha = if (master.isCompleted) 0.2f else 0.08f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (master.isCompleted) Icons.Default.EmojiEvents else Icons.Default.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = masterColor.copy(alpha = if (master.isCompleted) 1f else 0.4f),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = master.name,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (master.isCompleted) 1f else 0.5f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (!master.description.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = master.description,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-achievements — horizontal scroll, single row
|
||||||
|
if (others.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
others.forEach { achievement ->
|
||||||
|
SmallAchievementIcon(achievement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SmallAchievementIcon(achievement: AchievementItemDto) {
|
||||||
|
val color = badgeColor(achievement.badgeType)
|
||||||
|
val alpha = if (achievement.isCompleted) 1f else 0.35f
|
||||||
|
var showTooltip by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(48.dp)
|
||||||
|
.clickable { showTooltip = true }
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color.copy(alpha = if (achievement.isCompleted) 0.2f else 0.06f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (achievement.isCompleted) Icons.Default.EmojiEvents else Icons.Default.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color.copy(alpha = alpha),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = achievement.name,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontSize = 8.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showTooltip,
|
||||||
|
onDismissRequest = { showTooltip = false },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).widthIn(max = 200.dp)) {
|
||||||
|
Text(
|
||||||
|
text = achievement.name,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = achievement.badgeType.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
if (!achievement.description.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = achievement.description,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (achievement.requiredProgress != null && achievement.requiredProgress > 0) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
val progress = (achievement.progress ?: 0).toFloat() / achievement.requiredProgress
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress.coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp)),
|
||||||
|
color = color,
|
||||||
|
trackColor = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${achievement.progress ?: 0}/${achievement.requiredProgress}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun badgeColor(badgeType: String): Color {
|
||||||
|
return when (badgeType) {
|
||||||
|
"bronze" -> Color(0xFFCD7F32)
|
||||||
|
"silver" -> Color(0xFF9E9E9E)
|
||||||
|
"gold" -> Color(0xFFFFD700)
|
||||||
|
"platinum" -> Color(0xFF7B8794)
|
||||||
|
"diamond" -> Color(0xFF00BCD4)
|
||||||
|
"legendary" -> Color(0xFFFF6F00)
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatUtcToLocalDate(isoString: String): String {
|
private fun formatUtcToLocalDate(isoString: String): String {
|
||||||
return try {
|
return try {
|
||||||
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
|
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
|
||||||
@ -447,3 +722,13 @@ private fun formatUtcToLocalDate(isoString: String): String {
|
|||||||
isoString.substringBefore("T")
|
isoString.substringBefore("T")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatUtcToShortDate(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("MMM yyyy"))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
isoString.substringBefore("T")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,11 +13,13 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.yobble.messenger.data.remote.NetworkResult
|
import org.yobble.messenger.data.remote.NetworkResult
|
||||||
|
import org.yobble.messenger.data.remote.dto.AchievementItemDto
|
||||||
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
|
import org.yobble.messenger.data.remote.dto.ProfileUpdateRequestDto
|
||||||
import org.yobble.messenger.data.remote.dto.RatingDataDto
|
import org.yobble.messenger.data.remote.dto.RatingDataDto
|
||||||
import org.yobble.messenger.data.remote.dto.RelationshipStatusDto
|
import org.yobble.messenger.data.remote.dto.RelationshipStatusDto
|
||||||
import org.yobble.messenger.data.remote.dto.WalletBalanceDto
|
import org.yobble.messenger.data.remote.dto.WalletBalanceDto
|
||||||
import org.yobble.messenger.data.local.SessionManager
|
import org.yobble.messenger.data.local.SessionManager
|
||||||
|
import org.yobble.messenger.domain.repository.AchievementRepository
|
||||||
import org.yobble.messenger.domain.repository.ChatRepository
|
import org.yobble.messenger.domain.repository.ChatRepository
|
||||||
import org.yobble.messenger.domain.repository.ProfileRepository
|
import org.yobble.messenger.domain.repository.ProfileRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@ -46,7 +48,9 @@ data class ProfileUiState(
|
|||||||
val isCreatingChat: Boolean = false,
|
val isCreatingChat: Boolean = false,
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
val editFullName: String = "",
|
val editFullName: String = "",
|
||||||
val editBio: String = ""
|
val editBio: String = "",
|
||||||
|
val achievements: Map<String, List<AchievementItemDto>> = emptyMap(),
|
||||||
|
val isLoadingAchievements: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class ProfileEvent {
|
sealed class ProfileEvent {
|
||||||
@ -60,6 +64,7 @@ class ProfileViewModel @Inject constructor(
|
|||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val profileRepository: ProfileRepository,
|
private val profileRepository: ProfileRepository,
|
||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
|
private val achievementRepository: AchievementRepository,
|
||||||
private val sessionManager: SessionManager
|
private val sessionManager: SessionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@ -74,6 +79,7 @@ class ProfileViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadProfile()
|
loadProfile()
|
||||||
|
loadAchievements()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadProfile() {
|
fun loadProfile() {
|
||||||
@ -96,7 +102,7 @@ class ProfileViewModel @Inject constructor(
|
|||||||
login = p.login,
|
login = p.login,
|
||||||
displayName = p.fullName ?: p.login,
|
displayName = p.fullName ?: p.login,
|
||||||
bio = p.bio,
|
bio = p.bio,
|
||||||
isVerified = p.isVerified == true,
|
isVerified = p.verification != null,
|
||||||
rating = p.rating.rating,
|
rating = p.rating.rating,
|
||||||
createdAt = p.createdAt,
|
createdAt = p.createdAt,
|
||||||
avatarFileId = p.avatars?.current?.fileId,
|
avatarFileId = p.avatars?.current?.fileId,
|
||||||
@ -139,7 +145,7 @@ class ProfileViewModel @Inject constructor(
|
|||||||
login = p.login ?: "",
|
login = p.login ?: "",
|
||||||
displayName = p.customName ?: p.fullName ?: p.login?.let { "@$it" } ?: "User",
|
displayName = p.customName ?: p.fullName ?: p.login?.let { "@$it" } ?: "User",
|
||||||
bio = p.bio,
|
bio = p.bio,
|
||||||
isVerified = p.isVerified == true,
|
isVerified = p.verification != null,
|
||||||
isSystem = p.isSystem == true,
|
isSystem = p.isSystem == true,
|
||||||
rating = p.rating.rating,
|
rating = p.rating.rating,
|
||||||
createdAt = p.createdAt,
|
createdAt = p.createdAt,
|
||||||
@ -162,6 +168,30 @@ class ProfileViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadAchievements() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoadingAchievements = true) }
|
||||||
|
val result = if (isMyProfile) {
|
||||||
|
achievementRepository.getMyAchievements()
|
||||||
|
} else {
|
||||||
|
achievementRepository.getUserAchievements(userId!!)
|
||||||
|
}
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
achievements = result.data.data.items,
|
||||||
|
isLoadingAchievements = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkResult.Error, is NetworkResult.Exception -> {
|
||||||
|
_uiState.update { it.copy(isLoadingAchievements = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun startEditing() {
|
fun startEditing() {
|
||||||
if (!isMyProfile) return
|
if (!isMyProfile) return
|
||||||
val profile = _uiState.value.profile ?: return
|
val profile = _uiState.value.profile ?: return
|
||||||
|
|||||||
@ -79,8 +79,8 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -149,7 +149,7 @@ private fun SearchResultItem(
|
|||||||
?: profile?.fullName
|
?: profile?.fullName
|
||||||
?: profile?.login?.let { "@$it" }
|
?: profile?.login?.let { "@$it" }
|
||||||
?: "User"
|
?: "User"
|
||||||
val isVerified = profile?.isVerified == true
|
val isVerified = profile?.verification != null
|
||||||
val rating = profile?.rating?.rating
|
val rating = profile?.rating?.rating
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@ -61,9 +61,9 @@ fun BlacklistScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -158,7 +158,7 @@ private fun BlacklistItem(
|
|||||||
) {
|
) {
|
||||||
UserAvatar(
|
UserAvatar(
|
||||||
userId = item.userId,
|
userId = item.userId,
|
||||||
fileId = null,
|
fileId = item.avatars?.current?.fileId,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
size = 44.dp,
|
size = 44.dp,
|
||||||
fontSize = 16.sp
|
fontSize = 16.sp
|
||||||
|
|||||||
@ -52,9 +52,9 @@ fun ChangePasswordScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,9 +46,9 @@ fun PrivacyScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -159,33 +159,9 @@ fun PrivacyScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
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))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
SectionHeader("Groups & Invites")
|
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(
|
SelectItem(
|
||||||
title = "Public invite",
|
title = "Public invite",
|
||||||
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
|
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),
|
||||||
|
|||||||
@ -1,23 +1,28 @@
|
|||||||
package org.yobble.messenger.presentation.settings
|
package org.yobble.messenger.presentation.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Computer
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.Tablet
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
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.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import org.yobble.messenger.data.remote.dto.UserSessionItemDto
|
import org.yobble.messenger.data.remote.dto.UserSessionItemDto
|
||||||
import org.yobble.messenger.util.formatUtcToLocalTime
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -42,16 +47,16 @@ fun SessionsScreen(
|
|||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Sessions", fontWeight = FontWeight.Bold) },
|
title = { Text("Active Sessions", fontWeight = FontWeight.Bold) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -71,9 +76,26 @@ fun SessionsScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding),
|
.padding(padding),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Control and manage all devices currently logged in to your account. Terminate any unrecognized activity instantly.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentSession = uiState.sessions.find { it.isCurrent }
|
||||||
|
if (currentSession != null) {
|
||||||
|
item {
|
||||||
|
SessionCard(session = currentSession, onRevoke = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val otherSessions = uiState.sessions.filter { !it.isCurrent }
|
val otherSessions = uiState.sessions.filter { !it.isCurrent }
|
||||||
|
|
||||||
if (otherSessions.size > 1) {
|
if (otherSessions.size > 1) {
|
||||||
item {
|
item {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@ -89,30 +111,7 @@ fun SessionsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()) {
|
if (otherSessions.isNotEmpty()) {
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Other sessions",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(otherSessions, key = { it.id }) { session ->
|
items(otherSessions, key = { it.id }) { session ->
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
@ -130,76 +129,120 @@ private fun SessionCard(
|
|||||||
session: UserSessionItemDto,
|
session: UserSessionItemDto,
|
||||||
onRevoke: (() -> Unit)?
|
onRevoke: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
|
val deviceInfo = parseDeviceInfo(session.userAgent, session.clientType)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (session.isCurrent)
|
containerColor = if (session.isCurrent)
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||||
else
|
else
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Row(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
// Device icon
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
if (session.isCurrent)
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.PhoneAndroid,
|
deviceInfo.icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(20.dp),
|
tint = if (session.isCurrent)
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
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.width(12.dp))
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = deviceInfo.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
if (session.isCurrent) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Current",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
|
||||||
|
if (!session.ipAddress.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = session.ipAddress,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "IP: ${session.ipAddress}",
|
text = "Active: ${formatSessionDate(session.lastRefreshAt)}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
if (onRevoke != null) {
|
||||||
Text(
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
text = "Last active: ${formatSessionDate(session.lastRefreshAt)}",
|
TextButton(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
onClick = onRevoke,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
colors = ButtonDefaults.textButtonColors(
|
||||||
)
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
Text(
|
),
|
||||||
text = "Created: ${formatSessionDate(session.createdAt)}",
|
contentPadding = PaddingValues(0.dp),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
modifier = Modifier.height(32.dp)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
) {
|
||||||
)
|
Text("Terminate session", fontSize = 13.sp)
|
||||||
|
}
|
||||||
if (onRevoke != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
TextButton(
|
|
||||||
onClick = onRevoke,
|
|
||||||
colors = ButtonDefaults.textButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Revoke")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class DeviceInfo(val name: String, val icon: ImageVector)
|
||||||
|
|
||||||
|
private fun parseDeviceInfo(userAgent: String?, clientType: String): DeviceInfo {
|
||||||
|
val ua = userAgent?.lowercase() ?: ""
|
||||||
|
return when {
|
||||||
|
ua.contains("ipad") -> DeviceInfo("iPad", Icons.Default.Tablet)
|
||||||
|
ua.contains("iphone") -> DeviceInfo("iPhone", Icons.Default.PhoneAndroid)
|
||||||
|
ua.contains("macintosh") || ua.contains("mac os") -> DeviceInfo("Mac", Icons.Default.Computer)
|
||||||
|
ua.contains("windows") -> DeviceInfo("Windows PC", Icons.Default.Computer)
|
||||||
|
ua.contains("linux") && !ua.contains("android") -> DeviceInfo("Linux PC", Icons.Default.Computer)
|
||||||
|
ua.contains("android") -> DeviceInfo("Android", Icons.Default.PhoneAndroid)
|
||||||
|
clientType == "web" -> DeviceInfo("Web Browser", Icons.Default.Computer)
|
||||||
|
clientType == "mobile" -> DeviceInfo("Mobile", Icons.Default.PhoneAndroid)
|
||||||
|
clientType == "desktop" -> DeviceInfo("Desktop", Icons.Default.Computer)
|
||||||
|
else -> DeviceInfo(clientType.replaceFirstChar { it.uppercase() }, Icons.Default.PhoneAndroid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatSessionDate(isoString: String): String {
|
private fun formatSessionDate(isoString: String): String {
|
||||||
return try {
|
return try {
|
||||||
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
|
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Block
|
import androidx.compose.material.icons.filled.Block
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
@ -45,6 +46,8 @@ fun SettingsScreen(
|
|||||||
onAccountSwitched: () -> Unit,
|
onAccountSwitched: () -> Unit,
|
||||||
onAddAccount: () -> Unit,
|
onAddAccount: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
navStyle: String = "bottom_bar",
|
||||||
|
onToggleNavStyle: () -> Unit = {},
|
||||||
bottomPadding: Dp = 0.dp,
|
bottomPadding: Dp = 0.dp,
|
||||||
accountsViewModel: AccountSwitcherViewModel = hiltViewModel()
|
accountsViewModel: AccountSwitcherViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
@ -63,9 +66,10 @@ fun SettingsScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
.padding(top = 8.dp, bottom = bottomPadding + 8.dp)
|
.padding(top = 8.dp, bottom = bottomPadding + 8.dp)
|
||||||
) {
|
) {
|
||||||
// Centered profile section
|
// Profile card
|
||||||
if (activeAccount != null) {
|
if (activeAccount != null) {
|
||||||
ProfileSection(
|
ProfileSection(
|
||||||
account = activeAccount,
|
account = activeAccount,
|
||||||
@ -73,11 +77,7 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
thickness = 0.5.dp
|
|
||||||
)
|
|
||||||
|
|
||||||
// Other accounts
|
// Other accounts
|
||||||
if (otherAccounts.isNotEmpty()) {
|
if (otherAccounts.isNotEmpty()) {
|
||||||
@ -87,31 +87,34 @@ fun SettingsScreen(
|
|||||||
onClick = { accountsViewModel.switchTo(account.accountId) },
|
onClick = { accountsViewModel.switchTo(account.accountId) },
|
||||||
onRemove = { accountsViewModel.removeAccount(account.accountId) }
|
onRemove = { accountsViewModel.removeAccount(account.accountId) }
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add account
|
// Add account
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.clickable {
|
.clickable {
|
||||||
accountsViewModel.prepareNewAccountLogin()
|
accountsViewModel.prepareNewAccountLogin()
|
||||||
onAddAccount()
|
onAddAccount()
|
||||||
}
|
}
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Add,
|
Icons.Default.Add,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -124,57 +127,114 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
thickness = 0.5.dp
|
|
||||||
)
|
|
||||||
|
|
||||||
// Settings items
|
// UI Style
|
||||||
SettingsMenuItem(
|
Card(
|
||||||
icon = Icons.Default.Shield,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
title = "Privacy",
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
subtitle = "Visibility, messages, search",
|
shape = RoundedCornerShape(16.dp)
|
||||||
onClick = onNavigateToPrivacy
|
) {
|
||||||
)
|
Row(
|
||||||
SettingsMenuItem(
|
modifier = Modifier
|
||||||
icon = Icons.Default.PhoneAndroid,
|
.fillMaxWidth()
|
||||||
title = "Sessions",
|
.clickable(onClick = onToggleNavStyle)
|
||||||
subtitle = "Active sessions and devices",
|
.padding(16.dp),
|
||||||
onClick = onNavigateToSessions
|
verticalAlignment = Alignment.CenterVertically
|
||||||
)
|
) {
|
||||||
SettingsMenuItem(
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
icon = Icons.Default.Lock,
|
Text("Navigation", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
||||||
title = "Change password",
|
Text(
|
||||||
subtitle = "Update your account password",
|
if (navStyle == "drawer") "Side menu" else "Bottom bar",
|
||||||
onClick = onNavigateToChangePassword
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
SettingsMenuItem(
|
)
|
||||||
icon = Icons.Default.Block,
|
}
|
||||||
title = "Blacklist",
|
Switch(
|
||||||
subtitle = "Blocked users",
|
checked = navStyle == "drawer",
|
||||||
onClick = onNavigateToBlacklist
|
onCheckedChange = { onToggleNavStyle() }
|
||||||
)
|
)
|
||||||
SettingsMenuItem(
|
}
|
||||||
icon = Icons.Default.FolderOpen,
|
}
|
||||||
title = "Storage",
|
|
||||||
subtitle = "Cache and media usage",
|
|
||||||
onClick = onNavigateToStorage
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Button(
|
// Settings group card
|
||||||
onClick = onLogout,
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
SettingsMenuItem(
|
||||||
|
icon = Icons.Default.FolderOpen,
|
||||||
|
title = "Storage",
|
||||||
|
subtitle = "Cache and media usage",
|
||||||
|
onClick = onNavigateToStorage,
|
||||||
|
showDivider = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.clickable(onClick = onLogout),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error
|
containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Text("Log out", modifier = Modifier.padding(vertical = 4.dp))
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Logout,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Log out",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -189,6 +249,7 @@ private fun ProfileSection(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(vertical = 20.dp),
|
.padding(vertical = 20.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
@ -229,7 +290,7 @@ private fun ProfileSection(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(horizontal = 32.dp)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -246,11 +307,14 @@ private fun AccountItem(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
.padding(vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
InitialsAvatar(
|
UserAvatar(
|
||||||
|
userId = account.userId,
|
||||||
|
fileId = account.avatarFileId,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
size = 40.dp,
|
size = 40.dp,
|
||||||
fontSize = 16.sp
|
fontSize = 16.sp
|
||||||
@ -292,7 +356,8 @@ private fun SettingsMenuItem(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
|
showDivider: Boolean = true
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -301,13 +366,21 @@ private fun SettingsMenuItem(
|
|||||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
icon,
|
modifier = Modifier
|
||||||
contentDescription = null,
|
.size(36.dp)
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
.clip(RoundedCornerShape(10.dp))
|
||||||
modifier = Modifier.size(24.dp)
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||||
)
|
contentAlignment = Alignment.Center
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
@ -323,7 +396,15 @@ private fun SettingsMenuItem(
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDivider) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||||
|
thickness = 0.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,9 +54,9 @@ fun StorageScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,30 +2,31 @@ package org.yobble.messenger.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
// Telegram-style primary palette
|
// Primary palette
|
||||||
val TelegramBlue = Color(0xFF2AABEE)
|
val YobbleBlue = Color(0xFF5AB1FD)
|
||||||
val TelegramBlueDark = Color(0xFF0088CC)
|
val YobbleBlueDark = Color(0xFF3A8FD4)
|
||||||
val TelegramBlueLight = Color(0xFF6DD0FF)
|
val YobbleBlueLight = Color(0xFF71B1FF)
|
||||||
|
|
||||||
// Light theme colors
|
// Light theme colors
|
||||||
val LightBackground = Color(0xFFF7F7F8)
|
val LightBackground = Color(0xFFF5F7FA)
|
||||||
val LightSurface = Color(0xFFFFFFFF)
|
val LightSurface = Color(0xFFFFFFFF)
|
||||||
val LightSurfaceVariant = Color(0xFFEFEFF4)
|
val LightSurfaceVariant = Color(0xFFEBEEF3)
|
||||||
val LightOnBackground = Color(0xFF1C1C1E)
|
val LightOnBackground = Color(0xFF1A1C20)
|
||||||
val LightOnSurface = Color(0xFF1C1C1E)
|
val LightOnSurface = Color(0xFF1A1C20)
|
||||||
val LightOnSurfaceVariant = Color(0xFF8E8E93)
|
val LightOnSurfaceVariant = Color(0xFF6E7787)
|
||||||
val LightOutline = Color(0xFFC7C7CC)
|
val LightOutline = Color(0xFFD0D5DD)
|
||||||
|
|
||||||
// Dark theme colors
|
// Dark theme colors — deep atmospheric blues
|
||||||
val DarkBackground = Color(0xFF1C1C1E)
|
val DarkBackground = Color(0xFF070F18)
|
||||||
val DarkSurface = Color(0xFF2C2C2E)
|
val DarkSurface = Color(0xFF101A26)
|
||||||
val DarkSurfaceVariant = Color(0xFF3A3A3C)
|
val DarkSurfaceVariant = Color(0xFF1B2735)
|
||||||
val DarkOnBackground = Color(0xFFF2F2F7)
|
val DarkSurfaceContainerHighest = Color(0xFF243242)
|
||||||
val DarkOnSurface = Color(0xFFF2F2F7)
|
val DarkOnBackground = Color(0xFFE6EEFD)
|
||||||
val DarkOnSurfaceVariant = Color(0xFF8E8E93)
|
val DarkOnSurface = Color(0xFFE6EEFD)
|
||||||
val DarkOutline = Color(0xFF48484A)
|
val DarkOnSurfaceVariant = Color(0xFF7A8899)
|
||||||
|
val DarkOutline = Color(0xFF2E3D4F)
|
||||||
|
|
||||||
// Semantic colors
|
// Semantic colors
|
||||||
val ErrorRed = Color(0xFFFF3B30)
|
val ErrorRed = Color(0xFFD7383B)
|
||||||
val SuccessGreen = Color(0xFF34C759)
|
val SuccessGreen = Color(0xFF34C759)
|
||||||
val OnPrimary = Color(0xFFFFFFFF)
|
val OnPrimary = Color(0xFFFFFFFF)
|
||||||
|
|||||||
@ -11,11 +11,11 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = TelegramBlue,
|
primary = YobbleBlue,
|
||||||
onPrimary = OnPrimary,
|
onPrimary = OnPrimary,
|
||||||
primaryContainer = TelegramBlueLight,
|
primaryContainer = YobbleBlueLight,
|
||||||
onPrimaryContainer = TelegramBlueDark,
|
onPrimaryContainer = YobbleBlueDark,
|
||||||
secondary = TelegramBlueDark,
|
secondary = YobbleBlueDark,
|
||||||
onSecondary = OnPrimary,
|
onSecondary = OnPrimary,
|
||||||
background = LightBackground,
|
background = LightBackground,
|
||||||
onBackground = LightOnBackground,
|
onBackground = LightOnBackground,
|
||||||
@ -29,11 +29,11 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = TelegramBlue,
|
primary = YobbleBlue,
|
||||||
onPrimary = OnPrimary,
|
onPrimary = OnPrimary,
|
||||||
primaryContainer = TelegramBlueDark,
|
primaryContainer = YobbleBlueDark,
|
||||||
onPrimaryContainer = TelegramBlueLight,
|
onPrimaryContainer = YobbleBlueLight,
|
||||||
secondary = TelegramBlueLight,
|
secondary = YobbleBlueLight,
|
||||||
onSecondary = DarkOnBackground,
|
onSecondary = DarkOnBackground,
|
||||||
background = DarkBackground,
|
background = DarkBackground,
|
||||||
onBackground = DarkOnBackground,
|
onBackground = DarkOnBackground,
|
||||||
@ -41,7 +41,9 @@ private val DarkColorScheme = darkColorScheme(
|
|||||||
onSurface = DarkOnSurface,
|
onSurface = DarkOnSurface,
|
||||||
surfaceVariant = DarkSurfaceVariant,
|
surfaceVariant = DarkSurfaceVariant,
|
||||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||||
|
surfaceContainerHighest = DarkSurfaceContainerHighest,
|
||||||
outline = DarkOutline,
|
outline = DarkOutline,
|
||||||
|
outlineVariant = DarkOutline,
|
||||||
error = ErrorRed,
|
error = ErrorRed,
|
||||||
onError = OnPrimary
|
onError = OnPrimary
|
||||||
)
|
)
|
||||||
@ -66,4 +68,4 @@ fun YobbleTheme(
|
|||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
package org.yobble.messenger.util
|
package org.yobble.messenger.util
|
||||||
|
|
||||||
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
fun formatUtcToLocalTime(isoString: String?): String {
|
fun formatUtcToLocalTime(isoString: String?): String {
|
||||||
if (isoString.isNullOrBlank()) return ""
|
if (isoString.isNullOrBlank()) return ""
|
||||||
@ -18,22 +17,29 @@ fun formatUtcToLocalTime(isoString: String?): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun formatLastSeen(secondsAgo: Int?): String {
|
fun formatLastSeen(lastSeenAt: String?): String {
|
||||||
if (secondsAgo == null) return ""
|
if (lastSeenAt.isNullOrBlank()) return ""
|
||||||
|
|
||||||
if (secondsAgo < 60) return "online"
|
return try {
|
||||||
|
val seenTime = ZonedDateTime.parse(lastSeenAt).toInstant()
|
||||||
|
val now = Instant.now()
|
||||||
|
val seconds = Duration.between(seenTime, now).seconds
|
||||||
|
|
||||||
val minutes = secondsAgo / 60
|
if (seconds < 60) return "online"
|
||||||
if (minutes < 60) return "last seen ${minutes}m ago"
|
|
||||||
|
|
||||||
val hours = minutes / 60
|
val minutes = seconds / 60
|
||||||
if (hours < 24) return "last seen ${hours}h ago"
|
if (minutes < 60) return "last seen ${minutes}m ago"
|
||||||
|
|
||||||
val days = hours / 24
|
val hours = minutes / 60
|
||||||
if (days == 1) return "last seen yesterday"
|
if (hours < 24) return "last seen ${hours}h ago"
|
||||||
if (days < 7) return "last seen ${days}d ago"
|
|
||||||
|
|
||||||
val seen = Instant.now().minusSeconds(secondsAgo.toLong())
|
val days = hours / 24
|
||||||
val seenDate = seen.atZone(ZoneId.systemDefault()).toLocalDate()
|
if (days == 1L) return "last seen yesterday"
|
||||||
return "last seen ${seenDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}"
|
if (days < 7) return "last seen ${days}d ago"
|
||||||
|
|
||||||
|
val seenDate = seenTime.atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
"last seen ${seenDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}"
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
app/src/main/res/values-v29/themes.xml
Normal file
10
app/src/main/res/values-v29/themes.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Yobble" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:enforceNavigationBarContrast">false</item>
|
||||||
|
<item name="android:enforceStatusBarContrast">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@ -1,5 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
<style name="Theme.Yobble" parent="android:Theme.Material.NoActionBar">
|
||||||
<style name="Theme.Yobble" parent="android:Theme.Material.Light.NoActionBar" />
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
</resources>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ firebaseBom = "33.7.0"
|
|||||||
googleServices = "4.4.2"
|
googleServices = "4.4.2"
|
||||||
socketIo = "2.1.1"
|
socketIo = "2.1.1"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
|
emojiPicker = "1.5.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@ -62,6 +63,7 @@ firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging
|
|||||||
socket-io-client = { group = "io.socket", name = "socket.io-client", version.ref = "socketIo" }
|
socket-io-client = { group = "io.socket", name = "socket.io-client", version.ref = "socketIo" }
|
||||||
|
|
||||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
androidx-emoji2-emojipicker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emojiPicker" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user