[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:
YaAndreyIgorevich 2026-04-03 01:05:08 +07:00
parent 8a6035be49
commit 4f3df5e440
46 changed files with 2127 additions and 765 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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